diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb857dca1d..1d1ee6a568 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -345,7 +345,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 9fefc67efd..383aff6b63 100644 --- a/.github/workflows/cli-package-validation.yml +++ b/.github/workflows/cli-package-validation.yml @@ -44,6 +44,9 @@ on: - 'packages/storage/src/native-file-lock.ts' - 'scripts/generate-runtime-host-peer-*' - 'scripts/release-cli-package.mjs' + - 'scripts/release-cli-publication.mjs' + - 'scripts/release-cli-publication.test.mjs' + - 'scripts/release-cli-workflow-policy.test.mjs' - 'scripts/qualify-released-cli-state-root.mjs' - 'scripts/qualify-released-cli-state-root.test.mjs' - 'scripts/released-cli-state-root-fixture.mjs' @@ -76,6 +79,9 @@ on: release_predecessor_integrity: description: npm SHA-512 integrity of the Nightly tarball qualified against this candidate value: ${{ jobs.state-root-qualification.outputs.release_predecessor_integrity }} + release_predecessor_source_commit: + description: Source commit proven for the npm Nightly predecessor + value: ${{ jobs.state-root-qualification.outputs.release_predecessor_source_commit }} workflow_dispatch: permissions: @@ -339,10 +345,12 @@ jobs: release_predecessor_version: ${{ steps.predecessor.outputs.version }} release_predecessor_tarball_url: ${{ steps.predecessor.outputs.tarball_url }} release_predecessor_integrity: ${{ steps.predecessor.outputs.integrity }} + release_predecessor_source_commit: ${{ steps.predecessor.outputs.source_commit }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.source_commit || github.sha }} + fetch-depth: 0 persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -366,7 +374,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 @@ -379,6 +387,7 @@ jobs: MAKA_QUALIFICATION_BWRAP_USE_SUDO: '1' PREDECESSOR_TARBALL_URL: ${{ steps.predecessor.outputs.tarball_url }} PREDECESSOR_INTEGRITY: ${{ steps.predecessor.outputs.integrity }} + PREDECESSOR_SOURCE_COMMIT: ${{ steps.predecessor.outputs.source_commit }} run: | set -euo pipefail evidence_root="$RUNNER_TEMP/released-state-root" @@ -458,11 +467,13 @@ jobs: PREDECESSOR_VERSION: ${{ steps.predecessor.outputs.version }} PREDECESSOR_TARBALL_URL: ${{ steps.predecessor.outputs.tarball_url }} PREDECESSOR_INTEGRITY: ${{ steps.predecessor.outputs.integrity }} + PREDECESSOR_SOURCE_COMMIT: ${{ steps.predecessor.outputs.source_commit }} run: | node scripts/release-cli-publication.mjs assert-nightly-predecessor \ "$PREDECESSOR_VERSION" \ "$PREDECESSOR_TARBALL_URL" \ - "$PREDECESSOR_INTEGRITY" + "$PREDECESSOR_INTEGRITY" \ + "$PREDECESSOR_SOURCE_COMMIT" eval: name: Validate installed CLI Eval diff --git a/.github/workflows/npm-publication.yml b/.github/workflows/npm-publication.yml index 721cff4e31..302b08ca95 100644 --- a/.github/workflows/npm-publication.yml +++ b/.github/workflows/npm-publication.yml @@ -153,11 +153,13 @@ jobs: PREDECESSOR_VERSION: ${{ needs.cli.outputs.release_predecessor_version }} PREDECESSOR_TARBALL_URL: ${{ needs.cli.outputs.release_predecessor_tarball_url }} PREDECESSOR_INTEGRITY: ${{ needs.cli.outputs.release_predecessor_integrity }} + PREDECESSOR_SOURCE_COMMIT: ${{ needs.cli.outputs.release_predecessor_source_commit }} run: | node scripts/release-cli-publication.mjs assert-nightly-predecessor \ "$PREDECESSOR_VERSION" \ "$PREDECESSOR_TARBALL_URL" \ - "$PREDECESSOR_INTEGRITY" + "$PREDECESSOR_INTEGRITY" \ + "$PREDECESSOR_SOURCE_COMMIT" current="$(npm view maka-agent dist-tags.nightly --registry https://registry.npmjs.org/)" node scripts/product-nightly.mjs assert-channel-advance "$NIGHTLY_VERSION" "$current" diff --git a/.github/workflows/release-cli-stage.yml b/.github/workflows/release-cli-stage.yml index 165bdad170..1f4516765e 100644 --- a/.github/workflows/release-cli-stage.yml +++ b/.github/workflows/release-cli-stage.yml @@ -178,6 +178,7 @@ jobs: PREDECESSOR_VERSION: ${{ needs.validate.outputs.release_predecessor_version }} PREDECESSOR_TARBALL_URL: ${{ needs.validate.outputs.release_predecessor_tarball_url }} PREDECESSOR_INTEGRITY: ${{ needs.validate.outputs.release_predecessor_integrity }} + PREDECESSOR_SOURCE_COMMIT: ${{ needs.validate.outputs.release_predecessor_source_commit }} PRODUCT_SOURCE_COMMIT: ${{ needs.authorize.outputs.source_commit }} PRODUCT_TAG: ${{ needs.authorize.outputs.product_tag }} RELEASE_DIST_TAG: ${{ steps.release.outputs.dist_tag }} @@ -186,7 +187,8 @@ jobs: node scripts/release-cli-publication.mjs assert-nightly-predecessor \ "$PREDECESSOR_VERSION" \ "$PREDECESSOR_TARBALL_URL" \ - "$PREDECESSOR_INTEGRITY" + "$PREDECESSOR_INTEGRITY" \ + "$PREDECESSOR_SOURCE_COMMIT" node scripts/product-release-authority.mjs verify-draft \ "$PRODUCT_TAG" "$PRODUCT_SOURCE_COMMIT" "$GITHUB_REPOSITORY" diff --git a/scripts/release-cli-publication.mjs b/scripts/release-cli-publication.mjs index 0065ce9540..b8210cf8c0 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'; @@ -30,9 +31,16 @@ import { const PACKAGE_NAME = 'maka-agent'; const REGISTRY_ORIGIN = 'https://registry.npmjs.org'; +const REGISTRY_ATTESTATION_PATH = `${REGISTRY_ORIGIN}/-/npm/v1/attestations`; 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 SLSA_PROVENANCE_PREDICATE = 'https://slsa.dev/provenance/v1'; +const SLSA_WORKFLOW_BUILD_TYPE = + 'https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1'; +const REPOSITORY_URL = `https://github.com/${REPOSITORY}`; +const MAIN_REF = 'refs/heads/main'; const RELEASE_RECORD_KEYS = [ 'schemaVersion', 'packageName', @@ -218,7 +226,92 @@ 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]{40}$/iu.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 (error) { + if (error?.status === 1) { + throw new Error( + `Registry Nightly source commit ${commit} is not an ancestor of ${head}; forward-roll baseline must precede the change under test (#4447)`, + ); + } + throw new Error( + `Unable to verify that Registry Nightly source commit ${commit} is an ancestor of ${head}; the checkout history or commit is unavailable (#4447)`, + { cause: error }, + ); + } +} + +export function parseRegistryNightlySourceCommit({ version, attestations }) { + // Nightlies are published from tarballs, so npm leaves versionMetadata.gitHead + // empty. SLSA provenance is the publisher-signed source identity we can fence. + if (!Array.isArray(attestations)) { + throw new Error(`Registry Nightly ${version} has no valid provenance attestations`); + } + const expectedSubject = `pkg:npm/${PACKAGE_NAME}@${version}`; + const expectedSourceUri = `git+${REPOSITORY_URL}@${MAIN_REF}`; + const statement = attestations.map(parseProvenanceStatement).find((candidate) => { + const definition = candidate?.predicate?.buildDefinition; + const workflow = definition?.externalParameters?.workflow; + const dependencies = definition?.resolvedDependencies; + return ( + candidate?._type === 'https://in-toto.io/Statement/v1' && + candidate?.predicateType === SLSA_PROVENANCE_PREDICATE && + candidate?.subject?.some((subject) => subject?.name === expectedSubject) && + definition?.buildType === SLSA_WORKFLOW_BUILD_TYPE && + workflow?.repository === REPOSITORY_URL && + workflow?.ref === MAIN_REF && + workflow?.path === PUBLICATION_WORKFLOW_PATH && + ['schedule', 'workflow_dispatch'].includes( + definition?.internalParameters?.github?.event_name, + ) && + Array.isArray(dependencies) && + dependencies.some( + (dependency) => + dependency?.uri === expectedSourceUri && + /^[0-9a-f]{40}$/iu.test(dependency?.digest?.gitCommit ?? ''), + ) + ); + }); + const sourceCommit = statement?.predicate?.buildDefinition?.resolvedDependencies?.find( + (dependency) => dependency?.uri === expectedSourceUri, + )?.digest?.gitCommit; + if (!sourceCommit) { + throw new Error( + `Registry Nightly ${version} has no valid SLSA source commit for ${REPOSITORY}@${MAIN_REF}`, + ); + } + return sourceCommit; +} + +async function fetchRegistryNightlySourceCommit({ version, fetchImpl }) { + const attestations = await fetchJson( + fetchImpl, + `${REGISTRY_ATTESTATION_PATH}/${PACKAGE_NAME}@${version}`, + 'Nightly provenance attestations', + 'application/json', + ); + return parseRegistryNightlySourceCommit({ version, attestations: attestations.attestations }); +} + +export async function resolveRegistryNightlyPredecessor({ + fetchImpl = fetch, + fencedAncestorHead, + includeSourceCommit = fencedAncestorHead !== undefined, + repoRoot = DEFAULT_REPO_ROOT, + exec = execFileSync, +} = {}) { const packageMetadata = await fetchJson( fetchImpl, `${REGISTRY_ORIGIN}/${PACKAGE_NAME}`, @@ -237,6 +330,19 @@ export async function resolveRegistryNightlyPredecessor({ fetchImpl = fetch } = throw new Error('Registry Nightly identity does not match its dist-tag'); } + let sourceCommit; + if (includeSourceCommit) { + sourceCommit = await fetchRegistryNightlySourceCommit({ version, fetchImpl }); + if (fencedAncestorHead !== undefined) { + assertCommitIsAncestor({ + commit: sourceCommit, + 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 +350,7 @@ export async function resolveRegistryNightlyPredecessor({ fetchImpl = fetch } = version, tarballUrl, integrity, + ...(sourceCommit ? { sourceCommit } : {}), }; } @@ -251,13 +358,18 @@ export async function assertRegistryNightlyPredecessor({ expectedVersion, expectedTarballUrl, expectedIntegrity, + expectedSourceCommit, fetchImpl = fetch, }) { - const current = await resolveRegistryNightlyPredecessor({ fetchImpl }); + const current = await resolveRegistryNightlyPredecessor({ + fetchImpl, + includeSourceCommit: expectedSourceCommit !== undefined, + }); if ( current.version !== expectedVersion || current.tarballUrl !== expectedTarballUrl || - current.integrity !== expectedIntegrity + current.integrity !== expectedIntegrity || + current.sourceCommit !== expectedSourceCommit ) { throw new Error( `Qualified npm Nightly predecessor ${expectedVersion} is no longer current; found ${current.version}`, @@ -621,22 +733,24 @@ 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.sourceCommit ? { source_commit: predecessor.sourceCommit } : {}), }); return; } - if (command === 'assert-nightly-predecessor' && args.length === 3) { - const [expectedVersion, expectedTarballUrl, expectedIntegrity] = args; + if (command === 'assert-nightly-predecessor' && (args.length === 3 || args.length === 4)) { + const [expectedVersion, expectedTarballUrl, expectedIntegrity, expectedSourceCommit] = args; await assertRegistryNightlyPredecessor({ expectedVersion, expectedTarballUrl, expectedIntegrity, + expectedSourceCommit, }); return; } diff --git a/scripts/release-cli-publication.test.mjs b/scripts/release-cli-publication.test.mjs index 28cc5d13fa..632bb5c373 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, @@ -33,6 +34,7 @@ import { prepareNightlyRelease, prepareSignatureAuditTree, prepareStageRelease, + parseRegistryNightlySourceCommit, resolveRegistryNightlyPredecessor, validateRegistryChannels, validateSignatureAudit, @@ -332,6 +334,117 @@ test('a newer Nightly invalidates previously qualified predecessor evidence', as ); }); +test('fences the Nightly source commit from SLSA provenance (#4447)', async () => { + const ancestorCommit = '1'.repeat(40); + const futureCommit = '2'.repeat(40); + const fixture = { + ...createCandidate('0.2.0-dev.42.20260829', '0.2.0'), + sourceCommit: ancestorCommit, + }; + const execStub = (command, args) => { + assert.equal(command, 'git'); + assert.deepEqual(args.slice(0, 3), ['merge-base', '--is-ancestor', ancestorCommit]); + }; + + const predecessor = await resolveRegistryNightlyPredecessor({ + fetchImpl: registryFetch({ fixture }), + fencedAncestorHead: 'HEAD', + exec: execStub, + }); + assert.equal(predecessor.sourceCommit, ancestorCommit); + + const nonAncestorFixture = { + ...fixture, + sourceCommit: futureCommit, + }; + await assert.rejects( + resolveRegistryNightlyPredecessor({ + fetchImpl: registryFetch({ fixture: nonAncestorFixture }), + fencedAncestorHead: 'HEAD', + exec: (command, args, options) => { + assert.equal(command, 'git'); + assert.equal(args[2], futureCommit); + const error = new Error('not an ancestor'); + error.status = 1; + throw error; + }, + }), + /is not an ancestor of HEAD/u, + ); + + const missingProvenanceFixture = { ...fixture, sourceCommit: undefined }; + await assert.rejects( + resolveRegistryNightlyPredecessor({ + fetchImpl: registryFetch({ fixture: missingProvenanceFixture }), + fencedAncestorHead: 'HEAD', + exec: execStub, + }), + /no valid SLSA source commit/u, + ); + + assert.throws( + () => assertCommitIsAncestor({ commit: 'not-a-sha', exec: execStub }), + /Invalid git commit SHA/u, + ); +}); + +test('distinguishes unavailable git history from a non-ancestor source', () => { + const commit = '3'.repeat(40); + const unavailable = new Error('unknown revision'); + unavailable.status = 128; + assert.throws( + () => + assertCommitIsAncestor({ + commit, + head: 'HEAD', + exec: () => { + throw unavailable; + }, + }), + /history or commit is unavailable/u, + ); +}); + +test('revalidates the provenance source commit when predecessor evidence is reused', async () => { + const sourceCommit = '5'.repeat(40); + const fixture = { ...createCandidate('0.2.0-dev.42.20260829'), sourceCommit }; + const current = await resolveRegistryNightlyPredecessor({ + fetchImpl: registryFetch({ fixture }), + includeSourceCommit: true, + }); + assert.equal(current.sourceCommit, sourceCommit); + await assert.doesNotReject( + assertRegistryNightlyPredecessor({ + expectedVersion: current.version, + expectedTarballUrl: current.tarballUrl, + expectedIntegrity: current.integrity, + expectedSourceCommit: sourceCommit, + fetchImpl: registryFetch({ fixture }), + }), + ); + await assert.rejects( + assertRegistryNightlyPredecessor({ + expectedVersion: current.version, + expectedTarballUrl: current.tarballUrl, + expectedIntegrity: current.integrity, + expectedSourceCommit: '6'.repeat(40), + fetchImpl: registryFetch({ fixture }), + }), + /is no longer current/u, + ); +}); + +test('rejects provenance that is not for the Maka main publication workflow', () => { + assert.throws( + () => + parseRegistryNightlySourceCommit({ + version: '0.2.0-dev.43.20260830', + attestations: [nightlyProvenanceBundle('0.2.0-dev.42.20260829', '4'.repeat(40))], + }), + /no valid SLSA source commit/u, + ); +}); + test('signature audit must contain Maka provenance for the finalized version', () => { const fixture = createPreparedCandidate(); const verified = { @@ -585,6 +698,43 @@ function provenanceBundle(mutate = () => {}) { }; } +function nightlyProvenanceBundle(version, sourceCommit) { + const statement = { + _type: 'https://in-toto.io/Statement/v1', + subject: [{ name: `pkg:npm/maka-agent@${version}`, digest: { sha512: 'a'.repeat(128) } }], + predicateType: 'https://slsa.dev/provenance/v1', + predicate: { + buildDefinition: { + buildType: 'https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1', + externalParameters: { + workflow: { + repository: 'https://github.com/apache/maka', + ref: 'refs/heads/main', + path: WORKFLOW_PATH, + }, + }, + resolvedDependencies: [ + { + uri: 'git+https://github.com/apache/maka@refs/heads/main', + digest: { gitCommit: sourceCommit }, + }, + ], + internalParameters: { github: { event_name: 'schedule' } }, + }, + runDetails: { builder: { id: 'https://github.com/actions/runner/github-hosted' } }, + }, + }; + return { + predicateType: 'https://slsa.dev/provenance/v1', + bundle: { + dsseEnvelope: { + payloadType: 'application/vnd.in-toto+json', + payload: Buffer.from(JSON.stringify(statement)).toString('base64'), + }, + }, + }; +} + function createCandidate(version = '0.2.0', sourceVersion = version) { const root = mkdtempSync(join(tmpdir(), 'maka-cli-publication-')); const releaseDirectory = join(root, 'packages/cli/release'); @@ -618,6 +768,14 @@ function registryFetch({ fixture, bytes = fixture.bytes }) { dist: { tarball: tarballUrl, integrity, shasum }, }); } + if (url === `https://registry.npmjs.org/-/npm/v1/attestations/maka-agent@${fixture.version}`) { + assert.equal(options.headers?.accept, 'application/json'); + return Response.json({ + attestations: fixture.sourceCommit + ? [nightlyProvenanceBundle(fixture.version, fixture.sourceCommit)] + : [], + }); + } if (url === 'https://registry.npmjs.org/maka-agent') { assert.equal(options.headers?.accept, 'application/vnd.npm.install-v1+json'); return Response.json({ 'dist-tags': { latest: fixture.version, nightly: fixture.version } }); diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index 743cd984c3..43128c8b77 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -63,7 +63,11 @@ test('CLI validation qualifies exact published State Roots without weakening art ); assert.match( workflow, - /id: predecessor\n\s+run: node scripts\/release-cli-publication\.mjs resolve-nightly-predecessor "\$GITHUB_OUTPUT"/u, + /release_predecessor_source_commit:[\s\S]*?jobs\.state-root-qualification\.outputs\.release_predecessor_source_commit/u, + ); + assert.match( + workflow, + /id: predecessor\n\s+run: node scripts\/release-cli-publication\.mjs resolve-nightly-predecessor "\$GITHUB_OUTPUT" HEAD/u, ); assert.match(workflow, /state-root-qualification:\n[\s\S]*?needs: build\n/u); assert.doesNotMatch(workflow, /needs\.build\.outputs\.release_predecessor/u); @@ -97,8 +101,17 @@ test('CLI validation qualifies exact published State Roots without weakening art name, ); } + assert.match( + workflow, + /PREDECESSOR_SOURCE_COMMIT: \$\{\{ steps\.predecessor\.outputs\.source_commit \}\}/u, + ); const steps = workflowSteps(workflow); + const qualificationCheckout = workflow.slice( + workflow.indexOf('\n state-root-qualification:'), + workflow.indexOf('\n eval:', workflow.indexOf('\n state-root-qualification:')), + ); + assert.match(qualificationCheckout, /fetch-depth: 0/u); const sandbox = namedStep(steps, 'Require the account-isolation sandbox'); assert.match(sandbox, /apt-get install --yes bubblewrap/u); const qualify = namedStep(steps, 'Qualify the released State Root transitions'); @@ -144,9 +157,19 @@ test('CLI validation qualifies exact published State Roots without weakening art const freshness = namedStep(steps, 'Require the qualified Nightly predecessor to remain current'); assert.match(freshness, /assert-nightly-predecessor/u); assert.match(freshness, /steps\.predecessor\.outputs\.version/u); + assert.match( + freshness, + /PREDECESSOR_SOURCE_COMMIT: \$\{\{ steps\.predecessor\.outputs\.source_commit \}\}/u, + ); assert.ok(steps.indexOf(freshness) > steps.indexOf(preserve)); }); +test('forward-roll CI fences the registry predecessor to the checked-out commit', () => { + const workflow = readWorkflow('ci.yml'); + assert.match(workflow, /resolve-nightly-predecessor "\$GITHUB_OUTPUT" HEAD/u); + assert.match(workflow, /fetch-depth: 0/u); +}); + test('both supported Node versions validate the tarball even when the first fails', () => { // One runner and one tarball, so the two Node versions are two steps rather // than two jobs. Without this the first failing would end the job and the @@ -177,6 +200,7 @@ test('npm mutations revalidate the exact qualified Nightly predecessor', () => { ); assert.match(nightlyFence, /needs\.cli\.outputs\.release_predecessor_version/u); assert.match(nightlyFence, /needs\.cli\.outputs\.release_predecessor_integrity/u); + assert.match(nightlyFence, /needs\.cli\.outputs\.release_predecessor_source_commit/u); assert.match(nightlyFence, /assert-nightly-predecessor/u); assert.ok(nightly.indexOf(nightlyFence) < nightly.indexOf('npm publish')); @@ -184,6 +208,7 @@ test('npm mutations revalidate the exact qualified Nightly predecessor', () => { const submit = namedStep(workflowSteps(stage), 'Submit the candidate to npm staging'); assert.match(submit, /needs\.validate\.outputs\.release_predecessor_version/u); assert.match(submit, /needs\.validate\.outputs\.release_predecessor_integrity/u); + assert.match(submit, /needs\.validate\.outputs\.release_predecessor_source_commit/u); assert.match(submit, /assert-nightly-predecessor/u); assert.ok(submit.indexOf('assert-nightly-predecessor') < submit.indexOf('npm stage publish')); });