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') {