diff --git a/docs-sources.yml b/docs-sources.yml index 65e0228..c37cfe8 100644 --- a/docs-sources.yml +++ b/docs-sources.yml @@ -12,7 +12,7 @@ sources: ref: main path: happyview-modules/CHANGELOG.md trackRelease: true - releaseTagPrefix: '@hypercerts-org/hypercerts-api@' + releaseTagPrefix: '@hypercerts-org/happyview-modules@' - id: cgs-changelog title: Certified Group Service changelog diff --git a/lib/external-docs-snapshot.js b/lib/external-docs-snapshot.js index 9fdba44..0f27a48 100644 --- a/lib/external-docs-snapshot.js +++ b/lib/external-docs-snapshot.js @@ -107,15 +107,10 @@ async function fetchSourceUpdatedAt(source, token) { /** Read a published stable GitHub release, never a package.json development version. */ async function fetchLatestRelease(source, token) { - const url = `https://api.github.com/repos/${source.repo}/releases/latest`; - const response = await fetch(url, { - headers: getGitHubHeaders(token, 'application/vnd.github+json', 'hypercerts-docs-build'), - cache: 'no-store', - }); - // No published release is a supported state. The Markdown fetch separately verifies repository access. - if (response.status === 404) return null; - if (!response.ok) throw new Error(`Release metadata for ${source.id} returned ${response.status}. Refusing to label a failed lookup as unreleased. Set DOCS_SOURCE_TOKEN or run gh auth login locally.`); - const release = await response.json(); + const release = source.releaseTagPrefix + ? await fetchLatestPrefixedRelease(source, token) + : await fetchRepositoryLatestRelease(source, token); + if (!release) return null; const tag = release.tag_name || ''; let versionTag = tag; if (source.releaseTagPrefix) { @@ -136,6 +131,34 @@ async function fetchLatestRelease(source, token) { }; } +async function fetchReleaseMetadata(url, source, token) { + const response = await fetch(url, { + headers: getGitHubHeaders(token, 'application/vnd.github+json', 'hypercerts-docs-build'), + cache: 'no-store', + }); + // No published release is a supported state. The Markdown fetch separately verifies repository access. + if (response.status === 404) return null; + if (!response.ok) throw new Error(`Release metadata for ${source.id} returned ${response.status}. Refusing to label a failed lookup as unreleased. Set DOCS_SOURCE_TOKEN or run gh auth login locally.`); + return response.json(); +} + +/** The repository's "latest" release, for repositories that release one package. */ +function fetchRepositoryLatestRelease(source, token) { + return fetchReleaseMetadata(`https://api.github.com/repos/${source.repo}/releases/latest`, source, token); +} + +/** + * The newest stable release tagged with the source's prefix. A repository that releases + * several packages has one "latest" release for all of them, so it cannot be used. + */ +async function fetchLatestPrefixedRelease(source, token) { + const releases = await fetchReleaseMetadata(`https://api.github.com/repos/${source.repo}/releases?per_page=100`, source, token); + if (!releases || releases.length === 0) return null; + const release = releases.find(item => !item.draft && !item.prerelease && (item.tag_name || '').startsWith(source.releaseTagPrefix)); + if (!release) throw new Error(`No stable release of ${source.id} is tagged "${source.releaseTagPrefix}.." among the latest ${releases.length} releases of ${source.repo}. Check releaseTagPrefix.`); + return release; +} + /** * Fetch one immutable Markdown snapshot used by rendering, search, raw exports, and fingerprinting. */ diff --git a/test/external-docs-snapshot.test.js b/test/external-docs-snapshot.test.js index b5a73fe..bee57cd 100644 --- a/test/external-docs-snapshot.test.js +++ b/test/external-docs-snapshot.test.js @@ -129,7 +129,7 @@ test('published stable release metadata is captured in the build snapshot', asyn assert.equal(snapshot.release.url, 'https://github.com/hypercerts-org/ePDS/releases/tag/v1.4.12'); }); -test('tracks the API release behind its scoped package tag and rejects another package prefix', async context => { +test('tracks the API release behind its scoped package tag among the repository\'s other packages', async context => { const originalFetch = global.fetch; context.after(() => { global.fetch = originalFetch; }); const apiSource = { @@ -137,25 +137,31 @@ test('tracks the API release behind its scoped package tag and rejects another p title: 'Hypercerts API changelog', repo: 'hypercerts-org/api', ref: 'main', - path: 'api/CHANGELOG.md', + path: 'happyview-modules/CHANGELOG.md', trackRelease: true, - releaseTagPrefix: '@hypercerts-org/hypercerts-api@', + releaseTagPrefix: '@hypercerts-org/happyview-modules@', }; - const snapshotForTag = tag => { - global.fetch = async url => String(url).endsWith('/releases/latest') - ? response({ json: { tag_name: tag, published_at: '2026-10-06T16:12:16Z', draft: false, prerelease: false } }) - : String(url).includes('/commits?') ? response({ json: [] }) : response({ text: '# @hypercerts-org/hypercerts-api\n\n## 0.1.0' }); + const release = (tag, extra = {}) => ({ tag_name: tag, published_at: '2026-11-03T09:22:45Z', draft: false, prerelease: false, ...extra }); + const snapshotFor = releases => { + global.fetch = async url => String(url).includes('/releases?') + ? response({ json: releases }) + : String(url).includes('/commits?') ? response({ json: [] }) : response({ text: '# @hypercerts-org/happyview-modules\n\n## 0.3.0' }); return collectSourceSnapshot(apiSource); }; - const snapshot = await snapshotForTag('@hypercerts-org/hypercerts-api@0.1.0'); - assert.equal(snapshot.release.version, '0.1.0'); - assert.equal(snapshot.release.url, 'https://github.com/hypercerts-org/api/releases/tag/%40hypercerts-org%2Fhypercerts-api%400.1.0'); - assert.equal(snapshot.path, 'api/CHANGELOG.md'); - await assert.rejects( - () => snapshotForTag('@hypercerts-org/another-package@0.1.0'), - /stable semantic release tag/, - ); + // The repository's newest release belongs to another package; the API's is found by its prefix. + const snapshot = await snapshotFor([ + release('@hypercerts-org/api@0.1.0'), + release('@hypercerts-org/happyview-modules@0.4.0-beta.1', { prerelease: true }), + release('@hypercerts-org/happyview-modules@0.3.0'), + release('@hypercerts-org/hypercerts-api@0.2.0'), + ]); + assert.equal(snapshot.release.version, '0.3.0'); + assert.equal(snapshot.release.url, 'https://github.com/hypercerts-org/api/releases/tag/%40hypercerts-org%2Fhappyview-modules%400.3.0'); + assert.equal(snapshot.path, 'happyview-modules/CHANGELOG.md'); + assert.equal(await snapshotFor([]).then(item => item.release), null); + await assert.rejects(() => snapshotFor([release('@hypercerts-org/api@0.1.0')]), /No stable release of api-changelog/); + await assert.rejects(() => snapshotFor([release('@hypercerts-org/happyview-modules@0.3')]), /stable semantic release tag/); }); test('an accessible repository without a GitHub release is explicitly unreleased', async context => { diff --git a/test/external-docs.test.js b/test/external-docs.test.js index 1a46866..cd9fd3f 100644 --- a/test/external-docs.test.js +++ b/test/external-docs.test.js @@ -205,7 +205,7 @@ test('configures API releases from the package changelog and scoped tag', () => ref: 'main', path: 'happyview-modules/CHANGELOG.md', trackRelease: true, - releaseTagPrefix: '@hypercerts-org/hypercerts-api@', + releaseTagPrefix: '@hypercerts-org/happyview-modules@', }); const pagePath = join(__dirname, '..', 'pages', 'releases', 'api.md'); diff --git a/test/releases.test.js b/test/releases.test.js index 4c5cdb5..8fab52b 100644 --- a/test/releases.test.js +++ b/test/releases.test.js @@ -13,7 +13,7 @@ function sources() { ref: 'main', path: component.id === 'api' ? 'happyview-modules/CHANGELOG.md' : 'CHANGELOG.md', trackRelease: true, - ...(component.id === 'api' ? { releaseTagPrefix: '@hypercerts-org/hypercerts-api@' } : {}), + ...(component.id === 'api' ? { releaseTagPrefix: '@hypercerts-org/happyview-modules@' } : {}), release: component.id === 'relay' ? null : { version: component.id === 'lexicons' ? '2.0.0' : component.id === 'api' ? '0.1.0' : '0.6.0', url: component.id === 'api'