diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index 460970bf..55860ed3 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -11,10 +11,20 @@ on: required: true type: boolean default: false + preflight_only: + description: "Read-only exact-state verification of stable artifacts" + required: true + type: boolean + default: false expected_sha: - description: "Full lowercase merged master SHA; FINALIZE only" + description: "Full lowercase merged master SHA; PREFLIGHT or FINALIZE only" + required: false + type: string + artifact_sha: + description: "Full lowercase publication SHA; empty uses expected_sha, and a different SHA is historical verification-only" required: false type: string + default: "" concurrency: group: release-stable cancel-in-progress: false @@ -60,7 +70,9 @@ jobs: env: REQUESTED_PROJECTS: ${{ inputs.projects }} PUBLISH_ONLY: ${{ inputs.publish_only }} + PREFLIGHT_ONLY: ${{ inputs.preflight_only }} EXPECTED_SHA: ${{ inputs.expected_sha }} + ARTIFACT_SHA: ${{ inputs.artifact_sha }} run: | set -euo pipefail RAW=$(printf '%s' "$REQUESTED_PROJECTS" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed '/^$/d') @@ -69,11 +81,20 @@ jobs: SELECTED=$(printf '%s\n' "$RAW" | sort) EXPECTED=$(printf '%s\n' '@effectify/hatchet' '@effectify/node-better-auth' '@effectify/prisma' '@effectify/react-query' '@effectify/react-router' '@effectify/react-router-better-auth' '@effectify/solid-query' | sort) cmp -s <(printf '%s\n' "$EXPECTED") <(printf '%s\n' "$SELECTED") || { echo '::error::stable requires exact seven-project matrix'; exit 1; } - if [ "$PUBLISH_ONLY" = true ]; then + if [ "$PREFLIGHT_ONLY" = true ] && [ "$PUBLISH_ONLY" = true ]; then + echo '::error::preflight_only and publish_only are mutually exclusive' + exit 1 + elif [ "$PREFLIGHT_ONLY" = true ]; then + [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo '::error::PREFLIGHT requires full lowercase expected_sha'; exit 1; } + if [ -n "$ARTIFACT_SHA" ]; then [[ "$ARTIFACT_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo '::error::PREFLIGHT requires full lowercase artifact_sha'; exit 1; }; fi + MODE=preflight + elif [ "$PUBLISH_ONLY" = true ]; then [[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo '::error::FINALIZE requires full lowercase expected_sha'; exit 1; } + if [ -n "$ARTIFACT_SHA" ]; then [[ "$ARTIFACT_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo '::error::FINALIZE requires full lowercase artifact_sha'; exit 1; }; fi MODE=finalize else test -z "$EXPECTED_SHA" || { echo '::error::PREPARE rejects expected_sha'; exit 1; } + test -z "$ARTIFACT_SHA" || { echo '::error::PREPARE rejects artifact_sha'; exit 1; } MODE=prepare fi echo "mode=$MODE" >> "$GITHUB_OUTPUT" @@ -88,7 +109,9 @@ jobs: HEAD_SHA=$(git rev-parse HEAD) REMOTE_SHA=$(git rev-parse origin/master) test "$HEAD_SHA" = "$REMOTE_SHA" || { echo '::error::checkout is not current origin/master'; exit 1; } - if [ "$MODE" = finalize ]; then test "$HEAD_SHA" = "$EXPECTED_SHA" || { echo '::error::FINALIZE SHA mismatch'; exit 1; }; fi + if [ "$MODE" = preflight ] || [ "$MODE" = finalize ]; then + test "$HEAD_SHA" = "$EXPECTED_SHA" || { echo '::error::PREFLIGHT/FINALIZE SHA mismatch'; exit 1; } + fi - name: 🏗️ Build selected projects env: { PROJECTS: "${{ steps.release.outputs.projects }}" } run: pnpm nx run-many -t build "--projects=$PROJECTS" --parallel=3 @@ -131,6 +154,13 @@ jobs: test -z "$(git status --porcelain)" || { echo '::error::post-commit tree dirty'; exit 1; } git push origin "HEAD:refs/heads/release/stable-$SHA_PREFIX" || { echo '::error::stable branch push failed'; exit 1; } echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT"; echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"; echo "changed_paths=$(paste -sd, "$EXPECTED_PATHS")" >> "$GITHUB_OUTPUT" + - name: 🔎 PREFLIGHT exact stable artifacts + if: ${{ steps.release.outputs.mode == 'preflight' }} + env: + EXPECTED_SHA: ${{ inputs.expected_sha }} + ARTIFACT_SHA: ${{ inputs.artifact_sha }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/release-finalize-stable.sh --preflight --json - name: 🔐 Verify npm authentication for FINALIZE if: ${{ steps.release.outputs.mode == 'finalize' }} env: { NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}" } @@ -140,6 +170,7 @@ jobs: env: PROJECTS: ${{ steps.release.outputs.projects }} EXPECTED_SHA: ${{ inputs.expected_sha }} + ARTIFACT_SHA: ${{ inputs.artifact_sha }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_CONFIG_PROVENANCE: true @@ -152,8 +183,9 @@ jobs: SOURCE_SHA: ${{ steps.prepare.outputs.source_sha || '' }} BRANCH: ${{ steps.prepare.outputs.branch || '' }} EXPECTED_SHA: ${{ inputs.expected_sha || '' }} + ARTIFACT_SHA: ${{ inputs.artifact_sha || inputs.expected_sha || '' }} run: | echo '## Protected stable promotion' >> "$GITHUB_STEP_SUMMARY" echo "**Mode:** $MODE" >> "$GITHUB_STEP_SUMMARY"; echo "**Projects:** $PROJECTS" >> "$GITHUB_STEP_SUMMARY" - echo "**Source:** $SOURCE_SHA **Branch:** $BRANCH **Expected SHA:** $EXPECTED_SHA" >> "$GITHUB_STEP_SUMMARY" - echo 'PREPARE requires a manually linked type:chore PR and protected review. FINALIZE reconciles tags → non-prerelease Releases → npm latest.' >> "$GITHUB_STEP_SUMMARY" + echo "**Source:** $SOURCE_SHA **Branch:** $BRANCH **Expected SHA:** $EXPECTED_SHA **Artifact SHA:** $ARTIFACT_SHA" >> "$GITHUB_STEP_SUMMARY" + echo 'PREFLIGHT is read-only exact-state verification; it does not tag, push, create Releases, or publish. PREPARE requires a manually linked type:chore PR and protected review. FINALIZE reconciles tags → non-prerelease Releases → npm latest.' >> "$GITHUB_STEP_SUMMARY" diff --git a/scripts/release-finalize-stable.mjs b/scripts/release-finalize-stable.mjs index ddeaef66..4ec735f5 100644 --- a/scripts/release-finalize-stable.mjs +++ b/scripts/release-finalize-stable.mjs @@ -12,6 +12,8 @@ const records = [ ["@effectify/solid-query", "packages/solid/query/package.json", "0.5.13"], ] const expectedSha = process.env.EXPECTED_SHA ?? "" +const artifactSha = process.env.ARTIFACT_SHA || expectedSha +const historicalReplay = artifactSha !== expectedSha const maxReads = 6 const delayMs = Number(process.env.NPM_READ_DELAY_MS ?? (Number(process.env.NPM_READ_DELAY ?? 10) * 1000)) const commandTimeoutMs = Number(process.env.FINALIZE_COMMAND_TIMEOUT_MS ?? 60_000) @@ -81,7 +83,7 @@ function parseTag(text, tag) { if (!match) return { kind: "unknown" } if (match[2] === directRef) direct.push(match[1]); else if (match[2] === peeledRef) peeled.push(match[1]); else return { kind: "unknown" } } - return direct.length === 1 && peeled.length === 1 && peeled[0] === expectedSha ? { kind: "exact" } : { kind: "divergent" } + return direct.length === 1 && peeled.length === 1 && peeled[0] === artifactSha ? { kind: "exact" } : { kind: "divergent" } } async function tagState(tag) { let result @@ -95,7 +97,7 @@ async function localTagState(tag) { const lines = result.stdout.trimEnd().split("\n") if (lines.length !== 1) return { kind: "divergent" } const match = lines[0].match(/^tag\t([0-9a-f]{40})$/) - return match && match[1] === expectedSha ? { kind: "exact" } : { kind: "divergent" } + return match && match[1] === artifactSha ? { kind: "exact" } : { kind: "divergent" } } function repository() { if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY @@ -139,8 +141,13 @@ async function main() { if (cliArguments.some((x) => !["--preflight", "--json"].includes(x))) fail("unknown argument") if (jsonOutput && !preflight) fail("--json requires --preflight") if (!/^[0-9a-f]{40}$/.test(expectedSha)) fail("FINALIZE requires full lowercase expected SHA") + if (!/^[0-9a-f]{40}$/.test(artifactSha)) fail("FINALIZE requires full lowercase artifact SHA") const states = await inspect() - if (preflight) { process.stdout.write(`${JSON.stringify({ ok: true, expectedSha, states })}\n`); return } + if (historicalReplay) { + const incomplete = states.find((item) => item.tag !== "exact" || item.release !== "exact" || item.npm !== "exact") + if (incomplete) fail(`historical replay requires exact existing tag, GitHub Release, and npm latest for ${incomplete.name}@${incomplete.version}`) + } + if (preflight) { process.stdout.write(`${JSON.stringify({ ok: true, expectedSha, artifactSha, states })}\n`); return } const missingTags = states.filter((x) => x.tag === "absent") const localTags = [] for (const item of missingTags) { @@ -152,7 +159,7 @@ async function main() { await run("git", ["config", "user.name", "github-actions[bot]"]) await run("git", ["config", "user.email", "github-actions[bot]@users.noreply.github.com"]) } - for (const { tag, local } of localTags) if (local === "absent") await run("git", ["tag", "-a", tag, expectedSha, "-m", tag]) + for (const { tag, local } of localTags) if (local === "absent") await run("git", ["tag", "-a", tag, artifactSha, "-m", tag]) if (missingTags.length) { const refs = missingTags.map((x) => `refs/tags/${x.name}@${x.version}:refs/tags/${x.name}@${x.version}`) try { await run("git", ["push", "--atomic", "origin", ...refs]) } catch { /* response loss is reconciled below */ } diff --git a/scripts/release-finalize-stable.test.mjs b/scripts/release-finalize-stable.test.mjs index bc7b672e..c926a9ed 100644 --- a/scripts/release-finalize-stable.test.mjs +++ b/scripts/release-finalize-stable.test.mjs @@ -7,7 +7,9 @@ import { join } from "node:path" import test from "node:test" const script = new URL("release-finalize-stable.mjs", import.meta.url).pathname +const stableWorkflow = readFileSync(new URL("../.github/workflows/release-stable.yml", import.meta.url), "utf8") const sha = "1234567890abcdef1234567890abcdef12345678" +const historicalSha = "abcdef1234567890abcdef1234567890abcdef12" const records = [ ["@effectify/hatchet", "packages/hatchet/package.json", "0.1.0"], ["@effectify/node-better-auth", "packages/node/better-auth/package.json", "0.5.12"], @@ -29,7 +31,7 @@ if(cmd==='git'){ const t=a[3].slice(10),v=s.tags[t]; if(v){if(v.raw)out(v.raw.replaceAll('$TAG',t));else{out((v.direct||'a'.repeat(40))+'\trefs/tags/'+t+'\n');if(v.peeled!==null)out((v.peeled||s.sha)+'\trefs/tags/'+t+'^{}\n')}} finish() } if(a[0]==='for-each-ref'){const t=a[2].slice(10),v=s.localTags[t];if(v)out((v.type||'tag')+'\t'+(v.peeled||s.sha)+'\n');finish()} - if(a[0]==='tag'){s.localTags[a[2]]={type:'tag',peeled:s.sha};finish()} + if(a[0]==='tag'){s.localTags[a[2]]={type:'tag',peeled:a[3]};finish()} if(a[0]==='push'){if(s.pushExit)finish(s.pushExit);for(const r of a.slice(3)){const t=r.split(':')[0].slice(10);s.tags[t]={peeled:s.localTags[t].peeled}}finish()} finish(127) } @@ -77,22 +79,38 @@ async function world(mode = "absent") { await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)) return { cwd, bin, stateFile, server, api: `http://127.0.0.1:${server.address().port}` } } -async function run(w, args = []) { +async function run(w, args = [], environment = {}) { return await new Promise(resolve => { - const child = spawn(process.execPath, [script, ...args], { cwd: w.cwd, env: { PATH: w.bin, EXPECTED_SHA: sha, NPM_READ_DELAY_MS: "0", FINALIZE_COMMAND_TIMEOUT_MS: "5000", GITHUB_API_URL: w.api, GITHUB_REPOSITORY: "owner/repo", GITHUB_TOKEN: "fake", FAKE_STATE: w.stateFile } }) + const child = spawn(process.execPath, [script, ...args], { cwd: w.cwd, env: { PATH: w.bin, EXPECTED_SHA: sha, ARTIFACT_SHA: "", NPM_READ_DELAY_MS: "0", FINALIZE_COMMAND_TIMEOUT_MS: "5000", GITHUB_API_URL: w.api, GITHUB_REPOSITORY: "owner/repo", GITHUB_TOKEN: "fake", FAKE_STATE: w.stateFile, ...environment } }) let stdout = "", stderr = ""; child.stdout.on("data", x => stdout += x); child.stderr.on("data", x => stderr += x); child.on("close", status => resolve({ status, stdout, stderr })) }) } -async function scenario(t, name, setup, verify, mode = "exact", args = []) { - await t.test(name, async () => { const w = await world(mode); try { const state = load(w.stateFile); await setup(state, w); save(w.stateFile, state); const result = await run(w, args); await verify(result, load(w.stateFile), w) } finally { await new Promise(resolve => w.server.close(resolve)) } }) +async function scenario(t, name, setup, verify, mode = "exact", args = [], environment = {}) { + await t.test(name, async () => { const w = await world(mode); try { const state = load(w.stateFile); await setup(state, w); save(w.stateFile, state); const result = await run(w, args, environment); await verify(result, load(w.stateFile), w) } finally { await new Promise(resolve => w.server.close(resolve)) } }) } function exactState(state) { assert.equal(Object.keys(state.tags).length, 7); assert.equal(Object.keys(state.releases).length, 7); for (const [n,,v] of records) { assert.deepEqual(state.npm[n].versions, [v]); assert.equal(state.npm[n].latest, v); assert.equal(state.npm[n].alpha, "alpha-sentinel"); assert.equal(state.npm[n].beta, "beta-sentinel") } } +function historicalTags(state) { for (const [name,,version] of records) state.tags[`${name}@${version}`] = { peeled: historicalSha } } +function workflowPreflightInvocation() { + const match = stableWorkflow.match(/^[ \t]*- name: 🔎 PREFLIGHT exact stable artifacts\n([\s\S]*?)(?=^[ \t]*- name:)/m) + assert.ok(match, "stable workflow preflight step") + const commands = [...match[1].matchAll(/^[ \t]*run:\s*(.+)$/gm)].map((entry) => entry[1].trim()) + assert.deepEqual(commands, ["bash scripts/release-finalize-stable.sh --preflight --json"]) + return { args: commands[0].split(/\s+/).slice(2), source: match[1] } +} const scenarioNames = [] test("hermetic Node CLI matrix", { timeout: 120_000 }, async t => { const add = async (...args) => { scenarioNames.push(args[0]); await scenario(t, ...args) } await add("all exact replay has zero mutation", async()=>{}, (r,s)=>{assert.equal(r.status,0,r.stderr);assert.deepEqual(mutations(s),[])}) - await add("all absent creates and publishes exact manifests", async()=>{}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s);const push=s.log.find(x=>x[0]==="git"&&x[1]==="push");assert.deepEqual(push.slice(1,4),["push","--atomic","origin"]);assert.equal(s.log.find(x=>x[0]==="pnpm")[4],`--projects=${records.map(x=>x[0]).join(",")}`)}, "absent") + await add("same-SHA all absent publishes normally", async()=>{}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s);const push=s.log.find(x=>x[0]==="git"&&x[1]==="push");assert.deepEqual(push.slice(1,4),["push","--atomic","origin"]);assert.equal(s.log.find(x=>x[0]==="pnpm")[4],`--projects=${records.map(x=>x[0]).join(",")}`)}, "absent") + await add("historical all-existing artifacts succeed with zero mutation", async s=>historicalTags(s), (r,s)=>{assert.equal(r.status,0,r.stderr);assert.deepEqual(mutations(s),[])}, "exact", [], {ARTIFACT_SHA:historicalSha}) + await add("historical missing tag fails before mutation", async s=>{historicalTags(s);delete s.tags[`${records[0][0]}@${records[0][2]}`]}, (r,s)=>{assert.notEqual(r.status,0);assert.match(r.stderr,/historical replay requires exact existing/);assert.deepEqual(mutations(s),[])}, "exact", [], {ARTIFACT_SHA:historicalSha}) + await add("historical missing Release fails before mutation", async s=>{historicalTags(s);delete s.releases[`${records[0][0]}@${records[0][2]}`]}, (r,s)=>{assert.notEqual(r.status,0);assert.match(r.stderr,/historical replay requires exact existing/);assert.deepEqual(mutations(s),[])}, "exact", [], {ARTIFACT_SHA:historicalSha}) + await add("historical missing npm version fails before mutation", async s=>{historicalTags(s);s.npm[records[0][0]].versions=[]}, (r,s)=>{assert.notEqual(r.status,0);assert.match(r.stderr,/historical replay requires exact existing/);assert.deepEqual(mutations(s),[])}, "exact", [], {ARTIFACT_SHA:historicalSha}) + await add("historical latest mismatch fails before mutation", async s=>{historicalTags(s);s.npm[records[0][0]].latest="alpha"}, (r,s)=>{assert.notEqual(r.status,0);assert.match(r.stderr,/permanent latest divergence/);assert.deepEqual(mutations(s),[])}, "exact", [], {ARTIFACT_SHA:historicalSha}) + await add("wrong artifact SHA fails before mutation", async s=>historicalTags(s), (r,s)=>{assert.notEqual(r.status,0);assert.match(r.stderr,/tag state is divergent/);assert.deepEqual(mutations(s),[])}, "exact", [], {ARTIFACT_SHA:"f".repeat(40)}) + await add("malformed artifact SHA fails closed independently", async()=>{}, (r,s)=>{assert.notEqual(r.status,0);assert.match(r.stderr,/full lowercase artifact SHA/);assert.deepEqual(mutations(s),[])}, "exact", [], {ARTIFACT_SHA:"not-a-sha"}) + await add("malformed expected SHA fails closed independently", async s=>historicalTags(s), (r,s)=>{assert.notEqual(r.status,0);assert.match(r.stderr,/full lowercase expected SHA/);assert.deepEqual(mutations(s),[])}, "exact", [], {EXPECTED_SHA:"not-a-sha",ARTIFACT_SHA:historicalSha}) for (const [index] of records.entries()) await add(`tag partial subset ${index+1} replays`, async s=>{for(const [n,,v] of records.slice(0,index+1))s.tags[`${n}@${v}`]={peeled:sha}}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s)}, "absent") for (const [index] of records.entries()) await add(`release partial subset ${index+1} replays`, async s=>{for(const [n,,v] of records)s.tags[`${n}@${v}`]={peeled:sha};for(const [n,,v] of records.slice(0,index+1))s.releases[`${n}@${v}`]={tag_name:`${n}@${v}`,draft:false,prerelease:false}}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s)}, "absent") for (const [index] of records.entries()) await add(`npm partial subset ${index+1} replays`, async s=>{for(const [n,,v] of records){s.tags[`${n}@${v}`]={peeled:sha};s.releases[`${n}@${v}`]={tag_name:`${n}@${v}`,draft:false,prerelease:false}}for(const [n,,v] of records.slice(0,index+1)){s.npm[n].versions=[v];s.npm[n].latest=v}}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s)}, "absent") @@ -113,7 +131,9 @@ test("hermetic Node CLI matrix", { timeout: 120_000 }, async t => { await add("manifest version mismatch fails before mutation", async(s,w)=>writeFileSync(join(w.cwd,records[0][1]),JSON.stringify({name:records[0][0],version:"9.9.9"})), (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}) await add("EXPECTED_SHA controls HEAD", async s=>{s.head="f".repeat(40)}, (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}) await add("EXPECTED_SHA controls origin", async s=>{s.origin="f".repeat(40)}, (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}) - await add("preflight JSON reads only", async()=>{}, (r,s)=>{assert.equal(r.status,0,r.stderr);assert.equal(JSON.parse(r.stdout).expectedSha,sha);assert.equal(mutations(s).length,0)}, "exact", ["--preflight","--json"]) + const preflight = workflowPreflightInvocation() + assert.doesNotMatch(preflight.source, /NODE_AUTH_TOKEN|NPM_CONFIG_PROVENANCE|npm whoami|nx release publish|git (?:tag|push)|gh release (?:create|delete)/) + await add("workflow historical preflight JSON includes both SHAs and reads only", async s=>historicalTags(s), (r,s)=>{assert.equal(r.status,0,r.stderr);const output=JSON.parse(r.stdout);assert.equal(output.expectedSha,sha);assert.equal(output.artifactSha,historicalSha);assert.equal(mutations(s).length,0)}, "exact", preflight.args, {ARTIFACT_SHA:historicalSha}) assert.equal(new Set(scenarioNames).size, scenarioNames.length) }) diff --git a/scripts/release-policy-contract.test.mjs b/scripts/release-policy-contract.test.mjs index e510803b..5dfa1c32 100644 --- a/scripts/release-policy-contract.test.mjs +++ b/scripts/release-policy-contract.test.mjs @@ -516,23 +516,44 @@ const stableViolations = (source, finalizeScript = stableFinalizeScript) => { const active = withoutComments(source) const activeFinalize = withoutComments(finalizeScript) { + const steps = extractSteps(source) + const resolve = steps.find((step) => step.name.includes("Resolve exact stable mode")) + const freshAuthorization = steps.find((step) => step.name.includes("Fresh master authorization")) + const prepare = steps.find((step) => step.name.includes("PREPARE protected stable")) + const preflight = steps.find((step) => step.name.includes("PREFLIGHT exact stable artifacts")) + const finalize = steps.find((step) => step.name.includes("FINALIZE exact stable artifacts")) + const finalizeBody = finalize?.source ?? "" const required = [ ["wrapper exec", /exec node .*release-finalize-stable\.mjs/, withoutComments(stableFinalizeWrapper)], - ["strict SHA", /\^\[0-9a-f\]\{40\}\$/, activeFinalize], + ["preflight boolean input", /preflight_only:\s*\n\s*description: ["']Read-only exact-state verification[^\n]*\n\s*required: true\s*\n\s*type: boolean\s*\n\s*default: false/, active], + ["expected SHA input", /expected_sha:\s*\n\s*description:[^\n]*\n\s*required: false\s*\n\s*type: string/, active], + ["artifact SHA input", /artifact_sha:\s*\n\s*description:[^\n]*\n\s*required: false\s*\n\s*type: string/, active], + ["expected SHA finalizer env", /EXPECTED_SHA:\s*\$\{\{ inputs\.expected_sha \}\}/, finalizeBody], + ["artifact SHA finalizer env", /ARTIFACT_SHA:\s*\$\{\{ inputs\.artifact_sha \}\}/, finalizeBody], + ["expected SHA environment", /const expectedSha = process\.env\.EXPECTED_SHA \?\? ""/, activeFinalize], + ["artifact SHA fallback", /const artifactSha = process\.env\.ARTIFACT_SHA \|\| expectedSha/, activeFinalize], + ["historical SHA distinction", /const historicalReplay = artifactSha !== expectedSha/, activeFinalize], + ["strict expected SHA", /if \(!\/\^\[0-9a-f\]\{40\}\$\/\.test\(expectedSha\)\) fail\("FINALIZE requires full lowercase expected SHA"\)/, activeFinalize], + ["strict artifact SHA", /if \(!\/\^\[0-9a-f\]\{40\}\$\/\.test\(artifactSha\)\) fail\("FINALIZE requires full lowercase artifact SHA"\)/, activeFinalize], ["fresh master", /master:refs\/remotes\/origin\/master/, activeFinalize], + ["HEAD execution authorization", /if \(head !== expectedSha\) fail\("HEAD does not match expected SHA"\)/, activeFinalize], + ["origin execution authorization", /if \(origin !== expectedSha\) fail\("origin\/master does not match expected SHA"\)/, activeFinalize], ["manifest identity", /value\.name !== name \|\| value\.version !== version/, activeFinalize], ["bounded npm reads", /const maxReads = 6\b/, activeFinalize], ["post-publish absence retries", /acceptAbsent && state\.kind === "absent"/, activeFinalize], ["local annotated tag inspection", /async function localTagState[\s\S]*objecttype[\s\S]*\^tag\\t/, activeFinalize], ["independent npm documents", /const versionsDoc[\s\S]*const latestDoc/, activeFinalize], - ["strict tag parse", /direct\.length === 1 && peeled\.length === 1 && peeled\[0\] === expectedSha/, activeFinalize], + ["strict tag parse", /direct\.length === 1 && peeled\.length === 1 && peeled\[0\] === artifactSha/, activeFinalize], + ["local artifact tag target", /match && match\[1\] === artifactSha/, activeFinalize], ["HTTP 404 absence", /result\.status === 404/, activeFinalize], ["unknown Release fail closed", /result\.status !== 200/, activeFinalize], - ["annotated tag", /\["tag", "-a",/, activeFinalize], + ["annotated artifact tag", /\["tag", "-a", tag, artifactSha, "-m", tag\]/, activeFinalize], ["atomic explicit push", /\["push", "--atomic", "origin", \.\.\.refs\]/, activeFinalize], ["release exact postverification", /releaseState\(`\$\{item\.name\}@\$\{item\.version\}`\)\)\.kind !== "exact"/, activeFinalize], ["missing npm subset", /states\.filter\(\(x\) => x\.npm === "absent"\)/, activeFinalize], ["default publication", /\["nx", "release", "publish", `--projects=\$\{missing\.join\(","\)\}`\]/, activeFinalize], + ["historical all-existing guard", /if \(historicalReplay\) \{[\s\S]*item\.tag !== "exact" \|\| item\.release !== "exact" \|\| item\.npm !== "exact"[\s\S]*historical replay requires exact existing tag, GitHub Release, and npm latest/, activeFinalize], + ["preflight both SHAs", /JSON\.stringify\(\{ ok: true, expectedSha, artifactSha, states \}\)/, activeFinalize], ["preflight return", /if \(preflight\) \{[\s\S]*return \}/, activeFinalize], ["PREPARE Node JSON type validation", /JSON\.parse\(/, active], ["PREPARE manifest object type", /!value\|\|typeof value!=="object"\|\|Array\.isArray\(value\)/, active], @@ -544,9 +565,84 @@ const stableViolations = (source, finalizeScript = stableFinalizeScript) => { ["PREPARE exact staging", /git add --pathspec-from-file="\$EXPECTED_PATHS"/, active], ["PREPARE staged path equality", /cmp -s "\$EXPECTED_PATHS" \/tmp\/stable-staged/, active], ["PREPARE release branch", /HEAD:refs\/heads\/release\/stable-\$SHA_PREFIX/, active], + ["read-only PREFLIGHT summary", /PREFLIGHT is read-only exact-state verification; it does not tag, push, create Releases, or publish\./, active], ] for (const [name, pattern, body] of required) if (!pattern.test(body)) violations.push(`stable ${name}`) - const prepare = extractSteps(source).find((step) => step.name.includes("PREPARE protected stable")) + + if (!resolve) { + violations.push("stable mode resolver") + } else { + const exclusivity = [ + /^if \[ "\$PREFLIGHT_ONLY" = true \] && \[ "\$PUBLISH_ONLY" = true \]; then$/, + /^echo '::error::preflight_only and publish_only are mutually exclusive'$/, + /^exit 1$/, + /^elif \[ "\$PREFLIGHT_ONLY" = true \]; then$/, + ] + if (!hasCommandSequence(resolve.commands, exclusivity)) violations.push("stable PREFLIGHT/FINALIZE exclusivity") + if (!/PREFLIGHT_ONLY:\s*\$\{\{ inputs\.preflight_only \}\}/.test(resolve.source)) { + violations.push("stable PREFLIGHT boolean environment") + } + const preflightBranch = resolve.source.match( + /elif \[ "\$PREFLIGHT_ONLY" = true \]; then([\s\S]*?)elif \[ "\$PUBLISH_ONLY" = true \]; then/, + )?.[1] + if (!preflightBranch) { + violations.push("stable PREFLIGHT mode branch") + } else { + if (!/\[\[ "\$EXPECTED_SHA" =~ \^\[0-9a-f\]\{40\}\$ \]\] \|\| \{ echo '::error::PREFLIGHT requires full lowercase expected_sha'; exit 1; \}/.test(preflightBranch)) { + violations.push("stable PREFLIGHT full expected SHA") + } + if (!/if \[ -n "\$ARTIFACT_SHA" \]; then \[\[ "\$ARTIFACT_SHA" =~ \^\[0-9a-f\]\{40\}\$ \]\] \|\| \{ echo '::error::PREFLIGHT requires full lowercase artifact_sha'; exit 1; \}; fi/.test(preflightBranch)) { + violations.push("stable PREFLIGHT optional full artifact SHA") + } + if (!/MODE=preflight/.test(preflightBranch)) violations.push("stable PREFLIGHT mode output") + } + } + + const freshSequence = [ + /^if \[ "\$MODE" = preflight \] \|\| \[ "\$MODE" = finalize \]; then$/, + /^test "\$HEAD_SHA" = "\$EXPECTED_SHA" \|\| \{ echo '::error::PREFLIGHT\/FINALIZE SHA mismatch'; exit 1; \}$/, + /^fi$/, + ] + if (!freshAuthorization || !hasCommandSequence(freshAuthorization.commands, freshSequence)) { + violations.push("stable fresh PREFLIGHT and FINALIZE expected SHA authorization") + } + + if (!prepare || prepare.condition !== "${{ steps.release.outputs.mode == 'prepare' }}") { + violations.push("stable PREPARE-only step") + } + if (!finalize || finalize.condition !== "${{ steps.release.outputs.mode == 'finalize' }}") { + violations.push("stable FINALIZE-only step") + } + if (!preflight) { + violations.push("stable PREFLIGHT step") + } else { + if (preflight.condition !== "${{ steps.release.outputs.mode == 'preflight' }}") { + violations.push("stable PREFLIGHT-only step") + } + if (!/EXPECTED_SHA:\s*\$\{\{ inputs\.expected_sha \}\}/.test(preflight.source)) { + violations.push("stable PREFLIGHT expected SHA environment") + } + if (!/ARTIFACT_SHA:\s*\$\{\{ inputs\.artifact_sha \}\}/.test(preflight.source)) { + violations.push("stable PREFLIGHT artifact SHA environment") + } + if (!/GITHUB_TOKEN:\s*\$\{\{ secrets\.GITHUB_TOKEN \}\}/.test(preflight.source)) { + violations.push("stable PREFLIGHT GitHub token") + } + if ( + preflight.commands.length !== 1 || + preflight.commands[0] !== "bash scripts/release-finalize-stable.sh --preflight --json" + ) { + violations.push("stable PREFLIGHT exact read-only invocation") + } + if ( + /NODE_AUTH_TOKEN|NPM_CONFIG_PROVENANCE|npm (?:whoami|publish|dist-tag|unpublish)|nx release publish|git (?:tag|push|commit)|gh release (?:create|delete|edit|upload)/.test( + preflight.source, + ) + ) { + violations.push("stable PREFLIGHT mutation isolation") + } + } + const prepareBody = prepare?.source ?? "" if (/\bread\s+-r\s+[^\n;]*\bPATH\b/.test(prepareBody)) violations.push("stable PREPARE reserved PATH shadowing") if (!/node -e '[^\n]*fs\.readFileSync\(path,"utf8"\)[^\n]*' "\$MANIFEST_PATH" "\$NAME"/.test(prepareBody)) { @@ -797,11 +893,87 @@ test("beta FINALIZE conflict and ordering mutations fail closed", () => { })) }) +test("protected stable PREFLIGHT rejects authorization and mutation-boundary drift", () => { + const stable = workflows.stable + assert.deepEqual(stableViolations(stable), []) + + for (const [name, changed] of [ + [ + "remove PREFLIGHT and FINALIZE exclusivity", + mutateStep( + stable, + "Resolve exact stable mode", + 'if [ "$PREFLIGHT_ONLY" = true ] && [ "$PUBLISH_ONLY" = true ]; then', + "if false; then", + ), + ], + [ + "allow PREFLIGHT without expected SHA", + mutateStep( + stable, + "Resolve exact stable mode", + '[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo \'::error::PREFLIGHT requires full lowercase expected_sha\'; exit 1; }', + ":", + ), + ], + [ + "route PREFLIGHT to FINALIZE command", + mutateStep( + stable, + "PREFLIGHT exact stable artifacts", + "bash scripts/release-finalize-stable.sh --preflight --json", + "bash scripts/release-finalize-stable.sh", + ), + ], + [ + "add a mutation command to PREFLIGHT", + mutateStep( + stable, + "PREFLIGHT exact stable artifacts", + "run: bash scripts/release-finalize-stable.sh --preflight --json", + "run: |\n bash scripts/release-finalize-stable.sh --preflight --json\n git push origin master", + ), + ], + [ + "add a publication token to PREFLIGHT", + mutateStep( + stable, + "PREFLIGHT exact stable artifacts", + "GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}", + "GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}", + ), + ], + [ + "skip fresh authorization for PREFLIGHT", + mutateStep( + stable, + "Fresh master authorization", + 'if [ "$MODE" = preflight ] || [ "$MODE" = finalize ]; then', + 'if [ "$MODE" = finalize ]; then', + ), + ], + ]) { + assert.notDeepEqual(stableViolations(changed), [], name) + } +}) + test("protected stable PREPARE and FINALIZE reject independent safety mutations", () => { const policy = { ...workflows, docs: readme } assert.deepEqual(stableViolations(policy.stable), []) for (const [name, before, after] of [ - ["weaken SHA", "^[0-9a-f]{40}$", "^[0-9a-f]{7,40}$"], + ["weaken expected SHA", "if (!/^[0-9a-f]{40}$/.test(expectedSha))", "if (!/^[0-9a-f]{7,40}$/.test(expectedSha))"], + ["weaken artifact SHA", "if (!/^[0-9a-f]{40}$/.test(artifactSha))", "if (!/^[0-9a-f]{7,40}$/.test(artifactSha))"], + ["remove expected SHA environment", 'const expectedSha = process.env.EXPECTED_SHA ?? ""', 'const expectedSha = ""'], + ["remove artifact SHA environment", "const artifactSha = process.env.ARTIFACT_SHA || expectedSha", "const artifactSha = expectedSha"], + ["swap expected SHA validation", '.test(expectedSha)) fail("FINALIZE requires full lowercase expected SHA")', '.test(artifactSha)) fail("FINALIZE requires full lowercase expected SHA")'], + ["swap artifact SHA validation", '.test(artifactSha)) fail("FINALIZE requires full lowercase artifact SHA")', '.test(expectedSha)) fail("FINALIZE requires full lowercase artifact SHA")'], + ["authorize HEAD with artifact SHA", "head !== expectedSha", "head !== artifactSha"], + ["authorize origin with artifact SHA", "origin !== expectedSha", "origin !== artifactSha"], + ["verify remote tags with expected SHA", "peeled[0] === artifactSha", "peeled[0] === expectedSha"], + ["verify local tags with expected SHA", "match[1] === artifactSha", "match[1] === expectedSha"], + ["target annotated tags at expected SHA", '["tag", "-a", tag, artifactSha, "-m", tag]', '["tag", "-a", tag, expectedSha, "-m", tag]'], + ["remove historical all-existing guard", "if (historicalReplay) {", "if (false) {"], + ["weaken historical npm exactness", 'item.npm !== "exact"', 'item.npm === "unknown"'], ["unbound retries", "const maxReads = 6", "const maxReads = 60"], ["weaken manifest", "value.name !== name || value.version !== version", "false"], ["accept duplicate tag refs", "direct.length === 1 && peeled.length === 1", "direct.length > 0 && peeled.length > 0"], @@ -814,6 +986,22 @@ test("protected stable PREPARE and FINALIZE reject independent safety mutations" assert.notDeepEqual(stableViolations(policy.stable, changed), [], name) } + for (const [name, before, after] of [ + ["remove expected SHA input", "expected_sha:", "execution_sha:"], + ["remove artifact SHA input", "artifact_sha:", "publication_sha:"], + ]) { + const changed = mutate(policy.stable, before, after) + assert.notDeepEqual(stableViolations(changed), [], name) + } + + for (const [name, before, after] of [ + ["swap FINALIZE expected SHA env", "EXPECTED_SHA: ${{ inputs.expected_sha }}", "EXPECTED_SHA: ${{ inputs.artifact_sha }}"], + ["swap FINALIZE artifact SHA env", "ARTIFACT_SHA: ${{ inputs.artifact_sha }}", "ARTIFACT_SHA: ${{ inputs.expected_sha }}"], + ]) { + const changed = mutateStep(policy.stable, "FINALIZE exact stable artifacts", before, after) + assert.notDeepEqual(stableViolations(changed), [], name) + } + const prepareJson = mutateStep(policy.stable, "PREPARE protected stable", /JSON\.parse/g, "JSON.parseSafe") assert.ok(stableViolations(prepareJson).includes("stable PREPARE Node JSON type validation")) const prepareShadow = mutateStep(policy.stable, "PREPARE protected stable", /read -r NAME MANIFEST_PATH/, "read -r NAME PATH") @@ -893,6 +1081,11 @@ test("protected stable documentation rejects authorization and recovery drift", test("protected stable promotion exposes exact PREPARE and FINALIZE contracts", () => { const active = withoutComments(`${workflows.stable}\n${stableFinalizeScript}`) assert.match(active, /expected_sha:/) + assert.match(active, /artifact_sha:/) + assert.match(active, /ARTIFACT_SHA:\s*\$\{\{ inputs\.artifact_sha \}\}/) + assert.match(active, /const artifactSha = process\.env\.ARTIFACT_SHA \|\| expectedSha/) + assert.match(active, /const historicalReplay = artifactSha !== expectedSha/) + assert.match(active, /historical replay requires exact existing tag, GitHub Release, and npm latest/) assert.match(active, /MODE=prepare/) assert.match(active, /MODE=finalize/) assert.match(