From efe31f108b56a7bb59b8933c01d26a39a1ca1dac Mon Sep 17 00:00:00 2001 From: asher <82265836+bytelazy@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:10:35 +0800 Subject: [PATCH] fix(ci): fence the forward-roll baseline to an ancestor of the change under test (#4447) resolveRegistryNightlyPredecessor reads whatever nightly tarball the npm registry is currently advertising. CI does not check that this tarball was published from a commit reachable from the workspace under test, so the forward roll can run backwards: a newer main-branch nightly writes durable state that the older workspace code then reads, producing a false failure (the new record fails to decode) or, worse, a silent no-op (when both sides happen to speak the same vocabulary). npm version metadata carries gitHead for packages published from a repo, and the CI checkout already uses fetch-depth: 0, so we have the information without an extra fetch. resolveRegistryNightlyPredecessor now accepts an optional fencedAncestorHead and, when set, fails loudly when gitHead is missing or not an ancestor of that ref. The two CI lanes that resolve the forward-roll baseline pass HEAD as the fence, and the public CLI signature carries the resolved gitHead so the assertion can match it later if it ever needs to. Generated-by: Claude (Claude Code) --- .github/workflows/ci.yml | 2 +- .github/workflows/cli-package-validation.yml | 2 +- scripts/release-cli-publication.mjs | 50 ++++++++++++++-- scripts/release-cli-publication.test.mjs | 60 ++++++++++++++++++++ 4 files changed, 107 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 714b545eeb..85bf6823df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -294,7 +294,7 @@ jobs: - id: forward-roll-baseline name: Resolve the published forward-roll baseline if: steps.plan.outputs.state_root_compat == 'true' - run: node scripts/release-cli-publication.mjs resolve-nightly-predecessor "$GITHUB_OUTPUT" + run: node scripts/release-cli-publication.mjs resolve-nightly-predecessor "$GITHUB_OUTPUT" HEAD - name: Download the forward-roll baseline if: steps.plan.outputs.state_root_compat == 'true' diff --git a/.github/workflows/cli-package-validation.yml b/.github/workflows/cli-package-validation.yml index a11d653674..cd1d1fd80b 100644 --- a/.github/workflows/cli-package-validation.yml +++ b/.github/workflows/cli-package-validation.yml @@ -356,7 +356,7 @@ jobs: # is now this job's output. - name: Resolve the current npm Nightly as immutable evidence id: predecessor - run: node scripts/release-cli-publication.mjs resolve-nightly-predecessor "$GITHUB_OUTPUT" + run: node scripts/release-cli-publication.mjs resolve-nightly-predecessor "$GITHUB_OUTPUT" HEAD # Three runs of one script against one sandbox, not three runners. Two of # these transitions are between tarballs that were published and frozen, # so nothing in a pull request can change their outcome except the diff --git a/scripts/release-cli-publication.mjs b/scripts/release-cli-publication.mjs index 0065ce9540..60200a5178 100644 --- a/scripts/release-cli-publication.mjs +++ b/scripts/release-cli-publication.mjs @@ -17,8 +17,9 @@ * under the License. */ +import { execFileSync } from 'node:child_process'; import { appendFileSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { basename, join, resolve } from 'node:path'; +import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createHash } from 'node:crypto'; import { CLI_RELEASE_ARTIFACT_LIMITS } from './release-cli-artifact-policy.mjs'; @@ -33,6 +34,7 @@ const REGISTRY_ORIGIN = 'https://registry.npmjs.org'; const REPOSITORY = 'apache/maka'; const PUBLICATION_WORKFLOW_PATH = '.github/workflows/npm-publication.yml'; const REGISTRY_REQUEST_TIMEOUT_MS = 30_000; +const DEFAULT_REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); const RELEASE_RECORD_KEYS = [ 'schemaVersion', 'packageName', @@ -218,7 +220,33 @@ export async function fetchRegistryRelease({ return { ...record, tarballPath, sha256 }; } -export async function resolveRegistryNightlyPredecessor({ fetchImpl = fetch } = {}) { +export function assertCommitIsAncestor({ + commit, + head = 'HEAD', + repoRoot = DEFAULT_REPO_ROOT, + exec = execFileSync, +}) { + if (typeof commit !== 'string' || !/^[0-9a-f]{7,40}$/i.test(commit)) { + throw new Error(`Invalid git commit SHA for ancestor check: ${commit}`); + } + try { + exec('git', ['merge-base', '--is-ancestor', commit, head], { + cwd: repoRoot, + stdio: ['ignore', 'ignore', 'pipe'], + }); + } catch { + throw new Error( + `Registry Nightly gitHead ${commit} is not an ancestor of ${head}; forward-roll baseline must precede the change under test (#4447)`, + ); + } +} + +export async function resolveRegistryNightlyPredecessor({ + fetchImpl = fetch, + fencedAncestorHead, + repoRoot = DEFAULT_REPO_ROOT, + exec = execFileSync, +} = {}) { const packageMetadata = await fetchJson( fetchImpl, `${REGISTRY_ORIGIN}/${PACKAGE_NAME}`, @@ -237,6 +265,16 @@ export async function resolveRegistryNightlyPredecessor({ fetchImpl = fetch } = throw new Error('Registry Nightly identity does not match its dist-tag'); } + const gitHead = versionMetadata.gitHead; + if (fencedAncestorHead !== undefined) { + if (!gitHead) { + throw new Error( + `Registry Nightly ${version} is missing gitHead metadata required for ancestor fencing (#4447)`, + ); + } + assertCommitIsAncestor({ commit: gitHead, head: fencedAncestorHead, repoRoot, exec }); + } + const tarball = `${PACKAGE_NAME}-${version}.tgz`; const tarballUrl = parseRegistryTarballUrl(versionMetadata.dist?.tarball, tarball); const integrity = parseSha512Integrity(versionMetadata.dist?.integrity); @@ -244,6 +282,7 @@ export async function resolveRegistryNightlyPredecessor({ fetchImpl = fetch } = version, tarballUrl, integrity, + ...(gitHead ? { gitHead } : {}), }; } @@ -621,13 +660,14 @@ async function main() { }); return; } - if (command === 'resolve-nightly-predecessor' && args.length === 1) { - const [output] = args; - const predecessor = await resolveRegistryNightlyPredecessor(); + if (command === 'resolve-nightly-predecessor' && (args.length === 1 || args.length === 2)) { + const [output, fencedAncestorHead] = args; + const predecessor = await resolveRegistryNightlyPredecessor({ fencedAncestorHead }); appendOutputs(output, { version: predecessor.version, tarball_url: predecessor.tarballUrl, integrity: predecessor.integrity, + ...(predecessor.gitHead ? { git_head: predecessor.gitHead } : {}), }); return; } diff --git a/scripts/release-cli-publication.test.mjs b/scripts/release-cli-publication.test.mjs index 28cc5d13fa..084ca4d0d7 100644 --- a/scripts/release-cli-publication.test.mjs +++ b/scripts/release-cli-publication.test.mjs @@ -26,6 +26,7 @@ import { join, resolve } from 'node:path'; import test from 'node:test'; import { CLI_RELEASE_ARTIFACT_LIMITS } from './release-cli-artifact-policy.mjs'; import { + assertCommitIsAncestor, assertRegistryNightlyPredecessor, fetchRegistryRelease, parseCliNightlyVersion, @@ -332,6 +333,64 @@ test('a newer Nightly invalidates previously qualified predecessor evidence', as ); }); +test('fences the predecessor commit to an ancestor of the change under test (#4447)', async () => { + const ancestorCommit = '1111111111111111111111111111111111111111'; + const nonAncestorCommit = '2222222222222222222222222222222222222222'; + const fixture = { + ...createCandidate('0.2.0-dev.42.20260829', '0.2.0'), + gitHead: ancestorCommit, + }; + + const execStub = (cmd, args) => { + assert.equal(cmd, 'git'); + assert.equal(args[0], 'merge-base'); + assert.equal(args[1], '--is-ancestor'); + if (args[2] === ancestorCommit) return; + const err = new Error('not an ancestor'); + err.status = 1; + throw err; + }; + + // 1. Success when gitHead is an ancestor + const predecessor = await resolveRegistryNightlyPredecessor({ + fetchImpl: registryFetch({ fixture }), + fencedAncestorHead: 'HEAD', + exec: execStub, + }); + assert.equal(predecessor.gitHead, ancestorCommit); + + // 2. Fails loudly when gitHead is NOT an ancestor + const nonAncestorFixture = { + ...createCandidate('0.2.0-dev.42.20260829', '0.2.0'), + gitHead: nonAncestorCommit, + }; + await assert.rejects( + resolveRegistryNightlyPredecessor({ + fetchImpl: registryFetch({ fixture: nonAncestorFixture }), + fencedAncestorHead: 'HEAD', + exec: execStub, + }), + /is not an ancestor of HEAD; forward-roll baseline must precede the change under test/, + ); + + // 3. Fails loudly when gitHead is missing from registry metadata and fence was requested + const missingGitHeadFixture = createCandidate('0.2.0-dev.42.20260829', '0.2.0'); + await assert.rejects( + resolveRegistryNightlyPredecessor({ + fetchImpl: registryFetch({ fixture: missingGitHeadFixture }), + fencedAncestorHead: 'HEAD', + exec: execStub, + }), + /is missing gitHead metadata required for ancestor fencing/, + ); + + // 4. Invalid commit SHA rejected + assert.throws( + () => assertCommitIsAncestor({ commit: 'not-a-sha', exec: execStub }), + /Invalid git commit SHA/, + ); +}); + test('signature audit must contain Maka provenance for the finalized version', () => { const fixture = createPreparedCandidate(); const verified = { @@ -616,6 +675,7 @@ function registryFetch({ fixture, bytes = fixture.bytes }) { name: 'maka-agent', version: fixture.version, dist: { tarball: tarballUrl, integrity, shasum }, + ...(fixture.gitHead ? { gitHead: fixture.gitHead } : {}), }); } if (url === 'https://registry.npmjs.org/maka-agent') {