Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions .github/workflows/release-stable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,20 @@ on:
required: true
type: boolean
default: false
preflight_only:
description: "Read-only exact-state verification of stable artifacts"
required: true
type: boolean
default: false
expected_sha:
description: "Full lowercase merged master SHA; FINALIZE only"
description: "Full lowercase merged master SHA; PREFLIGHT or FINALIZE only"
required: false
type: string
artifact_sha:
description: "Full lowercase publication SHA; empty uses expected_sha, and a different SHA is historical verification-only"
required: false
type: string
default: ""
concurrency:
group: release-stable
cancel-in-progress: false
Expand Down Expand Up @@ -60,7 +70,9 @@ jobs:
env:
REQUESTED_PROJECTS: ${{ inputs.projects }}
PUBLISH_ONLY: ${{ inputs.publish_only }}
PREFLIGHT_ONLY: ${{ inputs.preflight_only }}
EXPECTED_SHA: ${{ inputs.expected_sha }}
ARTIFACT_SHA: ${{ inputs.artifact_sha }}
run: |
set -euo pipefail
RAW=$(printf '%s' "$REQUESTED_PROJECTS" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed '/^$/d')
Expand All @@ -69,11 +81,20 @@ jobs:
SELECTED=$(printf '%s\n' "$RAW" | sort)
EXPECTED=$(printf '%s\n' '@effectify/hatchet' '@effectify/node-better-auth' '@effectify/prisma' '@effectify/react-query' '@effectify/react-router' '@effectify/react-router-better-auth' '@effectify/solid-query' | sort)
cmp -s <(printf '%s\n' "$EXPECTED") <(printf '%s\n' "$SELECTED") || { echo '::error::stable requires exact seven-project matrix'; exit 1; }
if [ "$PUBLISH_ONLY" = true ]; then
if [ "$PREFLIGHT_ONLY" = true ] && [ "$PUBLISH_ONLY" = true ]; then
echo '::error::preflight_only and publish_only are mutually exclusive'
exit 1
elif [ "$PREFLIGHT_ONLY" = true ]; then
[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo '::error::PREFLIGHT requires full lowercase expected_sha'; exit 1; }
if [ -n "$ARTIFACT_SHA" ]; then [[ "$ARTIFACT_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo '::error::PREFLIGHT requires full lowercase artifact_sha'; exit 1; }; fi
MODE=preflight
elif [ "$PUBLISH_ONLY" = true ]; then
[[ "$EXPECTED_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo '::error::FINALIZE requires full lowercase expected_sha'; exit 1; }
if [ -n "$ARTIFACT_SHA" ]; then [[ "$ARTIFACT_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo '::error::FINALIZE requires full lowercase artifact_sha'; exit 1; }; fi
MODE=finalize
else
test -z "$EXPECTED_SHA" || { echo '::error::PREPARE rejects expected_sha'; exit 1; }
test -z "$ARTIFACT_SHA" || { echo '::error::PREPARE rejects artifact_sha'; exit 1; }
MODE=prepare
fi
echo "mode=$MODE" >> "$GITHUB_OUTPUT"
Expand All @@ -88,7 +109,9 @@ jobs:
HEAD_SHA=$(git rev-parse HEAD)
REMOTE_SHA=$(git rev-parse origin/master)
test "$HEAD_SHA" = "$REMOTE_SHA" || { echo '::error::checkout is not current origin/master'; exit 1; }
if [ "$MODE" = finalize ]; then test "$HEAD_SHA" = "$EXPECTED_SHA" || { echo '::error::FINALIZE SHA mismatch'; exit 1; }; fi
if [ "$MODE" = preflight ] || [ "$MODE" = finalize ]; then
test "$HEAD_SHA" = "$EXPECTED_SHA" || { echo '::error::PREFLIGHT/FINALIZE SHA mismatch'; exit 1; }
fi
- name: 🏗️ Build selected projects
env: { PROJECTS: "${{ steps.release.outputs.projects }}" }
run: pnpm nx run-many -t build "--projects=$PROJECTS" --parallel=3
Expand Down Expand Up @@ -131,6 +154,13 @@ jobs:
test -z "$(git status --porcelain)" || { echo '::error::post-commit tree dirty'; exit 1; }
git push origin "HEAD:refs/heads/release/stable-$SHA_PREFIX" || { echo '::error::stable branch push failed'; exit 1; }
echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT"; echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"; echo "changed_paths=$(paste -sd, "$EXPECTED_PATHS")" >> "$GITHUB_OUTPUT"
- name: 🔎 PREFLIGHT exact stable artifacts
if: ${{ steps.release.outputs.mode == 'preflight' }}
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
ARTIFACT_SHA: ${{ inputs.artifact_sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: bash scripts/release-finalize-stable.sh --preflight --json
- name: 🔐 Verify npm authentication for FINALIZE
if: ${{ steps.release.outputs.mode == 'finalize' }}
env: { NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}" }
Expand All @@ -140,6 +170,7 @@ jobs:
env:
PROJECTS: ${{ steps.release.outputs.projects }}
EXPECTED_SHA: ${{ inputs.expected_sha }}
ARTIFACT_SHA: ${{ inputs.artifact_sha }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_CONFIG_PROVENANCE: true
Expand All @@ -152,8 +183,9 @@ jobs:
SOURCE_SHA: ${{ steps.prepare.outputs.source_sha || '' }}
BRANCH: ${{ steps.prepare.outputs.branch || '' }}
EXPECTED_SHA: ${{ inputs.expected_sha || '' }}
ARTIFACT_SHA: ${{ inputs.artifact_sha || inputs.expected_sha || '' }}
run: |
echo '## Protected stable promotion' >> "$GITHUB_STEP_SUMMARY"
echo "**Mode:** $MODE" >> "$GITHUB_STEP_SUMMARY"; echo "**Projects:** $PROJECTS" >> "$GITHUB_STEP_SUMMARY"
echo "**Source:** $SOURCE_SHA **Branch:** $BRANCH **Expected SHA:** $EXPECTED_SHA" >> "$GITHUB_STEP_SUMMARY"
echo 'PREPARE requires a manually linked type:chore PR and protected review. FINALIZE reconciles tags → non-prerelease Releases → npm latest.' >> "$GITHUB_STEP_SUMMARY"
echo "**Source:** $SOURCE_SHA **Branch:** $BRANCH **Expected SHA:** $EXPECTED_SHA **Artifact SHA:** $ARTIFACT_SHA" >> "$GITHUB_STEP_SUMMARY"
echo 'PREFLIGHT is read-only exact-state verification; it does not tag, push, create Releases, or publish. PREPARE requires a manually linked type:chore PR and protected review. FINALIZE reconciles tags → non-prerelease Releases → npm latest.' >> "$GITHUB_STEP_SUMMARY"
15 changes: 11 additions & 4 deletions scripts/release-finalize-stable.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const records = [
["@effectify/solid-query", "packages/solid/query/package.json", "0.5.13"],
]
const expectedSha = process.env.EXPECTED_SHA ?? ""
const artifactSha = process.env.ARTIFACT_SHA || expectedSha
const historicalReplay = artifactSha !== expectedSha
const maxReads = 6
const delayMs = Number(process.env.NPM_READ_DELAY_MS ?? (Number(process.env.NPM_READ_DELAY ?? 10) * 1000))
const commandTimeoutMs = Number(process.env.FINALIZE_COMMAND_TIMEOUT_MS ?? 60_000)
Expand Down Expand Up @@ -81,7 +83,7 @@ function parseTag(text, tag) {
if (!match) return { kind: "unknown" }
if (match[2] === directRef) direct.push(match[1]); else if (match[2] === peeledRef) peeled.push(match[1]); else return { kind: "unknown" }
}
return direct.length === 1 && peeled.length === 1 && peeled[0] === expectedSha ? { kind: "exact" } : { kind: "divergent" }
return direct.length === 1 && peeled.length === 1 && peeled[0] === artifactSha ? { kind: "exact" } : { kind: "divergent" }
}
async function tagState(tag) {
let result
Expand All @@ -95,7 +97,7 @@ async function localTagState(tag) {
const lines = result.stdout.trimEnd().split("\n")
if (lines.length !== 1) return { kind: "divergent" }
const match = lines[0].match(/^tag\t([0-9a-f]{40})$/)
return match && match[1] === expectedSha ? { kind: "exact" } : { kind: "divergent" }
return match && match[1] === artifactSha ? { kind: "exact" } : { kind: "divergent" }
}
function repository() {
if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY
Expand Down Expand Up @@ -139,8 +141,13 @@ async function main() {
if (cliArguments.some((x) => !["--preflight", "--json"].includes(x))) fail("unknown argument")
if (jsonOutput && !preflight) fail("--json requires --preflight")
if (!/^[0-9a-f]{40}$/.test(expectedSha)) fail("FINALIZE requires full lowercase expected SHA")
if (!/^[0-9a-f]{40}$/.test(artifactSha)) fail("FINALIZE requires full lowercase artifact SHA")
const states = await inspect()
if (preflight) { process.stdout.write(`${JSON.stringify({ ok: true, expectedSha, states })}\n`); return }
if (historicalReplay) {
const incomplete = states.find((item) => item.tag !== "exact" || item.release !== "exact" || item.npm !== "exact")
if (incomplete) fail(`historical replay requires exact existing tag, GitHub Release, and npm latest for ${incomplete.name}@${incomplete.version}`)
}
if (preflight) { process.stdout.write(`${JSON.stringify({ ok: true, expectedSha, artifactSha, states })}\n`); return }
const missingTags = states.filter((x) => x.tag === "absent")
const localTags = []
for (const item of missingTags) {
Expand All @@ -152,7 +159,7 @@ async function main() {
await run("git", ["config", "user.name", "github-actions[bot]"])
await run("git", ["config", "user.email", "github-actions[bot]@users.noreply.github.com"])
}
for (const { tag, local } of localTags) if (local === "absent") await run("git", ["tag", "-a", tag, expectedSha, "-m", tag])
for (const { tag, local } of localTags) if (local === "absent") await run("git", ["tag", "-a", tag, artifactSha, "-m", tag])
if (missingTags.length) {
const refs = missingTags.map((x) => `refs/tags/${x.name}@${x.version}:refs/tags/${x.name}@${x.version}`)
try { await run("git", ["push", "--atomic", "origin", ...refs]) } catch { /* response loss is reconciled below */ }
Expand Down
34 changes: 27 additions & 7 deletions scripts/release-finalize-stable.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { join } from "node:path"
import test from "node:test"

const script = new URL("release-finalize-stable.mjs", import.meta.url).pathname
const stableWorkflow = readFileSync(new URL("../.github/workflows/release-stable.yml", import.meta.url), "utf8")
const sha = "1234567890abcdef1234567890abcdef12345678"
const historicalSha = "abcdef1234567890abcdef1234567890abcdef12"
const records = [
["@effectify/hatchet", "packages/hatchet/package.json", "0.1.0"],
["@effectify/node-better-auth", "packages/node/better-auth/package.json", "0.5.12"],
Expand All @@ -29,7 +31,7 @@ if(cmd==='git'){
const t=a[3].slice(10),v=s.tags[t]; if(v){if(v.raw)out(v.raw.replaceAll('$TAG',t));else{out((v.direct||'a'.repeat(40))+'\trefs/tags/'+t+'\n');if(v.peeled!==null)out((v.peeled||s.sha)+'\trefs/tags/'+t+'^{}\n')}} finish()
}
if(a[0]==='for-each-ref'){const t=a[2].slice(10),v=s.localTags[t];if(v)out((v.type||'tag')+'\t'+(v.peeled||s.sha)+'\n');finish()}
if(a[0]==='tag'){s.localTags[a[2]]={type:'tag',peeled:s.sha};finish()}
if(a[0]==='tag'){s.localTags[a[2]]={type:'tag',peeled:a[3]};finish()}
if(a[0]==='push'){if(s.pushExit)finish(s.pushExit);for(const r of a.slice(3)){const t=r.split(':')[0].slice(10);s.tags[t]={peeled:s.localTags[t].peeled}}finish()}
finish(127)
}
Expand Down Expand Up @@ -77,22 +79,38 @@ async function world(mode = "absent") {
await new Promise(resolve => server.listen(0, "127.0.0.1", resolve))
return { cwd, bin, stateFile, server, api: `http://127.0.0.1:${server.address().port}` }
}
async function run(w, args = []) {
async function run(w, args = [], environment = {}) {
return await new Promise(resolve => {
const child = spawn(process.execPath, [script, ...args], { cwd: w.cwd, env: { PATH: w.bin, EXPECTED_SHA: sha, NPM_READ_DELAY_MS: "0", FINALIZE_COMMAND_TIMEOUT_MS: "5000", GITHUB_API_URL: w.api, GITHUB_REPOSITORY: "owner/repo", GITHUB_TOKEN: "fake", FAKE_STATE: w.stateFile } })
const child = spawn(process.execPath, [script, ...args], { cwd: w.cwd, env: { PATH: w.bin, EXPECTED_SHA: sha, ARTIFACT_SHA: "", NPM_READ_DELAY_MS: "0", FINALIZE_COMMAND_TIMEOUT_MS: "5000", GITHUB_API_URL: w.api, GITHUB_REPOSITORY: "owner/repo", GITHUB_TOKEN: "fake", FAKE_STATE: w.stateFile, ...environment } })
let stdout = "", stderr = ""; child.stdout.on("data", x => stdout += x); child.stderr.on("data", x => stderr += x); child.on("close", status => resolve({ status, stdout, stderr }))
})
}
async function scenario(t, name, setup, verify, mode = "exact", args = []) {
await t.test(name, async () => { const w = await world(mode); try { const state = load(w.stateFile); await setup(state, w); save(w.stateFile, state); const result = await run(w, args); await verify(result, load(w.stateFile), w) } finally { await new Promise(resolve => w.server.close(resolve)) } })
async function scenario(t, name, setup, verify, mode = "exact", args = [], environment = {}) {
await t.test(name, async () => { const w = await world(mode); try { const state = load(w.stateFile); await setup(state, w); save(w.stateFile, state); const result = await run(w, args, environment); await verify(result, load(w.stateFile), w) } finally { await new Promise(resolve => w.server.close(resolve)) } })
}
function exactState(state) { assert.equal(Object.keys(state.tags).length, 7); assert.equal(Object.keys(state.releases).length, 7); for (const [n,,v] of records) { assert.deepEqual(state.npm[n].versions, [v]); assert.equal(state.npm[n].latest, v); assert.equal(state.npm[n].alpha, "alpha-sentinel"); assert.equal(state.npm[n].beta, "beta-sentinel") } }
function historicalTags(state) { for (const [name,,version] of records) state.tags[`${name}@${version}`] = { peeled: historicalSha } }
function workflowPreflightInvocation() {
const match = stableWorkflow.match(/^[ \t]*- name: 🔎 PREFLIGHT exact stable artifacts\n([\s\S]*?)(?=^[ \t]*- name:)/m)
assert.ok(match, "stable workflow preflight step")
const commands = [...match[1].matchAll(/^[ \t]*run:\s*(.+)$/gm)].map((entry) => entry[1].trim())
assert.deepEqual(commands, ["bash scripts/release-finalize-stable.sh --preflight --json"])
return { args: commands[0].split(/\s+/).slice(2), source: match[1] }
}

const scenarioNames = []
test("hermetic Node CLI matrix", { timeout: 120_000 }, async t => {
const add = async (...args) => { scenarioNames.push(args[0]); await scenario(t, ...args) }
await add("all exact replay has zero mutation", async()=>{}, (r,s)=>{assert.equal(r.status,0,r.stderr);assert.deepEqual(mutations(s),[])})
await add("all absent creates and publishes exact manifests", async()=>{}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s);const push=s.log.find(x=>x[0]==="git"&&x[1]==="push");assert.deepEqual(push.slice(1,4),["push","--atomic","origin"]);assert.equal(s.log.find(x=>x[0]==="pnpm")[4],`--projects=${records.map(x=>x[0]).join(",")}`)}, "absent")
await add("same-SHA all absent publishes normally", async()=>{}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s);const push=s.log.find(x=>x[0]==="git"&&x[1]==="push");assert.deepEqual(push.slice(1,4),["push","--atomic","origin"]);assert.equal(s.log.find(x=>x[0]==="pnpm")[4],`--projects=${records.map(x=>x[0]).join(",")}`)}, "absent")
await add("historical all-existing artifacts succeed with zero mutation", async s=>historicalTags(s), (r,s)=>{assert.equal(r.status,0,r.stderr);assert.deepEqual(mutations(s),[])}, "exact", [], {ARTIFACT_SHA:historicalSha})
await add("historical missing tag fails before mutation", async s=>{historicalTags(s);delete s.tags[`${records[0][0]}@${records[0][2]}`]}, (r,s)=>{assert.notEqual(r.status,0);assert.match(r.stderr,/historical replay requires exact existing/);assert.deepEqual(mutations(s),[])}, "exact", [], {ARTIFACT_SHA:historicalSha})
await add("historical missing Release fails before mutation", async s=>{historicalTags(s);delete s.releases[`${records[0][0]}@${records[0][2]}`]}, (r,s)=>{assert.notEqual(r.status,0);assert.match(r.stderr,/historical replay requires exact existing/);assert.deepEqual(mutations(s),[])}, "exact", [], {ARTIFACT_SHA:historicalSha})
await add("historical missing npm version fails before mutation", async s=>{historicalTags(s);s.npm[records[0][0]].versions=[]}, (r,s)=>{assert.notEqual(r.status,0);assert.match(r.stderr,/historical replay requires exact existing/);assert.deepEqual(mutations(s),[])}, "exact", [], {ARTIFACT_SHA:historicalSha})
await add("historical latest mismatch fails before mutation", async s=>{historicalTags(s);s.npm[records[0][0]].latest="alpha"}, (r,s)=>{assert.notEqual(r.status,0);assert.match(r.stderr,/permanent latest divergence/);assert.deepEqual(mutations(s),[])}, "exact", [], {ARTIFACT_SHA:historicalSha})
await add("wrong artifact SHA fails before mutation", async s=>historicalTags(s), (r,s)=>{assert.notEqual(r.status,0);assert.match(r.stderr,/tag state is divergent/);assert.deepEqual(mutations(s),[])}, "exact", [], {ARTIFACT_SHA:"f".repeat(40)})
await add("malformed artifact SHA fails closed independently", async()=>{}, (r,s)=>{assert.notEqual(r.status,0);assert.match(r.stderr,/full lowercase artifact SHA/);assert.deepEqual(mutations(s),[])}, "exact", [], {ARTIFACT_SHA:"not-a-sha"})
await add("malformed expected SHA fails closed independently", async s=>historicalTags(s), (r,s)=>{assert.notEqual(r.status,0);assert.match(r.stderr,/full lowercase expected SHA/);assert.deepEqual(mutations(s),[])}, "exact", [], {EXPECTED_SHA:"not-a-sha",ARTIFACT_SHA:historicalSha})
for (const [index] of records.entries()) await add(`tag partial subset ${index+1} replays`, async s=>{for(const [n,,v] of records.slice(0,index+1))s.tags[`${n}@${v}`]={peeled:sha}}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s)}, "absent")
for (const [index] of records.entries()) await add(`release partial subset ${index+1} replays`, async s=>{for(const [n,,v] of records)s.tags[`${n}@${v}`]={peeled:sha};for(const [n,,v] of records.slice(0,index+1))s.releases[`${n}@${v}`]={tag_name:`${n}@${v}`,draft:false,prerelease:false}}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s)}, "absent")
for (const [index] of records.entries()) await add(`npm partial subset ${index+1} replays`, async s=>{for(const [n,,v] of records){s.tags[`${n}@${v}`]={peeled:sha};s.releases[`${n}@${v}`]={tag_name:`${n}@${v}`,draft:false,prerelease:false}}for(const [n,,v] of records.slice(0,index+1)){s.npm[n].versions=[v];s.npm[n].latest=v}}, (r,s)=>{assert.equal(r.status,0,r.stderr);exactState(s)}, "absent")
Expand All @@ -113,7 +131,9 @@ test("hermetic Node CLI matrix", { timeout: 120_000 }, async t => {
await add("manifest version mismatch fails before mutation", async(s,w)=>writeFileSync(join(w.cwd,records[0][1]),JSON.stringify({name:records[0][0],version:"9.9.9"})), (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)})
await add("EXPECTED_SHA controls HEAD", async s=>{s.head="f".repeat(40)}, (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)})
await add("EXPECTED_SHA controls origin", async s=>{s.origin="f".repeat(40)}, (r,s)=>{assert.notEqual(r.status,0);assert.equal(mutations(s).length,0)})
await add("preflight JSON reads only", async()=>{}, (r,s)=>{assert.equal(r.status,0,r.stderr);assert.equal(JSON.parse(r.stdout).expectedSha,sha);assert.equal(mutations(s).length,0)}, "exact", ["--preflight","--json"])
const preflight = workflowPreflightInvocation()
assert.doesNotMatch(preflight.source, /NODE_AUTH_TOKEN|NPM_CONFIG_PROVENANCE|npm whoami|nx release publish|git (?:tag|push)|gh release (?:create|delete)/)
await add("workflow historical preflight JSON includes both SHAs and reads only", async s=>historicalTags(s), (r,s)=>{assert.equal(r.status,0,r.stderr);const output=JSON.parse(r.stdout);assert.equal(output.expectedSha,sha);assert.equal(output.artifactSha,historicalSha);assert.equal(mutations(s).length,0)}, "exact", preflight.args, {ARTIFACT_SHA:historicalSha})
assert.equal(new Set(scenarioNames).size, scenarioNames.length)
})

Expand Down
Loading
Loading