From 9d8216228fbb12e890159067aa8624a42d2b36b2 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Wed, 12 Aug 2026 16:30:25 +0700 Subject: [PATCH] fix(docs): verify future release tag against candidate source Signed-off-by: Jeremi Joslin --- docs/site/docs/evidence-link-policy.md | 7 +++ docs/site/scripts/check-evidence-links.mjs | 41 +++++++++++++-- .../scripts/check-evidence-links.test.mjs | 52 ++++++++++++++++++- 3 files changed, 95 insertions(+), 5 deletions(-) diff --git a/docs/site/docs/evidence-link-policy.md b/docs/site/docs/evidence-link-policy.md index 6c9b3d057..e3c691467 100644 --- a/docs/site/docs/evidence-link-policy.md +++ b/docs/site/docs/evidence-link-policy.md @@ -46,6 +46,13 @@ The checker reads only local files and Git objects. It verifies that: - every linked repository path exists at that exact tag or commit - every root-relative documentation route exists at the selected source commit +The exact source tag of the newest validated release manifest is the only +pre-publication exception. While that tag does not exist, repository evidence +at the future tag is checked against the selected source commit. This lets +protected pre-tag CI verify the source that will receive the immutable tag +without creating the tag early. Any other missing tag still fails. Once the +release tag exists, the checker resolves and verifies that tag directly. + The release workflow passes its resolved tag commit with `--source-ref`, so current-documentation routes are verified against the same source that the release uses. The checker has no network fallback. A shallow or incomplete diff --git a/docs/site/scripts/check-evidence-links.mjs b/docs/site/scripts/check-evidence-links.mjs index 17f2550b9..c77b91b18 100644 --- a/docs/site/scripts/check-evidence-links.mjs +++ b/docs/site/scripts/check-evidence-links.mjs @@ -125,6 +125,27 @@ function gitObjectExists(repoRoot, object, gitCommand) { ); } +export function candidateTagFromValidationOutput(output) { + const match = /^validated .+: [A-Za-z0-9][A-Za-z0-9._-]{0,63} (\d+\.\d+\.\d+)\s*$/.exec( + output, + ); + return match ? `v${match[1]}` : undefined; +} + +function currentReleaseCandidateTag(repoRoot) { + const validator = resolve(repoRoot, 'release/scripts/registry-release'); + const result = spawnSync(validator, ['validate-current'], { + cwd: repoRoot, + encoding: 'utf8', + env: { ...process.env, GIT_NO_LAZY_FETCH: '1' }, + stdio: 'pipe', + }); + if (result.status !== 0) { + return undefined; + } + return candidateTagFromValidationOutput(result.stdout); +} + function safePathParts(parts) { try { return parts.map((part) => decodeURIComponent(part)); @@ -148,7 +169,12 @@ function validRepositoryPath(parts) { ); } -function checkRepositoryEvidence(repoRoot, rawUrl, gitCommand) { +function checkRepositoryEvidence( + repoRoot, + rawUrl, + gitCommand, + { candidateTag, sourceRef } = {}, +) { let url; try { url = new URL(rawUrl); @@ -189,7 +215,10 @@ function checkRepositoryEvidence(repoRoot, rawUrl, gitCommand) { } if (!gitObjectExists(repoRoot, `${commitish}^{commit}`, gitCommand)) { - return `references missing Git commit or tag ${ref}`; + if (ref !== candidateTag || !gitObjectExists(repoRoot, `${sourceRef}^{commit}`, gitCommand)) { + return `references missing Git commit or tag ${ref}`; + } + commitish = sourceRef; } const path = repositoryPath.join('/'); if (!gitObjectExists(repoRoot, `${commitish}^{commit}:${path}`, gitCommand)) { @@ -244,6 +273,7 @@ export function checkEvidenceLinks({ dataDir = resolve(scriptDir, '../src/data'), sourceRef = 'HEAD', gitCommand = 'git', + candidateTag, } = {}) { const errors = []; let evidence; @@ -256,7 +286,7 @@ export function checkEvidenceLinks({ for (const item of evidence) { const error = item.url.startsWith('/') ? checkCurrentDocsEvidence(repoRoot, sourceRef, item.url, gitCommand) - : checkRepositoryEvidence(repoRoot, item.url, gitCommand); + : checkRepositoryEvidence(repoRoot, item.url, gitCommand, { candidateTag, sourceRef }); if (error) { errors.push(`${item.location}: ${item.url}: ${error}`); } @@ -276,7 +306,10 @@ function sourceRefArgument(args) { if (process.argv[1] && resolve(process.argv[1]) === scriptPath) { try { - const result = checkEvidenceLinks({ sourceRef: sourceRefArgument(process.argv.slice(2)) }); + const repoRoot = resolve(scriptDir, '../../..'); + const sourceRef = sourceRefArgument(process.argv.slice(2)); + const candidateTag = currentReleaseCandidateTag(repoRoot); + const result = checkEvidenceLinks({ repoRoot, sourceRef, candidateTag }); if (result.errors.length > 0) { console.error('Evidence link check failed:'); for (const error of result.errors) { diff --git a/docs/site/scripts/check-evidence-links.test.mjs b/docs/site/scripts/check-evidence-links.test.mjs index 945339dde..47ac4173c 100644 --- a/docs/site/scripts/check-evidence-links.test.mjs +++ b/docs/site/scripts/check-evidence-links.test.mjs @@ -13,7 +13,11 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; -import { checkEvidenceLinks, extractEvidenceUrlsFromYaml } from './check-evidence-links.mjs'; +import { + candidateTagFromValidationOutput, + checkEvidenceLinks, + extractEvidenceUrlsFromYaml, +} from './check-evidence-links.mjs'; const here = dirname(fileURLToPath(import.meta.url)); const repositoryRoot = resolve(here, '../../..'); @@ -98,6 +102,52 @@ test('accepts semver tags, full commits, and root-relative current docs', (t) => }); }); +test('reads the candidate tag only from validated current-manifest output', () => { + assert.equal( + candidateTagFromValidationOutput( + 'validated /tmp/registry-stack-beta-30.yaml: beta-30 0.20.0\n', + ), + 'v0.20.0', + ); + assert.equal(candidateTagFromValidationOutput('error: validation failed\n'), undefined); +}); + +test('checks the exact unpublished release tag against the selected source', (t) => { + const { root, commit } = createRepository(t); + const dataDir = writeEvidenceData(root, { + contractUrls: [ + 'https://github.com/registrystack/registry-stack/blob/v9.9.9/source/file.md', + ], + }); + + assert.deepEqual( + checkEvidenceLinks({ + repoRoot: root, + dataDir, + sourceRef: commit, + candidateTag: 'v9.9.9', + }), + { checked: 1, errors: [] }, + ); +}); + +test('does not substitute the selected source for another missing tag', (t) => { + const { root, commit } = createRepository(t); + const dataDir = writeEvidenceData(root, { + contractUrls: [ + 'https://github.com/registrystack/registry-stack/blob/v9.9.8/source/file.md', + ], + }); + + const result = checkEvidenceLinks({ + repoRoot: root, + dataDir, + sourceRef: commit, + candidateTag: 'v9.9.9', + }); + assert.match(result.errors[0], /missing Git commit or tag v9\.9\.8/); +}); + test('rejects branches, short commits, missing refs, and missing paths', async (t) => { const { root, commit } = createRepository(t); const cases = [