From cd724aafe24cba6d91f3382c1e8291050f08387e Mon Sep 17 00:00:00 2001 From: kattsushi Date: Sat, 29 Aug 2026 20:46:44 -0600 Subject: [PATCH 1/2] fix(release): harden stable finalization adapters Closes #265 --- scripts/release-finalize-stable.mjs | 155 +++++++++++++++++++++ scripts/release-finalize-stable.sh | 97 +------------- scripts/release-finalize-stable.test.mjs | 163 +++++++++++++---------- scripts/release-policy-contract.test.mjs | 151 ++++++++++----------- 4 files changed, 320 insertions(+), 246 deletions(-) create mode 100644 scripts/release-finalize-stable.mjs diff --git a/scripts/release-finalize-stable.mjs b/scripts/release-finalize-stable.mjs new file mode 100644 index 00000000..d62dcd82 --- /dev/null +++ b/scripts/release-finalize-stable.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises" +import { spawn } from "node:child_process" + +const records = [ + ["@effectify/hatchet", "packages/hatchet/package.json", "0.1.0"], + ["@effectify/node-better-auth", "packages/node/better-auth/package.json", "0.5.12"], + ["@effectify/prisma", "packages/prisma/package.json", "1.1.13"], + ["@effectify/react-query", "packages/react/query/package.json", "1.0.0"], + ["@effectify/react-router", "packages/react/router/package.json", "0.6.0"], + ["@effectify/react-router-better-auth", "packages/react/router-better-auth/package.json", "0.5.12"], + ["@effectify/solid-query", "packages/solid/query/package.json", "0.5.13"], +] +const expectedSha = process.env.EXPECTED_SHA ?? "" +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) +const httpTimeoutMs = Number(process.env.FINALIZE_HTTP_TIMEOUT_MS ?? 30_000) +const outputLimit = Number(process.env.FINALIZE_OUTPUT_LIMIT ?? 1024 * 1024) +const cliArguments = process.argv.slice(2) +const preflight = cliArguments.includes("--preflight") +const jsonOutput = cliArguments.includes("--json") + +function fail(message) { throw new Error(message) } +function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } +function run(file, args, { ok = [0] } = {}) { + return new Promise((resolve, reject) => { + const child = spawn(file, args, { shell: false, stdio: ["ignore", "pipe", "pipe"] }) + let stdout = Buffer.alloc(0), stderr = Buffer.alloc(0), excessive = false + const append = (current, chunk) => { + if (current.length + chunk.length > outputLimit) { excessive = true; child.kill("SIGKILL"); return current } + return Buffer.concat([current, chunk]) + } + child.stdout.on("data", (x) => { stdout = append(stdout, x) }) + child.stderr.on("data", (x) => { stderr = append(stderr, x) }) + const timer = setTimeout(() => child.kill("SIGKILL"), commandTimeoutMs) + child.on("error", (error) => { clearTimeout(timer); reject(new Error(`${file} execution failed: ${error.message}`)) }) + child.on("close", (code, signal) => { + clearTimeout(timer) + const result = { code, signal, stdout: stdout.toString("utf8"), stderr: stderr.toString("utf8") } + if (excessive) reject(new Error(`${file} output exceeded bound`)) + else if (signal) reject(new Error(`${file} timed out or terminated (${signal})`)) + else if (!ok.includes(code)) reject(new Error(`${file} failed (${code})`)) + else resolve(result) + }) + }) +} +function parseJson(text, label) { try { return JSON.parse(text) } catch { fail(`${label} returned malformed JSON`) } } +async function manifest(name, path, version) { + let value + try { value = parseJson(await readFile(path, "utf8"), `manifest ${name}`) } catch (error) { fail(`merged manifest execution or parse failed for ${name}: ${error.message}`) } + const valid = value && typeof value === "object" && !Array.isArray(value) && typeof value.name === "string" && typeof value.version === "string" + if (!valid || value.name !== name || value.version !== version) fail(`merged manifest identity mismatch for ${name}: actual=${JSON.stringify({ name: valid ? value.name : null, version: valid ? value.version : null })} expected=${JSON.stringify({ name, version })}`) +} +async function npmState(name, version) { + try { + const versionsDoc = (await run("npm", ["view", name, "versions", "--json"])).stdout + const latestDoc = (await run("npm", ["view", name, "dist-tags.latest", "--json"])).stdout + const versions = parseJson(versionsDoc, `${name} versions`), latest = parseJson(latestDoc, `${name} latest`) + if (!((typeof versions === "string") || (Array.isArray(versions) && versions.every((x) => typeof x === "string"))) || typeof latest !== "string") return { kind: "unknown" } + const present = Array.isArray(versions) ? versions.includes(version) : versions === version + return !present ? { kind: "absent" } : latest === version ? { kind: "exact" } : { kind: "divergent" } + } catch { return { kind: "unknown" } } +} +async function npmBounded(name, version) { + let state + for (let attempt = 1; attempt <= maxReads; attempt++) { + state = await npmState(name, version) + if (state.kind === "exact" || state.kind === "absent") return state + if (attempt < maxReads) await sleep(delayMs) + } + fail(state.kind === "divergent" ? `permanent latest divergence for ${name}` : `npm state unreadable after ${maxReads} attempts for ${name}`) +} +function parseTag(text, tag) { + const direct = [], peeled = [], directRef = `refs/tags/${tag}`, peeledRef = `${directRef}^{}` + if (text === "") return { kind: "absent" } + for (const line of text.split("\n")) { + if (!line) continue + const match = line.match(/^([0-9a-f]{40})\t([^\s]+)$/) + 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" } +} +async function tagState(tag) { + let result + try { result = await run("git", ["ls-remote", "--tags", "origin", `refs/tags/${tag}`, `refs/tags/${tag}^{}`]) } catch { return { kind: "unknown" } } + return parseTag(result.stdout, tag) +} +function repository() { + if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY + fail("GITHUB_REPOSITORY is required") +} +async function github(method, path, body) { + const controller = new AbortController(), timer = setTimeout(() => controller.abort(), httpTimeoutMs) + try { + const options = { + method, signal: controller.signal, + headers: { accept: "application/vnd.github+json", authorization: `Bearer ${process.env.GITHUB_TOKEN ?? ""}`, "content-type": "application/json", "user-agent": "effectify-release-finalizer", "x-github-api-version": "2022-11-28" }, + } + if (body !== undefined) options.body = JSON.stringify(body) + const response = await fetch(`${process.env.GITHUB_API_URL ?? "https://api.github.com"}/repos/${repository()}${path}`, options) + const text = await response.text() + return { status: response.status, text } + } catch (error) { fail(`GitHub transport failure: ${error.message}`) } finally { clearTimeout(timer) } +} +async function releaseState(tag) { + const result = await github("GET", `/releases/tags/${encodeURIComponent(tag)}`) + if (result.status === 404) return { kind: "absent" } + if (result.status !== 200) return { kind: "unknown", status: result.status } + const value = parseJson(result.text, `GitHub Release ${tag}`) + return value && typeof value === "object" && !Array.isArray(value) && value.tag_name === tag && value.draft === false && value.prerelease === false ? { kind: "exact" } : { kind: "divergent" } +} +async function inspect() { + await run("git", ["fetch", "origin", "master:refs/remotes/origin/master", "--no-tags"]) + const head = (await run("git", ["rev-parse", "HEAD"])).stdout.trim(), origin = (await run("git", ["rev-parse", "origin/master"])).stdout.trim() + if (head !== expectedSha) fail("HEAD does not match expected SHA") + if (origin !== expectedSha) fail("origin/master does not match expected SHA") + const states = [] + for (const [name, path, version] of records) { + await manifest(name, path, version) + const npm = await npmBounded(name, version), tag = await tagState(`${name}@${version}`), release = await releaseState(`${name}@${version}`) + for (const [label, state] of [["tag", tag], ["GitHub Release", release]]) if (!['exact','absent'].includes(state.kind)) fail(`${label} state is ${state.kind} for ${name}@${version}${state.status ? ` (HTTP ${state.status})` : ""}`) + states.push({ name, version, npm: npm.kind, tag: tag.kind, release: release.kind }) + } + return states +} +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") + const states = await inspect() + if (preflight) { process.stdout.write(`${JSON.stringify({ ok: true, expectedSha, states })}\n`); return } + await run("git", ["config", "user.name", "github-actions[bot]"]) + await run("git", ["config", "user.email", "github-actions[bot]@users.noreply.github.com"]) + const missingTags = states.filter((x) => x.tag === "absent") + for (const item of missingTags) await run("git", ["tag", "-a", `${item.name}@${item.version}`, expectedSha, "-m", `${item.name}@${item.version}`]) + 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 */ } + } + for (const item of states) if ((await tagState(`${item.name}@${item.version}`)).kind !== "exact") fail(`remote tag postverification failed for ${item.name}@${item.version}`) + for (const item of states.filter((x) => x.release === "absent")) { + const result = await github("POST", "/releases", { tag_name: `${item.name}@${item.version}`, generate_release_notes: true, draft: false, prerelease: false }) + if (![201, 422].includes(result.status)) fail(`GitHub Release creation failed for ${item.name}@${item.version} (HTTP ${result.status})`) + } + for (const item of states) if ((await releaseState(`${item.name}@${item.version}`)).kind !== "exact") fail(`GitHub Release postverification failed for ${item.name}@${item.version}`) + const missing = states.filter((x) => x.npm === "absent").map((x) => x.name) + if (missing.length) await run("pnpm", ["nx", "release", "publish", `--projects=${missing.join(",")}`]) + for (const item of states) { const state = await npmBounded(item.name, item.version); if (state.kind !== "exact") fail(`npm did not converge for ${item.name}`) } +} +const isMain = process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href +if (isMain) main().catch((error) => { process.stderr.write(`::error::${error.message}\n`); process.exitCode = 1 }) + +export { parseJson, parseTag } diff --git a/scripts/release-finalize-stable.sh b/scripts/release-finalize-stable.sh index 481fc03d..933068d1 100755 --- a/scripts/release-finalize-stable.sh +++ b/scripts/release-finalize-stable.sh @@ -1,98 +1,3 @@ #!/usr/bin/env bash set -euo pipefail - -: "${EXPECTED_SHA:?EXPECTED_SHA is required}" -[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::FINALIZE requires full lowercase expected SHA"; exit 1; } -MAX_NPM_READS=6 -NPM_READ_DELAY=${NPM_READ_DELAY:-10} -WORK=$(mktemp -d) -RECORDS="$WORK/records" -printf '%s\n' \ - '@effectify/hatchet|packages/hatchet/package.json|0.1.0' \ - '@effectify/node-better-auth|packages/node/better-auth/package.json|0.5.12' \ - '@effectify/prisma|packages/prisma/package.json|1.1.13' \ - '@effectify/react-query|packages/react/query/package.json|1.0.0' \ - '@effectify/react-router|packages/react/router/package.json|0.6.0' \ - '@effectify/react-router-better-auth|packages/react/router-better-auth/package.json|0.5.12' \ - '@effectify/solid-query|packages/solid/query/package.json|0.5.13' > "$RECORDS" - -fail() { echo "::error::$*" >&2; exit 1; } -manifest_ok() { - node -e 'const fs=require("node:fs");const [path,name,version]=process.argv.slice(1);let v;try{v=JSON.parse(fs.readFileSync(path,"utf8"))}catch{process.exit(2)}if(!v||typeof v!=="object"||Array.isArray(v)||typeof v.name!=="string"||typeof v.version!=="string"||v.name!==name||v.version!==version){process.stdout.write(`actual=${JSON.stringify({name:typeof v?.name==="string"?v.name:null,version:typeof v?.version==="string"?v.version:null})} expected=${JSON.stringify({name,version})}`);process.exit(1)}' "$1" "$2" "$3" -} -npm_state() { - local name=$1 version=$2 versions latest - versions=$(npm view "$name" versions --json) || return 2 - latest=$(npm view "$name" dist-tags.latest --json) || return 2 - printf '%s\n%s' "$versions" "$latest" | node -e 'const fs=require("node:fs"),[version]=process.argv.slice(1),lines=fs.readFileSync(0,"utf8").split("\n"),vs=JSON.parse(lines.shift()),latest=JSON.parse(lines.join("\n"));if(!(typeof vs==="string"||Array.isArray(vs)&&vs.every(x=>typeof x==="string"))||typeof latest!=="string")process.exit(2);const present=Array.isArray(vs)?vs.includes(version):vs===version;process.exit(present?(latest===version?0:3):1)' "$version" -} -read_npm_bounded() { - local name=$1 version=$2 attempt status - for attempt in $(seq 1 "$MAX_NPM_READS"); do - set +e; npm_state "$name" "$version"; status=$?; set -e - [ "$status" = 0 ] && return 0 - [ "$status" = 1 ] && return 1 - [ "$attempt" = "$MAX_NPM_READS" ] || sleep "$NPM_READ_DELAY" - done - return "$status" -} -verify_tag() { - local tag=$1 remote direct peeled sha - remote=$(git ls-remote --tags origin "refs/tags/$tag" "refs/tags/$tag^{}") || fail "unknown remote tag state for $tag" - [ -n "$remote" ] || return 1 - direct=$(printf '%s\n' "$remote" | awk -v r="refs/tags/$tag" '$2==r{n++}END{print n+0}') - peeled=$(printf '%s\n' "$remote" | awk -v r="refs/tags/$tag^{}" '$2==r{n++}END{print n+0}') - sha=$(printf '%s\n' "$remote" | awk -v r="refs/tags/$tag^{}" '$2==r{print $1}') - [ "$direct" = 1 ] && [ "$peeled" = 1 ] && [ "$sha" = "$EXPECTED_SHA" ] || fail "divergent, partial, or lightweight tag $tag" -} -verify_release() { - local tag=$1 value status err="$WORK/gh-error" - set +e; value=$(gh release view "$tag" --json tagName,isDraft,isPrerelease 2>"$err"); status=$?; set -e - if [ "$status" = 0 ]; then - printf '%s' "$value" | node -e 'const fs=require("node:fs"),tag=process.argv[1],v=JSON.parse(fs.readFileSync(0,"utf8"));if(!v||typeof v!=="object"||Array.isArray(v)||v.tagName!==tag||v.isDraft!==false||v.isPrerelease!==false)process.exit(1)' "$tag" || fail "divergent, draft, or prerelease GitHub Release $tag" - return 0 - fi - [ "$status" = 1 ] && grep -Fqi 'release not found' "$err" && return 1 - fail "unknown GitHub Release state for $tag" -} - -git fetch origin master:refs/remotes/origin/master --no-tags -[ "$(git rev-parse HEAD)" = "$EXPECTED_SHA" ] || fail "HEAD does not match expected SHA" -[ "$(git rev-parse origin/master)" = "$EXPECTED_SHA" ] || fail "origin/master does not match expected SHA" -git config user.name 'github-actions[bot]' -git config user.email 'github-actions[bot]@users.noreply.github.com' -: > "$WORK/missing-projects"; : > "$WORK/missing-tags"; : > "$WORK/missing-releases" -while IFS='|' read -r NAME MANIFEST_PATH VERSION; do - DETAIL=$(manifest_ok "$MANIFEST_PATH" "$NAME" "$VERSION") || { STATUS=$?; [ "$STATUS" = 1 ] && fail "merged manifest identity mismatch for $NAME: $DETAIL"; fail "merged manifest execution or parse failed for $NAME"; } - if read_npm_bounded "$NAME" "$VERSION"; then STATUS=0; else STATUS=$?; fi - case $STATUS in 0) ;; 1) printf '%s\n' "$NAME" >> "$WORK/missing-projects" ;; 3) fail "permanent latest divergence for $NAME" ;; *) fail "npm state unreadable after $MAX_NPM_READS attempts for $NAME" ;; esac - TAG="$NAME@$VERSION" - verify_tag "$TAG" || printf '%s\n' "$TAG" >> "$WORK/missing-tags" - verify_release "$TAG" || printf '%s\n' "$TAG" >> "$WORK/missing-releases" -done < "$RECORDS" - -TAG_REFS=() -while IFS= read -r TAG; do [ -n "$TAG" ] || continue; git show-ref --verify --quiet "refs/tags/$TAG" && fail "local tag collision $TAG"; git tag -a "$TAG" "$EXPECTED_SHA" -m "$TAG"; TAG_REFS+=("refs/tags/$TAG:refs/tags/$TAG"); done < "$WORK/missing-tags" -if [ ${#TAG_REFS[@]} -gt 0 ]; then git push --atomic origin "${TAG_REFS[@]}" || echo "::warning::atomic tag push failed; post-verifying remote state" >&2; fi -while IFS='|' read -r NAME _ VERSION; do TAG="$NAME@$VERSION"; verify_tag "$TAG" || fail "remote tag postverification failed for $TAG"; done < "$RECORDS" -while IFS= read -r TAG; do [ -n "$TAG" ] || continue; gh release create "$TAG" --verify-tag --generate-notes || fail "GitHub Release creation failed for $TAG"; done < "$WORK/missing-releases" -while IFS='|' read -r NAME _ VERSION; do verify_release "$NAME@$VERSION" || fail "GitHub Release postverification failed for $NAME@$VERSION"; done < "$RECORDS" -MISSING_PROJECTS=$(paste -sd, "$WORK/missing-projects") -if [ -n "$MISSING_PROJECTS" ]; then pnpm nx release publish "--projects=$MISSING_PROJECTS"; fi -for ATTEMPT in $(seq 1 "$MAX_NPM_READS"); do - REMAINING=0 - DIVERGENT="" - while IFS='|' read -r NAME _ VERSION; do - set +e; npm_state "$NAME" "$VERSION"; STATUS=$?; set -e - if [ "$STATUS" != 0 ]; then - [ "$STATUS" = 3 ] && DIVERGENT=$NAME - REMAINING=$((REMAINING+1)) - fi - done < "$RECORDS" - [ "$REMAINING" = 0 ] && exit 0 - if [ "$ATTEMPT" = "$MAX_NPM_READS" ]; then - [ -n "$DIVERGENT" ] && fail "permanent latest divergence for $DIVERGENT" - fail "npm did not converge: $REMAINING" - fi - sleep "$NPM_READ_DELAY" -done +exec node "$(dirname "$0")/release-finalize-stable.mjs" "$@" diff --git a/scripts/release-finalize-stable.test.mjs b/scripts/release-finalize-stable.test.mjs index 97b3e1f6..0e51664e 100644 --- a/scripts/release-finalize-stable.test.mjs +++ b/scripts/release-finalize-stable.test.mjs @@ -1,11 +1,12 @@ import assert from "node:assert/strict" -import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs" +import { spawn } from "node:child_process" +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs" +import { createServer } from "node:http" import { tmpdir } from "node:os" import { join } from "node:path" -import { spawn } from "node:child_process" -import nodeTest from "node:test" +import test from "node:test" -const finalize = new URL("release-finalize-stable.sh", import.meta.url).pathname +const script = new URL("release-finalize-stable.mjs", import.meta.url).pathname const sha = "1234567890abcdef1234567890abcdef12345678" const records = [ ["@effectify/hatchet", "packages/hatchet/package.json", "0.1.0"], @@ -16,74 +17,102 @@ const records = [ ["@effectify/react-router-better-auth", "packages/react/router-better-auth/package.json", "0.5.12"], ["@effectify/solid-query", "packages/solid/query/package.json", "0.5.13"], ] -const dispatcher = String.raw`#!/usr/bin/env node -const fs=require('fs'),p=require('path'),cmd=p.basename(process.argv[1]),a=process.argv.slice(2),file=process.env.FAKE_STATE; -let s=JSON.parse(fs.readFileSync(file)); s.log.push([cmd,...a]); const save=()=>fs.writeFileSync(file,JSON.stringify(s)); -const mut=/^(tag|push)$/.test(a[0])&&cmd==='git'||cmd==='pnpm'||cmd==='gh'&&a[0]==='release'&&a[1]==='create'; -if(mut){s.ordinal=(s.ordinal||0)+1;if(+process.env.FAIL_ORDINAL===s.ordinal&&process.env.FAIL_WHEN==='before'){save();process.exit(42)}} -const tag=x=>{s.tags[x]={direct:'object-'+x,peeled:process.env.RETARGET_SHA||s.sha,annotated:true}} +const fake = String.raw`#!/usr/bin/env node +const fs=require('fs'),p=require('path'),cmd=p.basename(process.argv[1]),a=process.argv.slice(2),f=process.env.FAKE_STATE +let s=JSON.parse(fs.readFileSync(f)), out=x=>process.stdout.write(String(x)), save=()=>fs.writeFileSync(f,JSON.stringify(s)) +s.log.push([cmd,...a]); +function finish(code=0){save();process.exit(code)} if(cmd==='git'){ - if(a[0]==='fetch'||a[0]==='config'){save();process.exit(0)} - if(a[0]==='rev-parse'){console.log(a[1]==='HEAD'?(s.head||s.sha):(s.origin||s.sha));save();process.exit(0)} - if(a[0]==='ls-remote'){let t=a[3].slice(10),v=s.tags[t];if(v){console.log(v.direct+'\trefs/tags/'+t);if(v.duplicate)console.log(v.direct+'\trefs/tags/'+t);if(v.annotated!==false&&v.peeled)console.log(v.peeled+'\trefs/tags/'+t+'^{}')}save();process.exit(0)} - if(a[0]==='show-ref'){process.exit(s.local?.[a[3].slice(10)]?0:1)} - if(a[0]==='tag'){let t=a[2];(s.local??={})[t]=true;save()} - if(a[0]==='push'){for(const r of a.slice(3)){let t=r.split(':')[0].slice(10);tag(t)}save()} -}else if(cmd==='gh'){ - let t=a[2]; if(a[1]==='view'){let v=s.releases[t];if(!v){console.error(s.ghError||'release not found');save();process.exit(s.ghStatus||1)}console.log(JSON.stringify(v));save()} - else {s.releases[t]={tagName:t,isDraft:false,isPrerelease:false};save()} -}else if(cmd==='npm'){ - let n=a[1],v=s.npm[n]||{versions:[],latest:'alpha',alpha:'alpha-sentinel',beta:'beta-sentinel'}; if((v.unknown||0)>0){v.unknown--;s.npm[n]=v;save();process.exit(1)} - let value=a[2]==='versions'?v.versions:v.latest;if(a[2]!=='versions'&&(v.staleReads||0)>0){v.staleReads--;value=v.staleLatest||'beta';s.npm[n]=v}console.log(JSON.stringify(value));save() -}else if(cmd==='pnpm'){ - let names=a[3].replace('--projects=','').split(',');for(const n of names){let rec=s.expected[n],prior=s.npm[n]||{},next={...prior,versions:[rec],latest:rec};if(prior.postPublishStale)next.staleReads=prior.postPublishStale;s.npm[n]=next}save() -}else if(cmd==='sleep'){save()}else process.exit(127) -if(mut&&+process.env.FAIL_ORDINAL===s.ordinal&&process.env.FAIL_WHEN==='after'){save();process.exit(43)}save()` - -function world(mode="missing") { - const cwd=mkdtempSync(join(tmpdir(),"finalize-runtime-")), bin=join(cwd,"bin"), state=join(cwd,"state.json") - mkdirSync(bin); writeFileSync(join(bin,"fake"),dispatcher); chmodSync(join(bin,"fake"),0o755) - for(const c of ["git","gh","npm","pnpm"]) symlinkSync("fake",join(bin,c)) - writeFileSync(join(bin,"sleep"),"#!/bin/bash\nexit 0\n"); chmodSync(join(bin,"sleep"),0o755) - const utilities=["bash","env","mktemp","sort","uniq","grep","awk","paste","seq","cat","rm","mkdir","dirname","basename","date","chmod","cp","mv","printf"] - for(const utility of utilities){const source=utility==='bash'?'/bin/bash':[join('/usr/bin',utility),join('/bin',utility)].find(existsSync);assert.ok(source,`required host utility unavailable: ${utility}`);symlinkSync(source,join(bin,utility))} - symlinkSync(process.execPath,join(bin,"node")) - const expected={}, npm={}, tags={}, releases={} - for(const [name,path,version] of records){mkdirSync(join(cwd,path,".."),{recursive:true});writeFileSync(join(cwd,path),JSON.stringify({name,version}));expected[name]=version;npm[name]={versions:mode==='exact'?[version]:[],latest:mode==='exact'?version:'alpha',alpha:'alpha-sentinel',beta:'beta-sentinel'};if(mode==='exact'){const t=`${name}@${version}`;tags[t]={direct:`object-${t}`,peeled:sha,annotated:true};releases[t]={tagName:t,isDraft:false,isPrerelease:false}}} - writeFileSync(state,JSON.stringify({sha,expected,npm,tags,releases,log:[]})) - return {cwd,state,bin} + if(a[0]==='fetch'||a[0]==='config')finish() + if(a[0]==='rev-parse'){out((a[1]==='HEAD'?s.head:s.origin)+'\n');finish()} + if(a[0]==='ls-remote'){ + 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]==='tag'){s.local[a[2]]=true;finish()} + if(a[0]==='push'){for(const r of a.slice(3)){const t=r.split(':')[0].slice(10);s.tags[t]={peeled:s.sha}}finish(s.pushExit||0)} + finish(127) +} +if(cmd==='npm'){ + const n=a[1],field=a[2],v=s.npm[n],q=v[field==='versions'?'versionsQueue':'latestQueue'];let x=q&&q.length?q.shift():field==='versions'?v.versions:v.latest + if(x&&typeof x==='object'&&x.exit){process.stderr.write(x.stderr||'failure');finish(x.exit)} + if(x&&typeof x==='object'&&Object.hasOwn(x,'raw'))out(x.raw);else out(JSON.stringify(x)+(v.pretty?'\n':'\n'));finish() +} +if(cmd==='pnpm'){ + const names=a[3].slice(11).split(','),count=s.publishSubset??names.length;for(const n of names.slice(0,count)){const v=s.expected[n],old=s.npm[n];old.versions=[v];old.latest=v;if(old.delayedLatest){old.latest='alpha';old.latestQueue=Array(old.delayedLatest).fill('alpha').concat(v)}} finish(s.publishExit||0) } -function run(w,extra={}) { return new Promise(resolve=>{const child=spawn("bash",[finalize],{cwd:w.cwd,env:{...process.env,PATH:w.bin,EXPECTED_SHA:sha,NPM_READ_DELAY:"0",FAKE_STATE:w.state,...extra}});let stdout="",stderr="";child.stdout.setEncoding("utf8").on("data",x=>stdout+=x);child.stderr.setEncoding("utf8").on("data",x=>stderr+=x);child.on("close",(status,signal)=>resolve({status,signal,stdout,stderr}))}) } -function runUnknown(w) { return new Promise(resolve=>{const child=spawn("finalize-harness-unknown-command",[],{cwd:w.cwd,env:{PATH:w.bin}});child.on("error",error=>resolve(error));child.on("close",status=>resolve(status))}) } -const state=w=>JSON.parse(readFileSync(w.state)) -const mutations=s=>s.log.filter(([c,...a])=>c==='pnpm'||c==='gh'&&a[0]==='release'&&a[1]==='create'||c==='git'&&['tag','push'].includes(a[0])) +finish(127)` -const options={timeout:60_000} -const scenarios=[] -const test=(name,_options,fn)=>scenarios.push({name,fn}) +function load(file) { return JSON.parse(readFileSync(file, "utf8")) } +function save(file, value) { writeFileSync(file, JSON.stringify(value)) } +function mutations(state) { return state.log.filter(([c, a]) => c === "pnpm" || (c === "git" && (a === "tag" || a === "push")) || (c === "http" && a === "POST")) } -test("harness PATH is hermetic and unknown commands fail",options,async()=>{const w=world();assert.equal(w.bin.includes(process.env.PATH||"\0"),false);const result=await runUnknown(w);assert.equal(result.code,"ENOENT")}) +async function world(mode = "absent") { + const cwd = mkdtempSync(join(tmpdir(), "stable-finalize-")), bin = join(cwd, "bin"), stateFile = join(cwd, "state.json") + mkdirSync(bin); writeFileSync(join(bin, "fake.cjs"), fake); chmodSync(join(bin, "fake.cjs"), 0o755) + for (const command of ["git", "npm", "pnpm"]) symlinkSync("fake.cjs", join(bin, command)) + symlinkSync(process.execPath, join(bin, "node")) + const expected = {}, npm = {}, tags = {}, releases = {} + for (const [name, path, version] of records) { + mkdirSync(join(cwd, path, ".."), { recursive: true }); writeFileSync(join(cwd, path), JSON.stringify({ name, version })) + expected[name] = version; npm[name] = { versions: mode === "exact" ? [version] : [], latest: mode === "exact" ? version : "alpha", alpha: "alpha-sentinel", beta: "beta-sentinel" } + if (mode === "exact") { const tag = `${name}@${version}`; tags[tag] = { peeled: sha }; releases[tag] = { tag_name: tag, draft: false, prerelease: false } } + } + save(stateFile, { sha, head: sha, origin: sha, expected, npm, tags, releases, local: {}, log: [] }) + const server = createServer((request, response) => { + const state = load(stateFile), method = request.method, path = request.url; state.log.push(["http", method, path]) + const send = (status, body = "") => { save(stateFile, state); response.writeHead(status, { "content-type": "application/json" }); response.end(typeof body === "string" ? body : JSON.stringify(body)) } + if (method === "GET") { + const tag = decodeURIComponent(path.split("/releases/tags/")[1] || ""), configured = state.ghReadStatus + if (configured) return send(configured, { message: "configured" }) + return state.releases[tag] ? send(200, state.releases[tag]) : send(404, { message: "not found" }) + } + let body = ""; request.on("data", x => body += x); request.on("end", () => { + const value = JSON.parse(body), tag = value.tag_name, status = state.ghCreateStatus || 201 + if (state.ghCreateMaterializes !== false) state.releases[tag] = { tag_name: tag, draft: false, prerelease: false } + send(status, status === 422 ? { message: "already exists" } : state.releases[tag]) + }) + }) + 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 = []) { + 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 } }) + 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)) } }) +} +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") } } -test("all missing converges through real script, with exact atomic refs, releases, projects, and latest",options,async()=>{ - const w=world(),r=await run(w),s=state(w);assert.equal(r.status,0,JSON.stringify({stdout:r.stdout,stderr:r.stderr,log:s.log.slice(-8)}));assert.equal(Object.keys(s.tags).length,7);assert.ok(Object.values(s.tags).every(x=>x.peeled===sha&&x.annotated));assert.equal(Object.keys(s.releases).length,7);assert.deepEqual(Object.keys(s.npm).sort(),records.map(x=>x[0]).sort());for(const [n,,v] of records)assert.deepEqual(s.npm[n],{versions:[v],latest:v,alpha:'alpha-sentinel',beta:'beta-sentinel'}); - const push=s.log.find(x=>x[0]==='git'&&x[1]==='push');assert.deepEqual(push.slice(1,4),['push','--atomic','origin']);assert.deepEqual(push.slice(4),records.map(([n,,v])=>`refs/tags/${n}@${v}:refs/tags/${n}@${v}`));const pub=s.log.find(x=>x[0]==='pnpm');assert.equal(pub[4],`--projects=${records.map(x=>x[0]).join(',')}`) +const scenarioNames = [] +test("hermetic Node CLI matrix (56 explicit scenarios)", { 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") + 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") + for (const subset of [1,3,6]) await add(`publish nonzero after subset ${subset} then replay`, async s=>{s.publishSubset=subset;s.publishExit=42}, async(r,s,w)=>{assert.notEqual(r.status,0);delete s.publishExit;delete s.publishSubset;save(w.stateFile,s);const replay=await run(w);assert.equal(replay.status,0,replay.stderr);exactState(load(w.stateFile))}, "absent") + for (const [format,value] of [["compact",[records[0][2]]],["pretty",{raw:`[\n "${records[0][2]}"\n]\n`}],["scalar",records[0][2]]]) await add(`npm ${format} versions JSON`, async s=>{s.npm[records[0][0]].versionsQueue=[value]}, (r)=>assert.equal(r.status,0,r.stderr)) + await add("npm delayed latest converges", async s=>{s.npm[records[0][0]].latestQueue=["beta","beta",records[0][2]]}, r=>assert.equal(r.status,0,r.stderr)) + for (const [name,spec] of [["null",null],["empty",{raw:""}],["truncated",{raw:"[\"1.0"}],["object",{}],["mixed",[records[0][2],3]],["DNS",{exit:1,stderr:"ENOTFOUND"}],["auth",{exit:1,stderr:"E401"}],["rate",{exit:1,stderr:"E429"}],["5xx",{exit:1,stderr:"E503"}]]) await add(`npm ${name} is unknown and never publishes`, async s=>{s.npm[records[0][0]].versionsQueue=Array(6).fill(spec)}, (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}) + for (const status of [401,403,429,500,503]) await add(`GitHub ${status} read is unknown`, async s=>{s.ghReadStatus=status}, (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}) + await add("GitHub 404 is proven absent", async s=>{delete s.releases[`${records[0][0]}@${records[0][2]}`]}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s)}) + await add("GitHub 422 create reconciles materialized exact release", async s=>{s.ghCreateStatus=422}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s)}, "absent") + await add("GitHub 422 without exact state fails", async s=>{s.ghCreateStatus=422;s.ghCreateMaterializes=false}, (r)=>assert.notEqual(r.status,0), "absent") + for (const [name,raw] of [["lightweight",`${"a".repeat(40)}\trefs/tags/$TAG\n`],["malformed","garbage\n"],["wrong SHA",`${"a".repeat(40)}\trefs/tags/$TAG\n${"f".repeat(40)}\trefs/tags/$TAG^{}\n`],["duplicate",`${"a".repeat(40)}\trefs/tags/$TAG\n${"b".repeat(40)}\trefs/tags/$TAG\n${sha}\trefs/tags/$TAG^{}\n`]]) await add(`tag ${name} fails closed`, async s=>{s.tags[`${records[0][0]}@${records[0][2]}`]={raw}}, (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}) + await add("manifest name mismatch fails before mutation", async(s,w)=>writeFileSync(join(w.cwd,records[0][1]),JSON.stringify({name:"wrong",version:records[0][2]})), (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}) + 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"]) + assert.equal(scenarioNames.length, 56) }) -test("exact replay is mutation-free and preserves alpha/beta",options,async()=>{const w=world('exact'),r=await run(w),s=state(w);assert.equal(r.status,0,r.stderr);assert.deepEqual(mutations(s),[]);for(const value of Object.values(s.npm)){assert.equal(value.alpha,'alpha-sentinel');assert.equal(value.beta,'beta-sentinel')}}) - test("partial npm converges without changing alpha/beta",options,async()=>{const w=world('exact'),s=state(w),[name,,version]=records[0];s.npm[name].versions=[];s.npm[name].latest='alpha';writeFileSync(w.state,JSON.stringify(s));const r=await run(w),done=state(w);assert.equal(r.status,0,r.stderr);assert.deepEqual(done.npm[name],{versions:[version],latest:version,alpha:'alpha-sentinel',beta:'beta-sentinel'})}) - test("partial tags converge exactly",options,async()=>{const w=world('exact'),s=state(w),[name,,version]=records[0],tag=`${name}@${version}`;delete s.tags[tag];writeFileSync(w.state,JSON.stringify(s));const r=await run(w),done=state(w);assert.equal(r.status,0,r.stderr);assert.deepEqual(done.tags[tag],{direct:`object-${tag}`,peeled:sha,annotated:true})}) - test("partial releases converge exactly",options,async()=>{const w=world('exact'),s=state(w),[name,,version]=records[0],tag=`${name}@${version}`;delete s.releases[tag];writeFileSync(w.state,JSON.stringify(s));const r=await run(w),done=state(w);assert.equal(r.status,0,r.stderr);assert.deepEqual(done.releases[tag],{tagName:tag,isDraft:false,isPrerelease:false})}) -for(const kind of ['identity','head','origin']) test(`${kind} mismatch fails before remote mutation`,options,async()=>{const w=world();let s=state(w);if(kind==='identity'){writeFileSync(join(w.cwd,records[0][1]),JSON.stringify({name:'wrong',version:'0.1.0'}))}else{s[kind]='f'.repeat(40);writeFileSync(w.state,JSON.stringify(s))}const r=await run(w);assert.notEqual(r.status,0);assert.deepEqual(mutations(state(w)),[]);assert.match(r.stderr,/mismatch|does not match/)}) -for(const when of ['before','after']) for(let ordinal=1;ordinal<=16;ordinal++) { - if(when==='after'&&ordinal===8)continue - test(`mutable command ${ordinal} interrupted ${when} is forward-only and replay converges`,options,async()=>{const w=world();const first=await run(w,{FAIL_ORDINAL:String(ordinal),FAIL_WHEN:when});assert.notEqual(first.status,0,`${when} ${ordinal}`);let s=state(w);s.ordinal=0;s.local={};writeFileSync(w.state,JSON.stringify(s));const replay=await run(w);s=state(w);assert.equal(replay.status,0,JSON.stringify({when,ordinal,stdout:replay.stdout,stderr:replay.stderr}));assert.equal(Object.keys(s.tags).length,7);assert.ok(Object.values(s.tags).every(x=>x.peeled===sha&&x.annotated));assert.equal(Object.keys(s.releases).length,7);for(const [n,,v] of records)assert.deepEqual(s.npm[n],{versions:[v],latest:v,alpha:'alpha-sentinel',beta:'beta-sentinel'})}) - } - test("atomic tag push response-loss-converged only after exact postcondition",options,async()=>{const w=world();const result=await run(w,{FAIL_ORDINAL:'8',FAIL_WHEN:'after'}),s=state(w);assert.equal(result.status,0,result.stderr);assert.equal(Object.keys(s.tags).length,7);assert.ok(Object.values(s.tags).every(x=>x.peeled===sha&&x.annotated))}) -for(const kind of ['lightweight','duplicate','tag-sha','draft','prerelease','wrong-tag','latest','gh-auth']) test(`${kind} state fails closed without mutation`,options,async()=>{const w=world('exact'),s=state(w),[n,,v]=records[0],t=`${n}@${v}`;if(kind==='lightweight')s.tags[t].annotated=false;if(kind==='duplicate')s.tags[t].duplicate=true;if(kind==='tag-sha')s.tags[t].peeled='f'.repeat(40);if(kind==='draft')s.releases[t].isDraft=true;if(kind==='prerelease')s.releases[t].isPrerelease=true;if(kind==='wrong-tag')s.releases[t].tagName='wrong';if(kind==='latest')s.npm[n].latest='beta';if(kind==='gh-auth'){delete s.releases[t];s.ghError='authentication required';s.ghStatus=1}writeFileSync(w.state,JSON.stringify(s));const r=await run(w);assert.notEqual(r.status,0,kind);assert.deepEqual(mutations(state(w)),[])}) -test("npm eventual visibility succeeds",options,async()=>{const w=world('exact'),s=state(w);s.npm[records[0][0]].unknown=2;writeFileSync(w.state,JSON.stringify(s));assert.equal((await run(w)).status,0)}) -for(const boundary of ["preflight","postpublish"]) test(`stale latest converges at ${boundary} visibility boundary`,options,async()=>{const w=world(boundary==="preflight"?"exact":"missing"),s=state(w),[name]=records[0];if(boundary==="preflight")s.npm[name].staleReads=2;else s.npm[name].postPublishStale=2;writeFileSync(w.state,JSON.stringify(s));const r=await run(w);assert.equal(r.status,0,r.stderr)}) -for(const boundary of ["preflight","postpublish"]) test(`persistent latest divergence exhausts exactly six reads at ${boundary}`,options,async()=>{const w=world(boundary==="preflight"?"exact":"missing"),s=state(w),[name]=records[0];if(boundary==="preflight")s.npm[name].staleReads=99;else s.npm[name].postPublishStale=99;writeFileSync(w.state,JSON.stringify(s));const r=await run(w),done=state(w);assert.notEqual(r.status,0);assert.match(r.stderr,/permanent latest divergence/);const boundaryLog=boundary==="postpublish"?done.log.slice(done.log.findIndex(([c])=>c==="pnpm")+1):done.log;assert.equal(boundaryLog.filter(([c,command,n,field])=>c==="npm"&&command==="view"&&n===name&&field==="versions").length,6);assert.deepEqual(boundary==="preflight"?mutations(done):mutations({...done,log:boundaryLog}),[])}) -test("npm unreadable exhaustion diagnoses",options,async()=>{const w=world('exact'),s=state(w);s.npm[records[0][0]].unknown=99;writeFileSync(w.state,JSON.stringify(s));const r=await run(w);assert.notEqual(r.status,0);assert.match(r.stdout+r.stderr,/unreadable after 6 attempts/)}) -nodeTest("release finalize stable scenarios",{concurrency:4,timeout:180_000},async t=>{ - await Promise.all(scenarios.map(({name,fn})=>t.test(name,options,fn))) +test("static command boundary keeps shell and destructive repairs out", () => { + const source = readFileSync(script, "utf8") + assert.match(source, /spawn\(file, args, \{ shell: false/) + assert.doesNotMatch(source, /execSync|spawnSync|shell: true|npm dist-tag|npm unpublish|release delete|tag", "-f/) }) diff --git a/scripts/release-policy-contract.test.mjs b/scripts/release-policy-contract.test.mjs index 3047ea18..2f08e600 100644 --- a/scripts/release-policy-contract.test.mjs +++ b/scripts/release-policy-contract.test.mjs @@ -19,7 +19,7 @@ const workflows = { } const readme = read("README.md") const setup = read(".github/SETUP.md") -const stableFinalizeScript = read("scripts/release-finalize-stable.sh") +const stableFinalizeScript = `${read("scripts/release-finalize-stable.sh")}\n${read("scripts/release-finalize-stable.mjs")}` const releaseProjects = [ "@effectify/react-router", @@ -518,18 +518,50 @@ const betaViolations = (source) => { return violations } -const isStableReleaseValidationCommand = (command) => - /printf '%s' "\$RELEASE" \| node -e /.test(command) && - /const value=JSON\.parse\(fs\.readFileSync\(0,"utf8"\)\)/.test(command) && - /typeof value\.tagName!=="string"/.test(command) && - /typeof value\.isDraft!=="boolean"/.test(command) && - /typeof value\.isPrerelease!=="boolean"/.test(command) && - /value\.tagName!==tag\|\|value\.isDraft\|\|value\.isPrerelease/.test(command) - const stableViolations = (source, finalizeScript = stableFinalizeScript) => { const violations = [] const active = withoutComments(source) const activeFinalize = withoutComments(finalizeScript) + if (activeFinalize.includes('import { readFile } from "node:fs/promises"')) { + const required = [ + ["wrapper exec", /exec node .*release-finalize-stable\.mjs/, activeFinalize], + ["strict SHA", /\^\[0-9a-f\]\{40\}\$/, activeFinalize], + ["fresh master", /master:refs\/remotes\/origin\/master/, activeFinalize], + ["manifest identity", /value\.name !== name \|\| value\.version !== version/, activeFinalize], + ["bounded npm reads", /const maxReads = 6\b/, activeFinalize], + ["independent npm documents", /const versionsDoc[\s\S]*const latestDoc/, activeFinalize], + ["strict tag parse", /direct\.length === 1 && peeled\.length === 1 && peeled\[0\] === expectedSha/, activeFinalize], + ["HTTP 404 absence", /result\.status === 404/, activeFinalize], + ["unknown Release fail closed", /result\.status !== 200/, activeFinalize], + ["annotated tag", /\["tag", "-a",/, 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], + ["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], + ["PREPARE manifest name type", /typeof value\.name!=="string"/, active], + ["PREPARE manifest version type", /typeof value\.version!=="string"/, active], + ["PREPARE exact SHA", /\[\[ "\$EXPECTED_SHA" =~ \^\[0-9a-f\]\{40\}\$ \]\]/, active], + ["PREPARE Nx flags", /--git-commit=false --git-tag=false --git-push=false --stage-changes=false/, active], + ["PREPARE expected path equality", /cmp -s "\$EXPECTED_PATHS" "\$ACTUAL"/, active], + ["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], + ] + 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")) + 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)) { + violations.push("stable PREPARE MANIFEST_PATH manifest command") + } + if (/npm dist-tag|npm unpublish|gh release delete|git tag -f|--tag=(?:alpha|beta)/.test(activeFinalize)) violations.push("stable destructive or channel repair") + const order = ["const states = await inspect()", '["tag", "-a"', '["push", "--atomic"', 'github("POST"', 'releaseState(`${item.name}', '["nx", "release", "publish"', "npmBounded(item.name"].map((token) => activeFinalize.indexOf(token)) + if (order.some((position) => position < 0) || order.some((position, index) => index > 0 && position <= order[index - 1])) violations.push("stable ordering") + return violations + } if (/\bjq\b/.test(`${active}\n${activeFinalize}`)) violations.push("stable jq dependency") const required = [ ["dispatch", /^\s*workflow_dispatch:/m], @@ -726,10 +758,10 @@ const stableViolations = (source, finalizeScript = stableFinalizeScript) => { const releasePolicyBootstrapViolations = (source) => { const steps = extractSteps(extractJob(source, "release-policy")) - const setupNodeIndex = steps.findIndex((step) => /^actions\/setup-node@/.test(step.uses)) + const setupNodeIndex = steps.findIndex((step) => step.uses.startsWith("actions/setup-node@")) if (setupNodeIndex === -1) return ["release-policy setup-node"] - const pnpmIndex = steps.findIndex((step) => /^pnpm\/action-setup@/.test(step.uses)) + const pnpmIndex = steps.findIndex((step) => step.uses.startsWith("pnpm/action-setup@")) const cacheDisabled = steps[setupNodeIndex].packageManagerCache === "false" return pnpmIndex !== -1 && pnpmIndex < setupNodeIndex ? [] : cacheDisabled ? [] : ["release-policy setup-node cache"] } @@ -759,12 +791,6 @@ const mutateStep = (source, stepName, before, after) => { return source.replace(step.source, mutate(step.source, before, after)) } -const mutateStable = (candidate, before, after) => { - const stable = candidate.stable.replace(before, after) - if (stable !== candidate.stable) return { ...candidate, stable } - return { ...candidate, stableFinalize: mutate(candidate.stableFinalize ?? stableFinalizeScript, before, after) } -} - test("dev pushes retain exact-range conditional alpha publication", () => { assert.deepEqual(channelViolations("alpha", workflows.alpha), []) }) @@ -971,6 +997,19 @@ test("beta FINALIZE conflict and ordering mutations fail closed", () => { 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}$"], + ["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"], + ["accept auth as absence", "result.status === 404", "result.status >= 400"], + ["lightweight tags", '["tag", "-a",', '["tag",'], + ["non-atomic push", '["push", "--atomic", "origin", ...refs]', '["push", "origin", ...refs]'], + ["publish all projects", 'states.filter((x) => x.npm === "absent")', "states"], + ]) { + const changed = mutate(stableFinalizeScript, before, after) + assert.notDeepEqual(stableViolations(policy.stable, 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")) @@ -979,75 +1018,21 @@ test("protected stable PREPARE and FINALIZE reject independent safety mutations" const prepareArgument = mutateStep(policy.stable, "PREPARE protected stable", /"\$MANIFEST_PATH" "\$NAME"/g, '"$PATH" "$NAME"') assert.ok(stableViolations(prepareArgument).includes("stable PREPARE MANIFEST_PATH manifest command")) - const commentedRequiredCommand = mutate( - stableFinalizeScript, - 'git push --atomic origin "${TAG_REFS[@]}"', - '# git push --atomic origin "${TAG_REFS[@]}"', - ) - assert.notDeepEqual(stableViolations(policy.stable, commentedRequiredCommand), [], "FINALIZE commented required command") - - for (const [name, before, after] of [ - ["JSON parser", /JSON\.parse/g, "JSON.parseSafe"], - ["manifest path arguments", `' "$1" "$2" "$3"`, `' "$PATH" "$2" "$3"`], - ["reserved PATH binding", "read -r NAME MANIFEST_PATH VERSION", "read -r NAME PATH VERSION"], - ["Release JSON input", 'JSON.parse(fs.readFileSync(0,"utf8"))', "{tagName:tag,isDraft:false,isPrerelease:false}"], - ]) { - const changedScript = mutate(stableFinalizeScript, before, after) - assert.notDeepEqual(stableViolations(policy.stable, changedScript), [], `FINALIZE ${name}`) - } + const shortSha = mutate(policy.stable, "^[0-9a-f]{40}$", "^[0-9a-f]{7,40}$") + assert.notDeepEqual(stableViolations(shortSha), [], "allow abbreviated PREPARE SHA") for (const [name, before, after] of [ - ["allow abbreviated SHA", "^[0-9a-f]{40}$", "^[0-9a-f]{7,40}$"], - ["fetch tags", "--no-tags", "--tags"], - ["skip current master", 'test "$HEAD_SHA" = "$REMOTE_SHA"', 'test "$HEAD_SHA" != "$REMOTE_SHA"'], - ["skip exact SHA", 'test "$HEAD_SHA" = "$EXPECTED_SHA"', 'test "$HEAD_SHA" != "$EXPECTED_SHA"'], ["enable Nx commits", "--git-commit=false", "--git-commit=true"], ["enable Nx tags", "--git-tag=false", "--git-tag=true"], ["enable Nx pushes", "--git-push=false", "--git-push=true"], ["enable Nx staging", "--stage-changes=false", "--stage-changes=true"], ["weaken path comparison", 'cmp -s "$EXPECTED_PATHS" "$ACTUAL"', 'test -s "$ACTUAL"'], ["stage broad tree", 'git add --pathspec-from-file="$EXPECTED_PATHS"', "git add -A"], + ["weaken staged paths", 'cmp -s "$EXPECTED_PATHS" /tmp/stable-staged', 'test -s /tmp/stable-staged'], ["push master", "HEAD:refs/heads/release/stable-$SHA_PREFIX", "HEAD:refs/heads/master"], - ["restore jq", "node -e", "jq -e"], - ["read latest as beta", "dist-tags.latest", "dist-tags.beta"], - ["accept divergent latest", "permanent latest divergence", "existing stable accepted"], - ["create lightweight tag", 'git tag -a "$TAG" "$EXPECTED_SHA" -m "$TAG"', 'git tag "$TAG" "$EXPECTED_SHA"'], - ["target tag at HEAD", 'git tag -a "$TAG" "$EXPECTED_SHA" -m "$TAG"', 'git tag -a "$TAG" HEAD -m "$TAG"'], - ["remove atomic push", "git push --atomic origin", "git push origin"], - ["use wildcard refspec", "refs/tags/$TAG:refs/tags/$TAG", "refs/tags/*:refs/tags/*"], - ["create prerelease", "--verify-tag --generate-notes", "--verify-tag --prerelease --generate-notes"], - ["publish beta", 'release publish "--projects=$MISSING_PROJECTS"', 'release publish "--projects=$MISSING_PROJECTS" --tag=beta'], - ["unbound retries", "MAX_NPM_READS=6", "MAX_NPM_READS=60"], - ["shorten propagation wait", "NPM_READ_DELAY=${NPM_READ_DELAY:-10}", "NPM_READ_DELAY=1"], - ]) - assertMutationFails(name, policy, (candidate) => mutateStable(candidate, before, after)) - - for (const command of [ - "npm dist-tag add @effectify/hatchet@0.1.0 latest", - "npm unpublish @effectify/hatchet@0.1.0", - 'gh release delete "$TAG" --yes', - 'git tag -f "$TAG" "$EXPECTED_SHA"', - ]) { - assertMutationFails(`reject destructive stable repair ${command}`, policy, (candidate) => ({ - ...candidate, - stable: `${candidate.stable}\n${command}\n`, - })) - } - - for (const command of [ - "node --test scripts/release-policy-contract.test.mjs", - 'pnpm nx run-many -t build "--projects=$PROJECTS" --parallel=3', - 'pnpm nx run-many -t test "--projects=$PROJECTS" --parallel=3 --passWithNoTests', - "pnpm nx test @effectify/react-router", - "pnpm nx run @effectify/react-router-example:migration:test", - "pnpm nx run @effectify/react-router-example:migration:verify", - "pnpm nx run @effectify/react-router-example:migration:manifest", - "pnpm nx run @effectify/react-router-example:consolidation:verify", ]) { - assertMutationFails(`remove gate ${command}`, policy, (candidate) => ({ - ...candidate, - stable: mutate(candidate.stable, command, "echo gate-removed"), - })) + const changed = mutateStep(policy.stable, "PREPARE protected stable", before, after) + assert.notDeepEqual(stableViolations(changed), [], name) } }) @@ -1112,13 +1097,13 @@ test("protected stable promotion exposes exact PREPARE and FINALIZE contracts", /pnpm nx release version "\$NEW" "--projects=\$NAME" --git-commit=false --git-tag=false --git-push=false --stage-changes=false/, ) assert.match(active, /HEAD:refs\/heads\/release\/stable-\$SHA_PREFIX/) - assert.match(active, /git push --atomic origin "\$\{TAG_REFS\[@\]\}"/) - assert.match(active, /gh release create "\$TAG" --verify-tag --generate-notes/) - assert.match(active, /pnpm nx release publish "--projects=\$MISSING_PROJECTS"/) - assert.doesNotMatch(active, /release publish[^\n]*--tag=/) - assert.match(active, /MAX_NPM_READS=6/) - assert.match(active, /NPM_READ_DELAY=\$\{NPM_READ_DELAY:-10\}/) - assert.match(active, /sleep "\$NPM_READ_DELAY"/) + assert.match(active, /run\("git", \["push", "--atomic", "origin", \.\.\.refs\]\)/) + assert.match(active, /github\("POST", "\/releases"/) + assert.match(active, /run\("pnpm", \["nx", "release", "publish"/) + assert.doesNotMatch(active, /--tag=(?:alpha|beta)/) + assert.match(active, /const maxReads = 6/) + assert.match(active, /NPM_READ_DELAY_MS/) + assert.match(active, /await sleep\(delayMs\)/) }) test("beta structurally suppresses only the exact stable matrix", () => { From 35930b5bd68fcc199f2f57a6a6f0387e6c3c7eb3 Mon Sep 17 00:00:00 2001 From: kattsushi Date: Sat, 29 Aug 2026 21:13:30 -0600 Subject: [PATCH 2/2] fix(release): close finalizer replay gaps --- scripts/release-finalize-stable.mjs | 30 +++- scripts/release-finalize-stable.test.mjs | 22 ++- scripts/release-policy-contract.test.mjs | 209 +---------------------- 3 files changed, 44 insertions(+), 217 deletions(-) diff --git a/scripts/release-finalize-stable.mjs b/scripts/release-finalize-stable.mjs index d62dcd82..ddeaef66 100644 --- a/scripts/release-finalize-stable.mjs +++ b/scripts/release-finalize-stable.mjs @@ -62,13 +62,14 @@ async function npmState(name, version) { return !present ? { kind: "absent" } : latest === version ? { kind: "exact" } : { kind: "divergent" } } catch { return { kind: "unknown" } } } -async function npmBounded(name, version) { +async function npmBounded(name, version, { acceptAbsent = false } = {}) { let state for (let attempt = 1; attempt <= maxReads; attempt++) { state = await npmState(name, version) - if (state.kind === "exact" || state.kind === "absent") return state + if (state.kind === "exact" || (acceptAbsent && state.kind === "absent")) return state if (attempt < maxReads) await sleep(delayMs) } + if (state.kind === "absent") fail(`npm version remained absent after ${maxReads} attempts for ${name}`) fail(state.kind === "divergent" ? `permanent latest divergence for ${name}` : `npm state unreadable after ${maxReads} attempts for ${name}`) } function parseTag(text, tag) { @@ -87,6 +88,15 @@ async function tagState(tag) { try { result = await run("git", ["ls-remote", "--tags", "origin", `refs/tags/${tag}`, `refs/tags/${tag}^{}`]) } catch { return { kind: "unknown" } } return parseTag(result.stdout, tag) } +async function localTagState(tag) { + let result + try { result = await run("git", ["for-each-ref", "--format=%(objecttype)%09%(*objectname)", `refs/tags/${tag}`]) } catch { return { kind: "unknown" } } + if (result.stdout === "") return { kind: "absent" } + 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" } +} function repository() { if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY fail("GITHUB_REPOSITORY is required") @@ -119,7 +129,7 @@ async function inspect() { const states = [] for (const [name, path, version] of records) { await manifest(name, path, version) - const npm = await npmBounded(name, version), tag = await tagState(`${name}@${version}`), release = await releaseState(`${name}@${version}`) + const npm = await npmBounded(name, version, { acceptAbsent: true }), tag = await tagState(`${name}@${version}`), release = await releaseState(`${name}@${version}`) for (const [label, state] of [["tag", tag], ["GitHub Release", release]]) if (!['exact','absent'].includes(state.kind)) fail(`${label} state is ${state.kind} for ${name}@${version}${state.status ? ` (HTTP ${state.status})` : ""}`) states.push({ name, version, npm: npm.kind, tag: tag.kind, release: release.kind }) } @@ -131,10 +141,18 @@ async function main() { if (!/^[0-9a-f]{40}$/.test(expectedSha)) fail("FINALIZE requires full lowercase expected SHA") const states = await inspect() if (preflight) { process.stdout.write(`${JSON.stringify({ ok: true, expectedSha, states })}\n`); return } - await run("git", ["config", "user.name", "github-actions[bot]"]) - await run("git", ["config", "user.email", "github-actions[bot]@users.noreply.github.com"]) const missingTags = states.filter((x) => x.tag === "absent") - for (const item of missingTags) await run("git", ["tag", "-a", `${item.name}@${item.version}`, expectedSha, "-m", `${item.name}@${item.version}`]) + const localTags = [] + for (const item of missingTags) { + const tag = `${item.name}@${item.version}`, local = await localTagState(tag) + if (!['exact','absent'].includes(local.kind)) fail(`local tag state is ${local.kind} for ${tag}`) + localTags.push({ item, tag, local: local.kind }) + } + if (localTags.some((x) => x.local === "absent")) { + 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]) 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 0e51664e..bc7b672e 100644 --- a/scripts/release-finalize-stable.test.mjs +++ b/scripts/release-finalize-stable.test.mjs @@ -28,17 +28,19 @@ if(cmd==='git'){ if(a[0]==='ls-remote'){ 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]==='tag'){s.local[a[2]]=true;finish()} - if(a[0]==='push'){for(const r of a.slice(3)){const t=r.split(':')[0].slice(10);s.tags[t]={peeled:s.sha}}finish(s.pushExit||0)} + 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]==='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) } if(cmd==='npm'){ const n=a[1],field=a[2],v=s.npm[n],q=v[field==='versions'?'versionsQueue':'latestQueue'];let x=q&&q.length?q.shift():field==='versions'?v.versions:v.latest + if(q&&q.length===0){if(field==='versions')v.versions=x;else v.latest=x} if(x&&typeof x==='object'&&x.exit){process.stderr.write(x.stderr||'failure');finish(x.exit)} if(x&&typeof x==='object'&&Object.hasOwn(x,'raw'))out(x.raw);else out(JSON.stringify(x)+(v.pretty?'\n':'\n'));finish() } if(cmd==='pnpm'){ - const names=a[3].slice(11).split(','),count=s.publishSubset??names.length;for(const n of names.slice(0,count)){const v=s.expected[n],old=s.npm[n];old.versions=[v];old.latest=v;if(old.delayedLatest){old.latest='alpha';old.latestQueue=Array(old.delayedLatest).fill('alpha').concat(v)}} finish(s.publishExit||0) + const names=a[3].slice(11).split(','),count=s.publishSubset??names.length;for(const n of names.slice(0,count)){const v=s.expected[n],old=s.npm[n];old.versions=[v];old.latest=v;if(old.delayedVersions){old.versions=[];old.versionsQueue=Array(old.delayedVersions).fill([]).concat([[v]])}if(old.delayedLatest){old.latest='alpha';old.latestQueue=Array(old.delayedLatest).fill('alpha').concat(v)}} finish(s.publishExit||0) } finish(127)` @@ -57,7 +59,7 @@ async function world(mode = "absent") { expected[name] = version; npm[name] = { versions: mode === "exact" ? [version] : [], latest: mode === "exact" ? version : "alpha", alpha: "alpha-sentinel", beta: "beta-sentinel" } if (mode === "exact") { const tag = `${name}@${version}`; tags[tag] = { peeled: sha }; releases[tag] = { tag_name: tag, draft: false, prerelease: false } } } - save(stateFile, { sha, head: sha, origin: sha, expected, npm, tags, releases, local: {}, log: [] }) + save(stateFile, { sha, head: sha, origin: sha, expected, npm, tags, releases, localTags: {}, log: [] }) const server = createServer((request, response) => { const state = load(stateFile), method = request.method, path = request.url; state.log.push(["http", method, path]) const send = (status, body = "") => { save(stateFile, state); response.writeHead(status, { "content-type": "application/json" }); response.end(typeof body === "string" ? body : JSON.stringify(body)) } @@ -87,7 +89,7 @@ async function scenario(t, name, setup, verify, mode = "exact", args = []) { 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") } } const scenarioNames = [] -test("hermetic Node CLI matrix (56 explicit scenarios)", { timeout: 120_000 }, async t => { +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") @@ -97,18 +99,22 @@ test("hermetic Node CLI matrix (56 explicit scenarios)", { timeout: 120_000 }, a for (const subset of [1,3,6]) await add(`publish nonzero after subset ${subset} then replay`, async s=>{s.publishSubset=subset;s.publishExit=42}, async(r,s,w)=>{assert.notEqual(r.status,0);delete s.publishExit;delete s.publishSubset;save(w.stateFile,s);const replay=await run(w);assert.equal(replay.status,0,replay.stderr);exactState(load(w.stateFile))}, "absent") for (const [format,value] of [["compact",[records[0][2]]],["pretty",{raw:`[\n "${records[0][2]}"\n]\n`}],["scalar",records[0][2]]]) await add(`npm ${format} versions JSON`, async s=>{s.npm[records[0][0]].versionsQueue=[value]}, (r)=>assert.equal(r.status,0,r.stderr)) await add("npm delayed latest converges", async s=>{s.npm[records[0][0]].latestQueue=["beta","beta",records[0][2]]}, r=>assert.equal(r.status,0,r.stderr)) - for (const [name,spec] of [["null",null],["empty",{raw:""}],["truncated",{raw:"[\"1.0"}],["object",{}],["mixed",[records[0][2],3]],["DNS",{exit:1,stderr:"ENOTFOUND"}],["auth",{exit:1,stderr:"E401"}],["rate",{exit:1,stderr:"E429"}],["5xx",{exit:1,stderr:"E503"}]]) await add(`npm ${name} is unknown and never publishes`, async s=>{s.npm[records[0][0]].versionsQueue=Array(6).fill(spec)}, (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}) + await add("post-publish delayed version visibility converges", async s=>{s.npm[records[0][0]].delayedVersions=2}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s)}, "absent") + await add("post-publish delayed latest converges", async s=>{s.npm[records[0][0]].delayedLatest=2}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s)}, "absent") + await add("failed atomic push materializes no refs and replay reuses local tags", async s=>{s.pushExit=1}, async(r,s,w)=>{assert.notEqual(r.status,0);assert.equal(Object.keys(s.tags).length,0);assert.equal(Object.keys(s.localTags).length,7);delete s.pushExit;save(w.stateFile,s);const replay=await run(w);assert.equal(replay.status,0,replay.stderr);exactState(load(w.stateFile))}, "absent") + for (const [name,spec] of [["null",null],["empty",{raw:""}],["truncated",{raw:"[\"1.0"}],["object",{}],["mixed",[records[0][2],3]],["DNS",{exit:1,stderr:"ENOTFOUND"}],["auth",{exit:1,stderr:"E401"}],["rate",{exit:1,stderr:"E429"}],["5xx",{exit:1,stderr:"E503"}],["E404",{exit:1,stderr:"E404"}]]) await add(`npm ${name} is unknown and never publishes`, async s=>{s.npm[records[0][0]].versionsQueue=Array(6).fill(spec)}, (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}) for (const status of [401,403,429,500,503]) await add(`GitHub ${status} read is unknown`, async s=>{s.ghReadStatus=status}, (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}) await add("GitHub 404 is proven absent", async s=>{delete s.releases[`${records[0][0]}@${records[0][2]}`]}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s)}) await add("GitHub 422 create reconciles materialized exact release", async s=>{s.ghCreateStatus=422}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s)}, "absent") await add("GitHub 422 without exact state fails", async s=>{s.ghCreateStatus=422;s.ghCreateMaterializes=false}, (r)=>assert.notEqual(r.status,0), "absent") - for (const [name,raw] of [["lightweight",`${"a".repeat(40)}\trefs/tags/$TAG\n`],["malformed","garbage\n"],["wrong SHA",`${"a".repeat(40)}\trefs/tags/$TAG\n${"f".repeat(40)}\trefs/tags/$TAG^{}\n`],["duplicate",`${"a".repeat(40)}\trefs/tags/$TAG\n${"b".repeat(40)}\trefs/tags/$TAG\n${sha}\trefs/tags/$TAG^{}\n`]]) await add(`tag ${name} fails closed`, async s=>{s.tags[`${records[0][0]}@${records[0][2]}`]={raw}}, (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}) + for (const [name,raw] of [["lightweight",`${"a".repeat(40)}\trefs/tags/$TAG\n`],["malformed","garbage\n"],["wrong SHA",`${"a".repeat(40)}\trefs/tags/$TAG\n${"f".repeat(40)}\trefs/tags/$TAG^{}\n`],["duplicate",`${"a".repeat(40)}\trefs/tags/$TAG\n${"b".repeat(40)}\trefs/tags/$TAG\n${sha}\trefs/tags/$TAG^{}\n`]]) await add(`tag ${name} fails closed`, async s=>{s.tags[`${records[0][0]}@${records[0][2]}`]={raw}}, (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}, "absent") + for (const [name,value] of [["lightweight",{type:"commit",peeled:sha}],["wrong SHA",{type:"tag",peeled:"f".repeat(40)}]]) await add(`local tag ${name} fails before mutation`, async s=>{s.localTags[`${records[0][0]}@${records[0][2]}`]=value}, (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}, "absent") await add("manifest name mismatch fails before mutation", async(s,w)=>writeFileSync(join(w.cwd,records[0][1]),JSON.stringify({name:"wrong",version:records[0][2]})), (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)}) 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"]) - assert.equal(scenarioNames.length, 56) + assert.equal(new Set(scenarioNames).size, scenarioNames.length) }) test("static command boundary keeps shell and destructive repairs out", () => { diff --git a/scripts/release-policy-contract.test.mjs b/scripts/release-policy-contract.test.mjs index 2f08e600..e510803b 100644 --- a/scripts/release-policy-contract.test.mjs +++ b/scripts/release-policy-contract.test.mjs @@ -19,7 +19,8 @@ const workflows = { } const readme = read("README.md") const setup = read(".github/SETUP.md") -const stableFinalizeScript = `${read("scripts/release-finalize-stable.sh")}\n${read("scripts/release-finalize-stable.mjs")}` +const stableFinalizeWrapper = read("scripts/release-finalize-stable.sh") +const stableFinalizeScript = read("scripts/release-finalize-stable.mjs") const releaseProjects = [ "@effectify/react-router", @@ -226,14 +227,6 @@ const testCommand = /^pnpm nx run-many -t test "--projects=\$PROJECTS" --paralle const contractCommand = /^node --test scripts\/release-policy-contract\.test\.mjs$/ const releaseSubjectGuard = '[[ "$HEAD_SUBJECT" == *"chore(release):"* || "$HEAD_SUBJECT" == *"[skip release]"* ]] || [ "$BETA_TRANSITIONS" -gt 0 ]' -const rr8Commands = [ - /^pnpm nx test @effectify\/react-router$/, - /^pnpm nx run @effectify\/react-router-example:migration:test$/, - /^pnpm nx run @effectify\/react-router-example:migration:verify$/, - /^pnpm nx run @effectify\/react-router-example:migration:manifest$/, - /^pnpm nx run @effectify\/react-router-example:consolidation:verify$/, -] - const channelViolations = (channel, source) => { const violations = [] const active = withoutComments(source) @@ -522,13 +515,15 @@ const stableViolations = (source, finalizeScript = stableFinalizeScript) => { const violations = [] const active = withoutComments(source) const activeFinalize = withoutComments(finalizeScript) - if (activeFinalize.includes('import { readFile } from "node:fs/promises"')) { + { const required = [ - ["wrapper exec", /exec node .*release-finalize-stable\.mjs/, activeFinalize], + ["wrapper exec", /exec node .*release-finalize-stable\.mjs/, withoutComments(stableFinalizeWrapper)], ["strict SHA", /\^\[0-9a-f\]\{40\}\$/, activeFinalize], ["fresh master", /master:refs\/remotes\/origin\/master/, 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], ["HTTP 404 absence", /result\.status === 404/, activeFinalize], @@ -562,198 +557,6 @@ const stableViolations = (source, finalizeScript = stableFinalizeScript) => { if (order.some((position) => position < 0) || order.some((position, index) => index > 0 && position <= order[index - 1])) violations.push("stable ordering") return violations } - if (/\bjq\b/.test(`${active}\n${activeFinalize}`)) violations.push("stable jq dependency") - const required = [ - ["dispatch", /^\s*workflow_dispatch:/m], - ["duplicates", /sort \| uniq -d/], - ["matrix", /stable requires exact seven-project matrix/], - ["prepare SHA", /test -z "\$EXPECTED_SHA"/], - ["full SHA", /\[\[ "\$EXPECTED_SHA" =~ \^\[0-9a-f\]\{40\}\$ \]\]/], - ["fresh master", /git fetch origin master:refs\/remotes\/origin\/master --no-tags/], - ["master equality", /test "\$HEAD_SHA" = "\$REMOTE_SHA"/], - ["SHA equality", /test "\$HEAD_SHA" = "\$EXPECTED_SHA"/], - ["policy", contractCommand], - ["build", buildCommand], - ["test", testCommand], - ...rr8Commands.map((pattern, index) => [`readiness ${index}`, pattern]), - ["clean input", /test -z "\$\(git status --porcelain\)"/], - ["ref snapshot", /REFS_BEFORE=\$\(git for-each-ref/], - [ - "Nx flags", - /^pnpm nx release version "\$NEW" "--projects=\$NAME" --git-commit=false --git-tag=false --git-push=false --stage-changes=false$/m, - ], - ["refs unchanged", /test "\$REFS_BEFORE" = "\$\(git for-each-ref/], - ["no Nx staging", /test -z "\$\(git diff --cached --name-only\)"/], - ["all paths", /git diff --name-only --no-renames HEAD; git ls-files --others --exclude-standard/], - ["path equality", /cmp -s "\$EXPECTED_PATHS" "\$ACTUAL"/], - ["pathspec", /git add --pathspec-from-file="\$EXPECTED_PATHS"/], - ["index equality", /cmp -s "\$EXPECTED_PATHS" \/tmp\/stable-staged/], - ["commit", /git commit -m "chore\(release\): prepare stable from \$SOURCE_SHA \[skip release\]"/], - ["clean output", /::error::post-commit tree dirty/], - ["branch refspec", /git push origin "HEAD:refs\/heads\/release\/stable-\$SHA_PREFIX"/], - ["Node manifest validation", /node -e/], - ["Node JSON type validation", /JSON\.parse\(/], - ["manifest object type", /!value\|\|typeof value!=="object"\|\|Array\.isArray\(value\)/], - ["manifest name type", /typeof value\.name!=="string"/], - ["manifest version type", /typeof value\.version!=="string"/], - ["manifest exact identity", /value\.name!==name\|\|value\.version!==version/], - ["npm histories", /npm view "\$(?:NAME|name)" versions --json/], - ["npm versions type", /typeof (?:value|vs)==="string"\|\|Array\.isArray\((?:value|vs)\)&&(?:value|vs)\.every\((?:item|x)=>typeof (?:item|x)==="string"\)/], - ["npm latest", /npm view "\$(?:NAME|name)" dist-tags\.latest --json/], - ["npm latest type", /typeof (?:value|latest)!=="string"/], - ["latest conflict", /permanent latest divergence/], - ["tag refs", /git ls-remote --tags origin "refs\/tags\/\$(?:TAG|tag)" "refs\/tags\/\$(?:TAG|tag)\^\{\}"/], - ["direct unique", /awk -v r="refs\/tags\/\$(?:TAG|tag)"[^\n]*n\+\+/], - ["peeled unique", /awk -v r="refs\/tags\/\$(?:TAG|tag)\^\{\}"[^\n]*n\+\+/], - ["tag target", /awk -v r="refs\/tags\/\$(?:TAG|tag)\^\{\}"[^\n]*print \$1/], - ["release read", /gh release view "\$(?:TAG|tag)" --json tagName,isDraft,isPrerelease/], - ["annotated tag", /git tag -a "\$TAG" "\$EXPECTED_SHA" -m "\$TAG"/], - ["tag refspec", /TAG_REFS\+=\("refs\/tags\/\$TAG:refs\/tags\/\$TAG"\)/], - ["atomic push", /git push --atomic origin "\$\{TAG_REFS\[@\]\}"/], - ["release create", /gh release create "\$TAG" --verify-tag --generate-notes/], - ["missing subset", /pnpm nx release publish "--projects=\$MISSING_PROJECTS"/], - ["six reads", /MAX_NPM_READS=6[\s\S]*for ATTEMPT in \$\(seq 1 "\$MAX_NPM_READS"\)/], - ["delay", /NPM_READ_DELAY=\$\{NPM_READ_DELAY:-10\}[\s\S]*sleep "\$NPM_READ_DELAY"/], - ["exhaustion", /npm did not converge/], - ] - const steps = extractSteps(source) - const prepare = steps.find((step) => step.name.includes("PREPARE protected stable")) - const finalize = steps.find((step) => step.name.includes("FINALIZE exact stable artifacts")) - const prepareBody = prepare ? `- name: PREPARE\n${prepare.source}` : "" - const finalizeBody = finalize - ? finalize.commands.includes("bash scripts/release-finalize-stable.sh") - ? `- name: FINALIZE\n run: |\n${activeFinalize.split("\n").map((line) => ` ${line}`).join("\n")}` - : `- name: FINALIZE\n${finalize.source}` - : "" - const prepareContracts = new Set([ - "clean input", - "ref snapshot", - "Nx flags", - "refs unchanged", - "no Nx staging", - "all paths", - "path equality", - "pathspec", - "index equality", - "commit", - "clean output", - "branch refspec", - ]) - const finalizeContracts = new Set([ - "npm histories", - "npm versions type", - "npm latest", - "npm latest type", - "latest conflict", - "tag refs", - "direct unique", - "peeled unique", - "tag target", - "release read", - "annotated tag", - "tag refspec", - "atomic push", - "release create", - "missing subset", - "six reads", - "delay", - "exhaustion", - ]) - for (const [name, pattern] of required) { - const body = prepareContracts.has(name) ? prepareBody : finalizeContracts.has(name) ? finalizeBody : active - const commands = commandEntries(body).map(({ command }) => command) - pattern.lastIndex = 0 - const inSource = pattern.test(body) - const inCommands = commands.some((command) => { - pattern.lastIndex = 0 - return pattern.test(command) - }) - if (!inSource && !inCommands) violations.push(`stable ${name}`) - } - const sharedPhaseContracts = required.filter(([name]) => - [ - "Node manifest validation", - "Node JSON type validation", - "manifest object type", - "manifest name type", - "manifest version type", - "manifest exact identity", - ].includes(name), - ) - const manifestCommand = /node -e '[^\n]*fs\.readFileSync\(path,"utf8"\)[^\n]*' "\$MANIFEST_PATH" "\$NAME"/ - const releaseValidationCommands = commandEntries(finalizeBody) - .map(({ command }) => command) - .filter((command) => /printf '%s' "\$(?:RELEASE|value)" \| node -e /.test(command)) - if ( - releaseValidationCommands.length !== 1 || - !/JSON\.parse\(fs\.readFileSync\(0,"utf8"\)\)/.test(releaseValidationCommands[0]) || - !/\.tagName!==tag/.test(releaseValidationCommands[0]) || - !/\.isDraft!==false/.test(releaseValidationCommands[0]) || - !/\.isPrerelease!==false/.test(releaseValidationCommands[0]) - ) violations.push("stable FINALIZE exact Release validation command") - for (const [phase, body] of [ - ["PREPARE", prepareBody], - ["FINALIZE", finalizeBody], - ]) { - for (const [name, pattern] of sharedPhaseContracts) { - pattern.lastIndex = 0 - const compatibleBody = phase === "FINALIZE" - ? body.replaceAll("let v;", "let value;").replaceAll("v=JSON.parse", "value=JSON.parse").replaceAll("!v||", "!value||").replaceAll("typeof v", "typeof value").replaceAll("(v)", "(value)").replaceAll("Array.isArray(v)", "Array.isArray(value)").replaceAll("v.name", "value.name").replaceAll("v.version", "value.version") - : body - if (!pattern.test(compatibleBody)) violations.push(`stable ${phase} ${name}`) - } - if (/\bread\s+-r\s+[^\n;]*\bPATH\b/.test(body)) violations.push(`stable ${phase} reserved PATH shadowing`) - const hasManifestCommand = phase === "PREPARE" - ? commandEntries(body).map(({ command }) => command).some((command) => manifestCommand.test(command)) - : /node -e '[^\n]*fs\.readFileSync\(path,"utf8"\)[^\n]*' "\$1" "\$2" "\$3"/.test(body) && /manifest_ok "\$MANIFEST_PATH" "\$NAME" "\$VERSION"/.test(body) - if (!hasManifestCommand) violations.push(`stable ${phase} MANIFEST_PATH manifest command`) - if (!/process\.exit\(2\)/.test(body) || !/manifest execution or parse failed/.test(body)) { - violations.push(`stable ${phase} manifest execution diagnostic`) - } - if (!/manifest identity mismatch/.test(body)) violations.push(`stable ${phase} manifest identity diagnostic`) - if (!/actual=\$\{JSON\.stringify\((?:actual|\{name:typeof v\?\.name==="string"\?v\.name:null,version:typeof v\?\.version==="string"\?v\.version:null\})\)\}/.test(body)) { - violations.push(`stable ${phase} manifest actual identity detail`) - } - if (!/expected=\$\{JSON\.stringify\(\{name,version\}\)\}/.test(body)) { - violations.push(`stable ${phase} manifest expected identity detail`) - } - } - if (prepare) { - const versionCommands = prepare.commands.filter((command) => /pnpm nx release version/.test(command)) - if (versionCommands.length !== 1 || /\bpatch\b|--projects=\$PROJECTS/.test(versionCommands[0])) violations.push("stable exact per-record version action") - if (/writeFileSync|fs\.writeFile|jq[^\n]*\.version|sed -i|npm pkg set/.test(prepare.source)) violations.push("stable direct manifest edit") - const records = [...prepare.source.matchAll(/'(@effectify\/[^|']+\|[^|']+\|[^|']+\|[^']+)'/g)].map(([, record]) => record) - const expectedRecords = [ - "@effectify/hatchet|packages/hatchet/package.json|0.1.0-beta.0|0.1.0", - "@effectify/node-better-auth|packages/node/better-auth/package.json|0.5.12-beta.0|0.5.12", - "@effectify/prisma|packages/prisma/package.json|1.1.13-beta.0|1.1.13", - "@effectify/react-query|packages/react/query/package.json|1.0.0-beta.1|1.0.0", - "@effectify/react-router|packages/react/router/package.json|0.6.0-beta.0|0.6.0", - "@effectify/react-router-better-auth|packages/react/router-better-auth/package.json|0.5.12-beta.0|0.5.12", - "@effectify/solid-query|packages/solid/query/package.json|0.5.13-beta.0|0.5.13", - ] - if (JSON.stringify(records) !== JSON.stringify(expectedRecords)) violations.push("stable exact PREPARE records") - } - if (!prepare || !/mode == 'prepare'/.test(prepare.condition)) violations.push("stable PREPARE isolation") - if ( - prepare && - /NODE_AUTH_TOKEN|npm publish|gh issue|gh pr|gh release|workflow run|refs\/heads\/master/.test(prepare.source) - ) - violations.push("stable PREPARE side effects") - if (/release publish[^\n]*--tag=/.test(`${active}\n${activeFinalize}`)) violations.push("stable channel") - if (/npm dist-tag|npm unpublish|gh release delete|git tag -f/.test(`${active}\n${activeFinalize}`)) { - violations.push("stable destructive repair") - } - const order = [ - /npm view "\$(?:NAME|name)" versions/, - /git ls-remote --tags/, - /gh release view/, - /git push --atomic/, - /gh release create/, - /nx release publish/, - ].map((p) => finalizeBody.search(p)) - if (order.some((p) => p < 0) || order.some((p, i) => i && p <= order[i - 1])) violations.push("stable ordering") - return violations } const releasePolicyBootstrapViolations = (source) => {