diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index a875a97..64c331e 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -116,11 +116,17 @@ jobs: DISPATCH_TAG: ${{ inputs.tag }} DISPATCH_SHA: ${{ inputs.sha }} RELEASE_PR_ACTORS: ${{ vars.RELEASE_PR_ACTORS || 'github-actions[bot]' }} + NPM_BOOTSTRAP_ENABLED: ${{ vars.NPM_BOOTSTRAP_ENABLED }} + NPM_TRUSTED_PUBLISHING_READY: ${{ vars.NPM_TRUSTED_PUBLISHING_READY }} + RELEASE_TAG_POLICY_READY: ${{ vars.RELEASE_TAG_POLICY_READY }} NPM_CONFIG_PROVENANCE: true NPM_CONFIG_USERCONFIG: /dev/null shell: bash run: | set -euo pipefail + [[ "$NPM_BOOTSTRAP_ENABLED" == false ]] + [[ "$NPM_TRUSTED_PUBLISHING_READY" == true ]] + [[ "$RELEASE_TAG_POLICY_READY" == true ]] TAG=${GITHUB_REF#refs/tags/} SHA=$GITHUB_SHA [[ "$GITHUB_REF" == "refs/tags/$TAG" ]] diff --git a/.github/workflows/release-readiness.yml b/.github/workflows/release-readiness.yml new file mode 100644 index 0000000..a3a917e --- /dev/null +++ b/.github/workflows/release-readiness.yml @@ -0,0 +1,45 @@ +name: Release readiness + +on: + pull_request_target: + branches: [main] + types: [opened, synchronize, reopened, edited, labeled, unlabeled, ready_for_review] + +concurrency: + group: release-pr-readiness-${{ github.event.pull_request.number }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + release-pr-readiness: + name: Release PR readiness + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + package-manager-cache: false + - name: Evaluate only default-branch release policy against PR metadata + env: + RELEASE_PR_ACTORS: ${{ vars.RELEASE_PR_ACTORS || 'github-actions[bot]' }} + NPM_BOOTSTRAP_ENABLED: ${{ vars.NPM_BOOTSTRAP_ENABLED }} + NPM_TRUSTED_PUBLISHING_READY: ${{ vars.NPM_TRUSTED_PUBLISHING_READY }} + RELEASE_TAG_POLICY_READY: ${{ vars.RELEASE_TAG_POLICY_READY }} + shell: bash + run: | + set -euo pipefail + node tools/release/release-pr-readiness.mjs \ + --event "$GITHUB_EVENT_PATH" \ + --contract docs/release/npm-integrity-contract.json \ + --repository "$GITHUB_REPOSITORY" \ + --release-pr-actors "$RELEASE_PR_ACTORS" \ + --npm-bootstrap-enabled "$NPM_BOOTSTRAP_ENABLED" \ + --npm-trusted-publishing-ready "$NPM_TRUSTED_PUBLISHING_READY" \ + --release-tag-policy-ready "$RELEASE_TAG_POLICY_READY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc38a0d..eebe1d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -162,12 +162,14 @@ jobs: DISPATCH_TAG: ${{ inputs.tag }} NPM_BOOTSTRAP_ENABLED: ${{ vars.NPM_BOOTSTRAP_ENABLED }} NPM_TRUSTED_PUBLISHING_READY: ${{ vars.NPM_TRUSTED_PUBLISHING_READY }} + RELEASE_TAG_POLICY_READY: ${{ vars.RELEASE_TAG_POLICY_READY }} RELEASE_PR_ACTORS: ${{ vars.RELEASE_PR_ACTORS || 'github-actions[bot]' }} shell: bash run: | set -euo pipefail - [[ "$NPM_BOOTSTRAP_ENABLED" != true ]] + [[ "$NPM_BOOTSTRAP_ENABLED" == false ]] [[ "$NPM_TRUSTED_PUBLISHING_READY" == true ]] + [[ "$RELEASE_TAG_POLICY_READY" == true ]] if [[ "$EVENT_NAME" == workflow_run ]]; then CONCLUSION=$(jq -r '.workflow_run.conclusion' "$GITHUB_EVENT_PATH") RUN_EVENT=$(jq -r '.workflow_run.event' "$GITHUB_EVENT_PATH") @@ -299,21 +301,12 @@ jobs: INTEGRITY_CONTRACT=docs/release/npm-integrity-contract.json test -f "$INTEGRITY_CONTRACT" - jq -e ' - .schemaVersion == 1 and - .package == "scriptspect" and - (.bootstrapVersion | test("^0\\.0\\.0-bootstrap\\.[0-9]+$")) and - (.sourceCommit | test("^[0-9a-f]{40}$")) and - (.integrityMode == "exact-bytes" or .integrityMode == "canonical-tree-v1") and - .comparatorAlgorithm == "scriptspect-canonical-tree/v1" and - (.comparatorAlgorithmDigest | test("^[0-9a-f]{64}$")) and - .latestUnchanged == true and - (.reviewedAt | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T")) - ' "$INTEGRITY_CONTRACT" >/dev/null - INTEGRITY_MODE=$(jq -er '.integrityMode' "$INTEGRITY_CONTRACT") + node tools/release/verify-package-integrity.mjs \ + --contract-only "$INTEGRITY_CONTRACT" \ + > "$RUNNER_TEMP/verified-integrity-contract.json" + INTEGRITY_MODE=$(jq -er '.integrityMode' "$RUNNER_TEMP/verified-integrity-contract.json") INTEGRITY_CONTRACT_HASH=$(sha256sum "$INTEGRITY_CONTRACT" | cut -d' ' -f1) - COMPARATOR_ALGORITHM_DIGEST=$(jq -er '.comparatorAlgorithmDigest' "$INTEGRITY_CONTRACT") - [[ "$COMPARATOR_ALGORITHM_DIGEST" == "$(node tools/release/canonical-tree.mjs algorithm-digest | jq -er '.algorithmDigest')" ]] + COMPARATOR_ALGORITHM_DIGEST=$(jq -er '.comparatorAlgorithmDigest' "$RUNNER_TEMP/verified-integrity-contract.json") { echo "intent-id=$INTENT_ID" diff --git a/tests/release/release-contract.test.ts b/tests/release/release-contract.test.ts index 9b54413..4afd057 100644 --- a/tests/release/release-contract.test.ts +++ b/tests/release/release-contract.test.ts @@ -168,6 +168,85 @@ function runIntegrityVerifier( ); } +function releasePullRequest(overrides: Record = {}): Record { + return { + base: { ref: 'main', repo: { full_name: 'Tom409114/scriptspect' } }, + head: { + ref: 'release-please--branches--main--components--scriptspect', + repo: { full_name: 'Tom409114/scriptspect', fork: false }, + }, + title: 'chore(main): release 0.1.0', + user: { login: 'github-actions[bot]' }, + labels: [{ name: 'autorelease: pending' }], + ...overrides, + }; +} + +function runReleaseReadiness(options: { + pullRequest: Record; + contract?: Record; + bootstrap?: string; + trusted?: string; + tagPolicy?: string; + actors?: string; + writeContract?: boolean; + omitFlag?: string; + extraArgs?: string[]; +}) { + const directory = temporaryDirectory('release-readiness'); + const eventPath = join(directory, 'event.json'); + const contractPath = join(directory, 'contract.json'); + writeFileSync(eventPath, `${JSON.stringify({ pull_request: options.pullRequest })}\n`); + if (options.writeContract !== false) { + writeFileSync( + contractPath, + `${JSON.stringify(options.contract ?? integrityContract('exact-bytes'))}\n`, + ); + } + const arguments_ = [ + '--event', + eventPath, + '--contract', + contractPath, + '--repository', + 'Tom409114/scriptspect', + '--release-pr-actors', + options.actors ?? 'github-actions[bot]', + '--npm-bootstrap-enabled', + options.bootstrap ?? 'false', + '--npm-trusted-publishing-ready', + options.trusted ?? 'true', + '--release-tag-policy-ready', + options.tagPolicy ?? 'true', + ]; + if (options.omitFlag !== undefined) { + const index = arguments_.indexOf(`--${options.omitFlag}`); + if (index === -1) throw new Error(`test attempted to omit unknown flag ${options.omitFlag}`); + arguments_.splice(index, 2); + } + arguments_.push(...(options.extraArgs ?? [])); + return spawnSync( + process.execPath, + [join(root, 'tools', 'release', 'release-pr-readiness.mjs'), ...arguments_], + { encoding: 'utf8' }, + ); +} + +function runContractOnly(contract: Record) { + const directory = temporaryDirectory('contract-only'); + const contractPath = join(directory, 'contract.json'); + writeFileSync(contractPath, `${JSON.stringify(contract)}\n`); + return spawnSync( + process.execPath, + [ + join(root, 'tools', 'release', 'verify-package-integrity.mjs'), + '--contract-only', + contractPath, + ], + { encoding: 'utf8' }, + ); +} + describe('release pull requests and exact release intent', () => { it('configures release-please to update only a release PR', () => { const config = json('release-please-config.json'); @@ -305,7 +384,10 @@ describe('release coordinator trust and recovery', () => { const authorize = jobSource('release.yml', 'authorize'); expect(authorize).toContain('docs/release/npm-integrity-contract.json'); expect(authorize).toContain('integrityMode'); - expect(authorize).toContain('bootstrapVersion'); + expect(authorize).toContain('--contract-only'); + expect( + readFileSync(join(root, 'tools', 'release', 'verify-package-integrity.mjs'), 'utf8'), + ).toContain('bootstrapVersion'); const verify = jobSource('npm-publish.yml', 'publish'); expect(verify).toContain('verify-package-integrity.mjs'); @@ -314,6 +396,221 @@ describe('release coordinator trust and recovery', () => { expect(authorize).toContain('NPM_TRUSTED_PUBLISHING_READY'); }); + it('keeps the release PR blocked until every external publication control is ready', () => { + const readiness = workflow('release-readiness.yml'); + expect(readiness.on).toMatchObject({ + pull_request_target: { branches: ['main'] }, + }); + expect(readiness.permissions).toEqual({ contents: 'read' }); + expect(readiness.jobs).toHaveProperty('release-pr-readiness'); + + const gate = jobSource('release-readiness.yml', 'release-pr-readiness'); + for (const predicate of [ + 'release-pr-readiness.mjs', + 'docs/release/npm-integrity-contract.json', + ]) { + expect(gate).toContain(predicate); + } + const readinessSource = source('release-readiness.yml'); + expect(readinessSource).toContain('github.event.pull_request.base.sha'); + expect(readinessSource).not.toContain('github.event.pull_request.head.sha'); + expect(readinessSource).not.toContain('github.event.pull_request.head.ref'); + expect(readinessSource).toContain('node-version: 22'); + + const authorize = jobSource('release.yml', 'authorize'); + expect(authorize).toContain('[[ "$NPM_BOOTSTRAP_ENABLED" == false ]]'); + expect(authorize).toContain('RELEASE_TAG_POLICY_READY'); + expect(authorize).toContain('[[ "$RELEASE_TAG_POLICY_READY" == true ]]'); + + const publish = jobSource('npm-publish.yml', 'publish'); + expect(publish).toContain('[[ "$NPM_BOOTSTRAP_ENABLED" == false ]]'); + expect(publish).toContain('[[ "$NPM_TRUSTED_PUBLISHING_READY" == true ]]'); + expect(publish).toContain('[[ "$RELEASE_TAG_POLICY_READY" == true ]]'); + }); + + it('executes the release readiness decision across identity and external-control boundaries', () => { + const ready = runReleaseReadiness({ pullRequest: releasePullRequest() }); + expect(ready.status, ready.stderr).toBe(0); + expect(JSON.parse(ready.stdout)).toMatchObject({ applicable: true, ready: true }); + + const ordinary = runReleaseReadiness({ + pullRequest: releasePullRequest({ + head: { + ref: 'feat/ordinary-change', + repo: { full_name: 'Tom409114/scriptspect', fork: false }, + }, + title: 'feat: ordinary change', + user: { login: 'contributor' }, + labels: [], + }), + bootstrap: '', + trusted: '', + tagPolicy: '', + writeContract: false, + }); + expect(ordinary.status, ordinary.stderr).toBe(0); + expect(JSON.parse(ordinary.stdout)).toMatchObject({ applicable: false, ready: true }); + + const missingContract = runReleaseReadiness({ + pullRequest: releasePullRequest(), + writeContract: false, + }); + expect(missingContract.status).toBe(1); + expect(missingContract.stderr).toMatch(/integrity contract.*read/i); + + const blockedCases: Array<[string, ReturnType, RegExp]> = [ + [ + 'unset bootstrap switch', + runReleaseReadiness({ pullRequest: releasePullRequest(), bootstrap: '' }), + /NPM_BOOTSTRAP_ENABLED/, + ], + [ + 'enabled bootstrap switch', + runReleaseReadiness({ pullRequest: releasePullRequest(), bootstrap: 'true' }), + /NPM_BOOTSTRAP_ENABLED/, + ], + [ + 'unready trusted publishing', + runReleaseReadiness({ pullRequest: releasePullRequest(), trusted: 'false' }), + /NPM_TRUSTED_PUBLISHING_READY/, + ], + [ + 'unready tag policy', + runReleaseReadiness({ pullRequest: releasePullRequest(), tagPolicy: 'false' }), + /RELEASE_TAG_POLICY_READY/, + ], + [ + 'unapproved actor', + runReleaseReadiness({ + pullRequest: releasePullRequest({ user: { login: 'attacker' } }), + }), + /actor is not in/, + ], + [ + 'renamed branch', + runReleaseReadiness({ + pullRequest: releasePullRequest({ + head: { + ref: 'release-tool-renamed-branch', + repo: { full_name: 'Tom409114/scriptspect', fork: false }, + }, + }), + }), + /head branch must start/, + ], + [ + 'noncanonical title', + runReleaseReadiness({ + pullRequest: releasePullRequest({ title: 'release 0.1.0' }), + }), + /canonical release-please title/, + ], + [ + 'missing release label', + runReleaseReadiness({ pullRequest: releasePullRequest({ labels: [] }) }), + /label autorelease: pending is missing/, + ], + [ + 'wrong base ref', + runReleaseReadiness({ + pullRequest: releasePullRequest({ + base: { ref: 'develop', repo: { full_name: 'Tom409114/scriptspect' } }, + }), + }), + /base ref must be main/, + ], + [ + 'wrong base repository', + runReleaseReadiness({ + pullRequest: releasePullRequest({ + base: { ref: 'main', repo: { full_name: 'attacker/fork' } }, + }), + }), + /base repository does not match/, + ], + [ + 'wrong head repository', + runReleaseReadiness({ + pullRequest: releasePullRequest({ + head: { + ref: 'release-please--branches--main--components--scriptspect', + repo: { full_name: 'attacker/fork', fork: false }, + }, + }), + }), + /head must be a non-fork branch/, + ], + [ + 'forked head repository', + runReleaseReadiness({ + pullRequest: releasePullRequest({ + head: { + ref: 'release-please--branches--main--components--scriptspect', + repo: { full_name: 'Tom409114/scriptspect', fork: true }, + }, + }), + }), + /head must be a non-fork branch/, + ], + ]; + for (const [description, blocked, message] of blockedCases) { + expect(blocked.status, description).toBe(1); + expect(blocked.stderr, description).toMatch(message); + } + }); + + it('uses the publisher contract parser for pre-merge readiness', () => { + const valid = runContractOnly(integrityContract('exact-bytes')); + expect(valid.status, valid.stderr).toBe(0); + expect(JSON.parse(valid.stdout)).toMatchObject({ + package: 'scriptspect', + integrityMode: 'exact-bytes', + }); + + const missingRegistryIntegrity = integrityContract('exact-bytes'); + delete (missingRegistryIntegrity as Record).registryIntegrity; + const contractOnlyMissing = runContractOnly(missingRegistryIntegrity); + expect(contractOnlyMissing.status).toBe(1); + expect(contractOnlyMissing.stderr).toContain('registryIntegrity'); + + const missing = runReleaseReadiness({ + pullRequest: releasePullRequest(), + contract: missingRegistryIntegrity, + }); + expect(missing.status).toBe(1); + expect(missing.stderr).toContain('registryIntegrity'); + + const wrongDigest = runReleaseReadiness({ + pullRequest: releasePullRequest(), + contract: integrityContract('exact-bytes', 'f'.repeat(64)), + }); + expect(wrongDigest.status).toBe(1); + expect(wrongDigest.stderr).toMatch(/comparator.*digest/i); + }); + + it('rejects missing, duplicate, and unknown readiness CLI flags', () => { + const malformed = [ + runReleaseReadiness({ + pullRequest: releasePullRequest(), + omitFlag: 'release-tag-policy-ready', + }), + runReleaseReadiness({ + pullRequest: releasePullRequest(), + extraArgs: ['--repository', 'Tom409114/scriptspect'], + }), + runReleaseReadiness({ + pullRequest: releasePullRequest(), + extraArgs: ['--unexpected', 'true'], + }), + ]; + expect(malformed[0]?.status).toBe(1); + expect(malformed[0]?.stderr).toContain('missing --release-tag-policy-ready'); + expect(malformed[1]?.status).toBe(1); + expect(malformed[1]?.stderr).toContain('duplicate --repository'); + expect(malformed[2]?.status).toBe(1); + expect(malformed[2]?.stderr).toContain('unknown --unexpected'); + }); + it('enforces exact bytes and both calculated and registry SRI in exact-bytes mode', () => { const { candidate, exactRegistry, repackedRegistry } = integrityTarballs('integrity-exact'); const contract = integrityContract('exact-bytes'); diff --git a/tests/workflows/ci-policy.test.ts b/tests/workflows/ci-policy.test.ts index ce3509e..7c11243 100644 --- a/tests/workflows/ci-policy.test.ts +++ b/tests/workflows/ci-policy.test.ts @@ -112,9 +112,20 @@ describe('pull-request trust boundary', () => { expect(source).not.toContain('secrets.'); expect(source).not.toContain('pull_request_target:'); } - for (const name of workflowNames()) { - expect(workflowSource(name)).not.toContain('pull_request_target:'); - } + + const targetWorkflows = workflowNames().filter((name) => + Object.hasOwn(workflow(name).on ?? {}, 'pull_request_target'), + ); + expect(targetWorkflows).toEqual(['release-readiness.yml']); + const trustedGate = workflow('release-readiness.yml'); + expect(trustedGate.permissions).toEqual({ contents: 'read' }); + const trustedSource = workflowSource('release-readiness.yml'); + expect(trustedSource).toContain('github.event.pull_request.base.sha'); + expect(trustedSource).not.toContain('github.event.pull_request.head.sha'); + expect(trustedSource).not.toContain('github.event.pull_request.head.ref'); + expect(trustedSource).not.toContain('secrets.'); + expect(trustedSource).not.toMatch(/git\s+push/); + expect(trustedSource).not.toContain('contents: write'); }); it('uses immutable third-party Action revisions in every workflow', () => { diff --git a/tools/release/release-pr-readiness.mjs b/tools/release/release-pr-readiness.mjs new file mode 100644 index 0000000..6329d79 --- /dev/null +++ b/tools/release/release-pr-readiness.mjs @@ -0,0 +1,166 @@ +import { + emitJson, + isMain, + ReleaseToolError, + readJson, + requireObject, + requireString, + runCli, +} from './shared.mjs'; +import { verifyIntegrityContract } from './verify-package-integrity.mjs'; + +const RELEASE_BRANCH_PREFIX = 'release-please--'; +const RELEASE_TITLE = /^chore\(main\): release [0-9]+\.[0-9]+\.[0-9]+$/u; +const RELEASE_LABEL = 'autorelease: pending'; +const ACTOR_LIST = /^[A-Za-z0-9_-]+(?:\[bot\])?(?:\s*,\s*[A-Za-z0-9_-]+(?:\[bot\])?)*$/u; + +function objectField(value, field, label) { + return requireObject(requireObject(value, label)[field], `${label}.${field}`); +} + +function stringField(value, field, label) { + return requireString(requireObject(value, label)[field], `${label}.${field}`); +} + +function fail(message) { + throw new ReleaseToolError(`release PR readiness failed: ${message}`); +} + +function exactSwitch(value, expected, label) { + if (value !== expected) fail(`${label} must be exactly ${expected}`); +} + +function labelNames(pullRequest) { + if (!Array.isArray(pullRequest.labels)) { + throw new ReleaseToolError('pull_request.labels must be an array'); + } + return pullRequest.labels.map((label, index) => + stringField(label, 'name', `pull_request.labels[${index}]`), + ); +} + +export function verifyReleasePrReadiness(input) { + const event = requireObject(input.event, 'event'); + const pullRequest = objectField(event, 'pull_request', 'event'); + const actor = stringField(objectField(pullRequest, 'user', 'pull_request'), 'login', 'user'); + const title = stringField(pullRequest, 'title', 'pull_request'); + const head = objectField(pullRequest, 'head', 'pull_request'); + const headRef = stringField(head, 'ref', 'pull_request.head'); + const labels = labelNames(pullRequest); + const repository = requireString( + input.repository, + 'repository', + /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u, + ); + const actorList = requireString(input.releasePrActors, 'release PR actor allowlist', ACTOR_LIST); + const allowedActors = actorList.split(',').map((entry) => entry.trim()); + + const signals = { + allowedActor: allowedActors.includes(actor), + releaseBranch: headRef.startsWith(RELEASE_BRANCH_PREFIX), + releaseTitle: RELEASE_TITLE.test(title), + releaseLabel: labels.includes(RELEASE_LABEL), + }; + if (!Object.values(signals).some(Boolean)) { + return { + schemaVersion: 'scriptspect-release-pr-readiness/v1', + applicable: false, + ready: true, + }; + } + + if (!signals.allowedActor) fail('actor is not in RELEASE_PR_ACTORS'); + if (!signals.releaseBranch) fail(`head branch must start with ${RELEASE_BRANCH_PREFIX}`); + if (!signals.releaseTitle) fail('title is not the canonical release-please title'); + if (!signals.releaseLabel) fail(`label ${RELEASE_LABEL} is missing`); + + const base = objectField(pullRequest, 'base', 'pull_request'); + if (stringField(base, 'ref', 'pull_request.base') !== 'main') { + fail('base ref must be main'); + } + const baseRepository = stringField( + objectField(base, 'repo', 'pull_request.base'), + 'full_name', + 'pull_request.base.repo', + ); + if (baseRepository !== repository) fail('base repository does not match the workflow repository'); + + const headRepositoryObject = objectField(head, 'repo', 'pull_request.head'); + const headRepository = stringField(headRepositoryObject, 'full_name', 'pull_request.head.repo'); + if (headRepository !== repository || headRepositoryObject.fork !== false) { + fail('release PR head must be a non-fork branch in the workflow repository'); + } + + exactSwitch(input.npmBootstrapEnabled, 'false', 'NPM_BOOTSTRAP_ENABLED'); + exactSwitch(input.npmTrustedPublishingReady, 'true', 'NPM_TRUSTED_PUBLISHING_READY'); + exactSwitch(input.releaseTagPolicyReady, 'true', 'RELEASE_TAG_POLICY_READY'); + const contractValue = + input.integrityContract ?? + readJson( + requireString(input.integrityContractPath, 'npm integrity contract path'), + 'npm integrity contract', + ); + const contract = verifyIntegrityContract(contractValue); + + return { + schemaVersion: 'scriptspect-release-pr-readiness/v1', + applicable: true, + ready: true, + releasePr: { actor, base: 'main', headRef, repository, title }, + integrityContract: { + bootstrapVersion: contract.bootstrapVersion, + comparatorAlgorithmDigest: contract.comparatorAlgorithmDigest, + integrityMode: contract.integrityMode, + sourceCommit: contract.sourceCommit, + workflowRunUrl: contract.workflowRunUrl, + }, + }; +} + +function parseFlags(arguments_) { + const flags = {}; + for (let index = 0; index < arguments_.length; index += 2) { + const key = arguments_[index]; + const value = arguments_[index + 1]; + if (!key?.startsWith('--') || value === undefined || value.startsWith('--')) { + throw new ReleaseToolError('readiness flags must be --name value pairs'); + } + const name = key.slice(2); + if (Object.hasOwn(flags, name)) throw new ReleaseToolError(`duplicate --${name} flag`); + flags[name] = value; + } + const required = [ + 'event', + 'contract', + 'repository', + 'release-pr-actors', + 'npm-bootstrap-enabled', + 'npm-trusted-publishing-ready', + 'release-tag-policy-ready', + ]; + for (const key of Object.keys(flags)) { + if (!required.includes(key)) throw new ReleaseToolError(`unknown --${key} flag`); + } + for (const key of required) { + if (!Object.hasOwn(flags, key)) throw new ReleaseToolError(`missing --${key} flag`); + } + return flags; +} + +async function main() { + const flags = parseFlags(process.argv.slice(2)); + const event = readJson(flags.event, 'pull_request_target event'); + emitJson( + verifyReleasePrReadiness({ + event, + integrityContractPath: flags.contract, + repository: flags.repository, + releasePrActors: flags['release-pr-actors'], + npmBootstrapEnabled: flags['npm-bootstrap-enabled'], + npmTrustedPublishingReady: flags['npm-trusted-publishing-ready'], + releaseTagPolicyReady: flags['release-tag-policy-ready'], + }), + ); +} + +if (isMain(import.meta.url)) runCli(main); diff --git a/tools/release/verify-package-integrity.mjs b/tools/release/verify-package-integrity.mjs index fe64936..5a0c679 100644 --- a/tools/release/verify-package-integrity.mjs +++ b/tools/release/verify-package-integrity.mjs @@ -21,7 +21,7 @@ import { const integrityModes = new Set(['exact-bytes', 'canonical-tree-v1']); -function requireIntegrityContract(value) { +export function verifyIntegrityContract(value) { const contract = requireExactKeys( value, 'npm integrity contract', @@ -76,7 +76,10 @@ function requireIntegrityContract(value) { } } return { + schemaVersion: 1, + package: 'scriptspect', integrityMode, + comparatorAlgorithm: CANONICAL_TREE_ALGORITHM, comparatorAlgorithmDigest, bootstrapVersion: requireString( contract.bootstrapVersion, @@ -98,6 +101,10 @@ function requireIntegrityContract(value) { 'npm integrity contract reviewedAt', /^[0-9]{4}-[0-9]{2}-[0-9]{2}T/u, ), + latestUnchanged: true, + ...(contract.nextRequiredActions === undefined + ? {} + : { nextRequiredActions: contract.nextRequiredActions }), }; } @@ -121,7 +128,7 @@ export function verifyPackageIntegrity(input) { 'candidateNpmSRI', 'registryNpmSRI', ]); - const contract = requireIntegrityContract(request.contract); + const contract = verifyIntegrityContract(request.contract); const candidatePath = requireString(request.candidatePath, 'candidate tarball path'); const registryPath = requireString(request.registryPath, 'registry tarball path'); const expectedCandidateSri = requireNpmSri( @@ -209,6 +216,13 @@ function parseFlags(arguments_) { async function main() { const flags = parseFlags(process.argv.slice(2)); + if (Object.hasOwn(flags, 'contract-only')) { + if (Object.keys(flags).length !== 1) { + throw new ReleaseToolError('--contract-only cannot be combined with package tarball flags'); + } + emitJson(verifyIntegrityContract(readJson(flags['contract-only'], 'npm integrity contract'))); + return; + } const allowed = new Set(['contract', 'candidate', 'registry', 'candidate-sri', 'registry-sri']); for (const key of Object.keys(flags)) { if (!allowed.has(key)) throw new ReleaseToolError(`unknown --${key} flag`);