Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/cli-package-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 45 additions & 5 deletions scripts/release-cli-publication.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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',
Expand Down Expand Up @@ -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}`,
Expand All @@ -237,13 +265,24 @@ 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);
return {
version,
tarballUrl,
integrity,
...(gitHead ? { gitHead } : {}),
};
}

Expand Down Expand Up @@ -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;
}
Expand Down
60 changes: 60 additions & 0 deletions scripts/release-cli-publication.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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') {
Expand Down