From b7c7355735baf9b22ec98297dd0fb836a60c7079 Mon Sep 17 00:00:00 2001 From: kattsushi Date: Sat, 29 Aug 2026 17:59:22 -0600 Subject: [PATCH 1/2] fix(release): harden stable FINALIZE reconciliation --- .github/workflows/ci.yml | 3 + .github/workflows/release-stable.yml | 25 +--- scripts/release-finalize-stable.sh | 87 +++++++++++++ scripts/release-finalize-stable.test.mjs | 87 +++++++++++++ scripts/release-policy-contract.test.mjs | 158 ++++++++++------------- 5 files changed, 248 insertions(+), 112 deletions(-) create mode 100755 scripts/release-finalize-stable.sh create mode 100644 scripts/release-finalize-stable.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0c44f85..c948a066 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,9 @@ jobs: - name: ๐Ÿ›ก๏ธ Verify release policy contract run: node --test scripts/release-policy-contract.test.mjs + - name: ๐Ÿงช Verify stable FINALIZE harness + run: node --test scripts/release-finalize-stable.test.mjs + # Lint and format check lint: name: ๐Ÿ” Lint & Format diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml index 3ad3e36a..460970bf 100644 --- a/.github/workflows/release-stable.yml +++ b/.github/workflows/release-stable.yml @@ -143,30 +143,7 @@ jobs: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_CONFIG_PROVENANCE: true - run: | - set -euo pipefail - git fetch origin master:refs/remotes/origin/master --no-tags - test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"; test "$(git rev-parse origin/master)" = "$EXPECTED_SHA" - RECORDS=$(mktemp); 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" - : > /tmp/missing-projects; : > /tmp/missing-tags; : > /tmp/missing-releases - while IFS='|' read -r NAME MANIFEST_PATH VERSION; do - if DETAIL=$(node -e 'const fs=require("node:fs");const [path,name,version]=process.argv.slice(1);let value;try{value=JSON.parse(fs.readFileSync(path,"utf8"))}catch{process.exit(2)}if(!value||typeof value!=="object"||Array.isArray(value)||typeof value.name!=="string"||typeof value.version!=="string"||value.name!==name||value.version!==version){const actual={name:typeof value?.name==="string"?value.name:null,version:typeof value?.version==="string"?value.version:null};process.stdout.write(`actual=${JSON.stringify(actual)} expected=${JSON.stringify({name,version})}`);process.exit(1)}' "$MANIFEST_PATH" "$NAME" "$VERSION"); then :; else STATUS=$?; if [ "$STATUS" = 1 ]; then echo "::error::merged manifest identity mismatch for $NAME: $DETAIL"; else echo "::error::merged manifest execution or parse failed for $NAME"; fi; exit 1; fi - TAG="$NAME@$VERSION"; VERSIONS=$(npm view "$NAME" versions --json); printf '%s' "$VERSIONS" | node -e 'const fs=require("node:fs");const value=JSON.parse(fs.readFileSync(0,"utf8"));if(!(typeof value==="string"||Array.isArray(value)&&value.every(item=>typeof item==="string")))process.exit(1)' - LATEST_JSON=$(npm view "$NAME" dist-tags.latest --json); LATEST=$(printf '%s' "$LATEST_JSON" | node -e 'const fs=require("node:fs");const value=JSON.parse(fs.readFileSync(0,"utf8"));if(typeof value!=="string")process.exit(1);process.stdout.write(value)') - if printf '%s' "$VERSIONS" | node -e 'const fs=require("node:fs");const version=process.argv[1];const value=JSON.parse(fs.readFileSync(0,"utf8"));if(!(typeof value==="string"||Array.isArray(value)&&value.every(item=>typeof item==="string")))process.exit(1);process.exit((Array.isArray(value)?value.includes(version):value===version)?0:1)' "$VERSION"; then test "$LATEST" = "$VERSION" || { echo '::error::existing stable has divergent latest'; exit 1; }; else printf '%s\n' "${NAME#@effectify/}" >> /tmp/missing-projects; fi - REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}") - if [ -z "$REMOTE" ]; then printf '%s\n' "$TAG" >> /tmp/missing-tags; else test "$(printf '%s\n' "$REMOTE" | grep -c $'\trefs/tags/'"$TAG"'$')" = 1; test "$(printf '%s\n' "$REMOTE" | grep -c $'\trefs/tags/'"$TAG"'\^{}$')" = 1; test "$(printf '%s\n' "$REMOTE" | awk -v r="refs/tags/$TAG^{}" '$2==r{print $1}')" = "$EXPECTED_SHA"; fi - set +e; RELEASE=$(gh release view "$TAG" --json tagName,isDraft,isPrerelease 2>/tmp/stable-gh-error); STATUS=$?; set -e - if [ "$STATUS" = 0 ]; then printf '%s' "$RELEASE" | node -e 'const fs=require("node:fs");const tag=process.argv[1];const value=JSON.parse(fs.readFileSync(0,"utf8"));if(!value||typeof value!=="object"||Array.isArray(value)||typeof value.tagName!=="string"||typeof value.isDraft!=="boolean"||typeof value.isPrerelease!=="boolean"||value.tagName!==tag||value.isDraft||value.isPrerelease)process.exit(1)' "$TAG"; elif [ "$STATUS" = 1 ] && grep -Fqi 'release not found' /tmp/stable-gh-error; then printf '%s\n' "$TAG" >> /tmp/missing-releases; else echo '::error::unknown GitHub Release state'; exit 1; fi - done < "$RECORDS" - TAG_REFS=(); while IFS= read -r TAG; do [ -n "$TAG" ] || continue; ! git show-ref --verify --quiet "refs/tags/$TAG"; git tag -a "$TAG" "$EXPECTED_SHA" -m "$TAG"; TAG_REFS+=("refs/tags/$TAG:refs/tags/$TAG"); done < /tmp/missing-tags - if [ ${#TAG_REFS[@]} -gt 0 ]; then git push --atomic origin "${TAG_REFS[@]}"; fi - while IFS= read -r TAG; do [ -n "$TAG" ] && gh release create "$TAG" --verify-tag --generate-notes; done < /tmp/missing-releases - MISSING=$(paste -sd, /tmp/missing-projects); if [ -n "$MISSING" ]; then PROJECTS="$MISSING"; pnpm nx release publish "--projects=$PROJECTS"; fi - MAX_NPM_READS=6; for ATTEMPT in $(seq 1 "$MAX_NPM_READS"); do - REMAINING=0; while IFS='|' read -r NAME MANIFEST_PATH VERSION; do V=$(npm view "$NAME" versions --json) || { REMAINING=$((REMAINING+1)); continue; }; L_JSON=$(npm view "$NAME" dist-tags.latest --json) || { REMAINING=$((REMAINING+1)); continue; }; L=$(printf '%s' "$L_JSON" | node -e 'const fs=require("node:fs");const value=JSON.parse(fs.readFileSync(0,"utf8"));if(typeof value!=="string")process.exit(1);process.stdout.write(value)') || { REMAINING=$((REMAINING+1)); continue; }; printf '%s' "$V" | node -e 'const fs=require("node:fs");const version=process.argv[1];const value=JSON.parse(fs.readFileSync(0,"utf8"));if(!(typeof value==="string"||Array.isArray(value)&&value.every(item=>typeof item==="string")))process.exit(1);process.exit((Array.isArray(value)?value.includes(version):value===version)?0:1)' "$VERSION" && [ "$L" = "$VERSION" ] || REMAINING=$((REMAINING+1)); done < "$RECORDS" - [ "$REMAINING" = 0 ] && break; [ "$ATTEMPT" = "$MAX_NPM_READS" ] && { echo "::error::npm did not converge: $REMAINING"; exit 1; }; sleep 10 - done + run: bash scripts/release-finalize-stable.sh - name: ๐Ÿ“Š Stable summary if: always() env: diff --git a/scripts/release-finalize-stable.sh b/scripts/release-finalize-stable.sh new file mode 100755 index 00000000..2b03b9f2 --- /dev/null +++ b/scripts/release-finalize-stable.sh @@ -0,0 +1,87 @@ +#!/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" -ne 2 ] && return "$status" + [ "$attempt" = "$MAX_NPM_READS" ] || sleep "$NPM_READ_DELAY" + done + return 2 +} +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 "existing stable has divergent latest 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 + while IFS='|' read -r NAME _ VERSION; do set +e; npm_state "$NAME" "$VERSION"; STATUS=$?; set -e; [ "$STATUS" = 0 ] || { [ "$STATUS" = 3 ] && fail "permanent latest divergence for $NAME"; REMAINING=$((REMAINING+1)); }; done < "$RECORDS" + [ "$REMAINING" = 0 ] && exit 0 + [ "$ATTEMPT" = "$MAX_NPM_READS" ] && fail "npm did not converge: $REMAINING" + sleep "$NPM_READ_DELAY" +done diff --git a/scripts/release-finalize-stable.test.mjs b/scripts/release-finalize-stable.test.mjs new file mode 100644 index 00000000..538b44ca --- /dev/null +++ b/scripts/release-finalize-stable.test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict" +import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { spawn } from "node:child_process" +import nodeTest from "node:test" + +const finalize = new URL("release-finalize-stable.sh", import.meta.url).pathname +const sha = "1234567890abcdef1234567890abcdef12345678" +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 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}} +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)} + console.log(JSON.stringify(a[2]==='versions'?v.versions:v.latest));save() +}else if(cmd==='pnpm'){ + let names=a[3].replace('--projects=','').split(',');for(const n of names){let rec=s.expected[n];s.npm[n]={...(s.npm[n]||{}),versions:[rec],latest:rec}}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} +} +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])) + +const options={timeout:60_000} +const scenarios=[] +const test=(name,_options,fn)=>scenarios.push({name,fn}) + +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")}) + +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(',')}`) +}) +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)}) +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))) +}) diff --git a/scripts/release-policy-contract.test.mjs b/scripts/release-policy-contract.test.mjs index a0b35ab5..142a74d2 100644 --- a/scripts/release-policy-contract.test.mjs +++ b/scripts/release-policy-contract.test.mjs @@ -19,6 +19,7 @@ const workflows = { } const readme = read("README.md") const setup = read(".github/SETUP.md") +const stableFinalizeScript = read("scripts/release-finalize-stable.sh") const releaseProjects = [ "@effectify/react-router", @@ -525,10 +526,11 @@ const isStableReleaseValidationCommand = (command) => /typeof value\.isPrerelease!=="boolean"/.test(command) && /value\.tagName!==tag\|\|value\.isDraft\|\|value\.isPrerelease/.test(command) -const stableViolations = (source) => { +const stableViolations = (source, finalizeScript = stableFinalizeScript) => { const violations = [] const active = withoutComments(source) - if (/\bjq\b/.test(active)) violations.push("stable jq dependency") + const activeFinalize = withoutComments(finalizeScript) + if (/\bjq\b/.test(`${active}\n${activeFinalize}`)) violations.push("stable jq dependency") const required = [ ["dispatch", /^\s*workflow_dispatch:/m], ["duplicates", /sort \| uniq -d/], @@ -563,30 +565,34 @@ const stableViolations = (source) => { ["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" versions --json/], - ["npm versions type", /typeof value==="string"\|\|Array\.isArray\(value\)&&value\.every\(item=>typeof item==="string"\)/], - ["npm latest", /npm view "\$NAME" dist-tags\.latest --json/], - ["npm latest type", /typeof value!=="string"/], + ["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", /existing stable has divergent latest/], - ["tag refs", /git ls-remote --tags origin "refs\/tags\/\$TAG" "refs\/tags\/\$TAG\^\{\}"/], - ["direct unique", /grep -c \$'\\trefs\/tags\/'"\$TAG"'\$'/], - ["peeled unique", /grep -c \$'\\trefs\/tags\/'"\$TAG"'\\\^\{\}\$'/], - ["tag target", /awk -v r="refs\/tags\/\$TAG\^\{\}"[^\n]*"\$EXPECTED_SHA"/], - ["release read", /gh release view "\$TAG" --json tagName,isDraft,isPrerelease/], + ["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", /PROJECTS="\$MISSING"; pnpm nx release publish "--projects=\$PROJECTS"/], - ["six reads", /MAX_NPM_READS=6; for ATTEMPT in \$\(seq 1 "\$MAX_NPM_READS"\)/], - ["delay", /sleep 10/], + ["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 ? `- name: FINALIZE\n${finalize.source}` : "" + const finalizeBody = finalize + ? finalize.commands.includes("bash scripts/release-finalize-stable.sh") + ? `- name: FINALIZE\n run: |\n${finalizeScript.split("\n").map((line) => ` ${line}`).join("\n")}` + : `- name: FINALIZE\n${finalize.source}` + : "" const prepareContracts = new Set([ "clean input", "ref snapshot", @@ -645,30 +651,35 @@ const stableViolations = (source) => { 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" \| node -e /.test(command)) - if (releaseValidationCommands.length !== 1 || !isStableReleaseValidationCommand(releaseValidationCommands[0])) { - violations.push("stable FINALIZE exact Release validation 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 - if (!pattern.test(body)) violations.push(`stable ${phase} ${name}`) + 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 manifestCommands = commandEntries(body) - .map(({ command }) => command) - .filter((command) => /fs\.readFileSync\(path,"utf8"\)/.test(command)) - if (manifestCommands.length === 0 || manifestCommands.some((command) => !manifestCommand.test(command))) { - violations.push(`stable ${phase} MANIFEST_PATH manifest command`) - } + 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\)\}/.test(body)) { + 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)) { @@ -697,12 +708,12 @@ const stableViolations = (source) => { /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)) violations.push("stable channel") - if (/npm dist-tag|npm unpublish|gh release delete|git tag -f/.test(active)) { + 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" versions/, + /npm view "\$(?:NAME|name)" versions/, /git ls-remote --tags/, /gh release view/, /git push --atomic/, @@ -723,8 +734,8 @@ const releasePolicyBootstrapViolations = (source) => { return pnpmIndex !== -1 && pnpmIndex < setupNodeIndex ? [] : cacheDisabled ? [] : ["release-policy setup-node cache"] } -const policyViolations = ({ alpha, beta, stable, docs }) => { - const violations = [...channelViolations("alpha", alpha), ...betaViolations(beta), ...stableViolations(stable)] +const policyViolations = ({ alpha, beta, stable, stableFinalize = stableFinalizeScript, docs }) => { + const violations = [...channelViolations("alpha", alpha), ...betaViolations(beta), ...stableViolations(stable, stableFinalize)] if (!/\|\s*Beta\s*\|[^\n]*`master`[^\n]*`beta`/.test(withoutComments(docs))) { violations.push("documented mapping") } @@ -748,6 +759,12 @@ 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), []) }) @@ -955,38 +972,22 @@ test("protected stable PREPARE and FINALIZE reject independent safety mutations" const policy = { ...workflows, docs: readme } assert.deepEqual(stableViolations(policy.stable), []) - for (const [phase, stepName] of [ - ["PREPARE", "PREPARE protected stable"], - ["FINALIZE", "FINALIZE exact stable artifacts"], - ]) { - const changed = mutateStep(policy.stable, stepName, /JSON\.parse/g, "JSON.parseSafe") - assert.ok(stableViolations(changed).includes(`stable ${phase} Node JSON type validation`)) - const withoutActual = mutateStep(policy.stable, stepName, /actual=\$\{JSON\.stringify\(actual\)\} /g, "") - assert.ok(stableViolations(withoutActual).includes(`stable ${phase} manifest actual identity detail`)) - const withoutExpected = mutateStep( - policy.stable, - stepName, - /expected=\$\{JSON\.stringify\(\{name,version\}\)\}/g, - "", - ) - assert.ok(stableViolations(withoutExpected).includes(`stable ${phase} manifest expected identity detail`)) - } - for (const [phase, stepName] of [ - ["PREPARE", "PREPARE protected stable"], - ["FINALIZE", "FINALIZE exact stable artifacts"], + 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") + assert.ok(stableViolations(prepareShadow).includes("stable PREPARE reserved PATH shadowing")) + 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")) + + 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 shadowed = mutateStep(policy.stable, stepName, /read -r NAME MANIFEST_PATH/, "read -r NAME PATH") - assert.ok(stableViolations(shadowed).includes(`stable ${phase} reserved PATH shadowing`)) - const wrongArgument = mutateStep(policy.stable, stepName, /"\$MANIFEST_PATH" "\$NAME"/, '"$PATH" "$NAME"') - assert.ok(stableViolations(wrongArgument).includes(`stable ${phase} MANIFEST_PATH manifest command`)) + const changedScript = mutate(stableFinalizeScript, before, after) + assert.notDeepEqual(stableViolations(policy.stable, changedScript), [], `FINALIZE ${name}`) } - const literalRelease = mutateStep( - policy.stable, - "FINALIZE exact stable artifacts", - 'const value=JSON.parse(fs.readFileSync(0,"utf8"));if(!value||typeof value!=="object"||Array.isArray(value)||typeof value.tagName', - 'const value={tagName:tag,isDraft:false,isPrerelease:false};if(!value||typeof value!=="object"||Array.isArray(value)||typeof value.tagName', - ) - assert.ok(stableViolations(literalRelease).includes("stable FINALIZE exact Release validation command")) for (const [name, before, after] of [ ["allow abbreviated SHA", "^[0-9a-f]{40}$", "^[0-9a-f]{7,40}$"], @@ -1001,38 +1002,18 @@ test("protected stable PREPARE and FINALIZE reject independent safety mutations" ["stage broad tree", 'git add --pathspec-from-file="$EXPECTED_PATHS"', "git add -A"], ["push master", "HEAD:refs/heads/release/stable-$SHA_PREFIX", "HEAD:refs/heads/master"], ["restore jq", "node -e", "jq -e"], - ["weaken JSON parse", "JSON.parse", "JSON.parseSafe"], - ["accept scalar manifest", /!value\|\|typeof value!=="object"\|\|Array\.isArray\(value\)/g, "!value"], - ["accept non-string manifest name", /typeof value\.name!=="string"\|\|/g, ""], - ["accept non-string manifest version", /typeof value\.version!=="string"\|\|/g, ""], - ["accept inexact manifest identity", /value\.name!==name\|\|value\.version!==version/g, "false"], - [ - "accept non-string npm versions", - /typeof value==="string"\|\|Array\.isArray\(value\)&&value\.every\(item=>typeof item==="string"\)/g, - "Array.isArray(value)", - ], - ["accept non-string npm latest", /typeof value!=="string"/g, "value==null"], - ["accept non-string release tag", 'typeof value.tagName!=="string"||', ""], - ["accept non-boolean release draft", 'typeof value.isDraft!=="boolean"||', ""], - ["accept non-boolean release prerelease", 'typeof value.isPrerelease!=="boolean"||', ""], - ["accept draft release", "||value.isDraft||value.isPrerelease", "||value.isPrerelease"], - ["accept prerelease release", "||value.isDraft||value.isPrerelease", "||value.isDraft"], ["read latest as beta", "dist-tags.latest", "dist-tags.beta"], ["accept divergent latest", "existing stable has divergent latest", "existing stable accepted"], - ["omit peeled tag ref", ' "refs/tags/$TAG^{}"', ""], ["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=$PROJECTS"', 'release publish "--projects=$PROJECTS" --tag=beta'], + ["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", "sleep 10", "sleep 1"], + ["shorten propagation wait", "NPM_READ_DELAY=${NPM_READ_DELAY:-10}", "NPM_READ_DELAY=1"], ]) - assertMutationFails(name, policy, (candidate) => ({ - ...candidate, - stable: mutate(candidate.stable, before, after), - })) + assertMutationFails(name, policy, (candidate) => mutateStable(candidate, before, after)) for (const command of [ "npm dist-tag add @effectify/hatchet@0.1.0 latest", @@ -1115,7 +1096,7 @@ test("protected stable documentation rejects authorization and recovery drift", }) test("protected stable promotion exposes exact PREPARE and FINALIZE contracts", () => { - const active = withoutComments(workflows.stable) + const active = withoutComments(`${workflows.stable}\n${stableFinalizeScript}`) assert.match(active, /expected_sha:/) assert.match(active, /MODE=prepare/) assert.match(active, /MODE=finalize/) @@ -1126,10 +1107,11 @@ test("protected stable promotion exposes exact PREPARE and FINALIZE contracts", 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=\$PROJECTS"/) + 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, /sleep 10/) + assert.match(active, /NPM_READ_DELAY=\$\{NPM_READ_DELAY:-10\}/) + assert.match(active, /sleep "\$NPM_READ_DELAY"/) }) test("beta structurally suppresses only the exact stable matrix", () => { From 7b69295328870d429f60870f3339ab82f8e18f57 Mon Sep 17 00:00:00 2001 From: kattsushi Date: Sat, 29 Aug 2026 18:21:14 -0600 Subject: [PATCH 2/2] fix(release): retry stale stable latest state --- scripts/release-finalize-stable.sh | 21 ++++++++++++++++----- scripts/release-finalize-stable.test.mjs | 8 +++++--- scripts/release-policy-contract.test.mjs | 13 ++++++++++--- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/scripts/release-finalize-stable.sh b/scripts/release-finalize-stable.sh index 2b03b9f2..481fc03d 100755 --- a/scripts/release-finalize-stable.sh +++ b/scripts/release-finalize-stable.sh @@ -30,10 +30,11 @@ 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" -ne 2 ] && return "$status" + [ "$status" = 0 ] && return 0 + [ "$status" = 1 ] && return 1 [ "$attempt" = "$MAX_NPM_READS" ] || sleep "$NPM_READ_DELAY" done - return 2 + return "$status" } verify_tag() { local tag=$1 remote direct peeled sha @@ -64,7 +65,7 @@ git config user.email 'github-actions[bot]@users.noreply.github.com' 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 "existing stable has divergent latest for $NAME" ;; *) fail "npm state unreadable after $MAX_NPM_READS attempts for $NAME" ;; esac + 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" @@ -80,8 +81,18 @@ 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 - while IFS='|' read -r NAME _ VERSION; do set +e; npm_state "$NAME" "$VERSION"; STATUS=$?; set -e; [ "$STATUS" = 0 ] || { [ "$STATUS" = 3 ] && fail "permanent latest divergence for $NAME"; REMAINING=$((REMAINING+1)); }; done < "$RECORDS" + 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 - [ "$ATTEMPT" = "$MAX_NPM_READS" ] && fail "npm did not converge: $REMAINING" + 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 diff --git a/scripts/release-finalize-stable.test.mjs b/scripts/release-finalize-stable.test.mjs index 538b44ca..97b3e1f6 100644 --- a/scripts/release-finalize-stable.test.mjs +++ b/scripts/release-finalize-stable.test.mjs @@ -34,9 +34,9 @@ if(cmd==='git'){ 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)} - console.log(JSON.stringify(a[2]==='versions'?v.versions:v.latest));save() + 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];s.npm[n]={...(s.npm[n]||{}),versions:[rec],latest:rec}}save() + 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()` @@ -72,7 +72,7 @@ test("exact replay is mutation-free and preserves alpha/beta",options,async()=>{ 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 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'})}) @@ -80,6 +80,8 @@ for(const when of ['before','after']) for(let ordinal=1;ordinal<=16;ordinal++) { 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=>{ diff --git a/scripts/release-policy-contract.test.mjs b/scripts/release-policy-contract.test.mjs index 142a74d2..3047ea18 100644 --- a/scripts/release-policy-contract.test.mjs +++ b/scripts/release-policy-contract.test.mjs @@ -569,7 +569,7 @@ const stableViolations = (source, finalizeScript = stableFinalizeScript) => { ["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", /existing stable has divergent latest/], + ["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\+\+/], @@ -590,7 +590,7 @@ const stableViolations = (source, finalizeScript = stableFinalizeScript) => { 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${finalizeScript.split("\n").map((line) => ` ${line}`).join("\n")}` + ? `- name: FINALIZE\n run: |\n${activeFinalize.split("\n").map((line) => ` ${line}`).join("\n")}` : `- name: FINALIZE\n${finalize.source}` : "" const prepareContracts = new Set([ @@ -979,6 +979,13 @@ 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"`], @@ -1003,7 +1010,7 @@ test("protected stable PREPARE and FINALIZE reject independent safety mutations" ["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", "existing stable has divergent latest", "existing stable accepted"], + ["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"],