From c7a47d2ea8cbef28e58e71539622e80d5da6f8a2 Mon Sep 17 00:00:00 2001 From: kattsushi Date: Mon, 31 Aug 2026 05:43:21 -0600 Subject: [PATCH] fix(release): authenticate stable tag push --- .github/SETUP.md | 4 +- scripts/release-finalize-stable.mjs | 11 ++++- scripts/release-finalize-stable.test.mjs | 52 +++++++++++++++++++++- scripts/release-policy-contract.test.mjs | 56 ++++++++++++++++++++++-- 4 files changed, 116 insertions(+), 7 deletions(-) diff --git a/.github/SETUP.md b/.github/SETUP.md index d5ebda35..5dd09e93 100644 --- a/.github/SETUP.md +++ b/.github/SETUP.md @@ -29,6 +29,8 @@ Create the `stable-release` environment under **Settings > Environments** and re FINALIZE installs required tooling with `--ignore-scripts`, forces `NPM_CONFIG_IGNORE_SCRIPTS=true` for the finalizer step and publish child, and runs no build, test, or package lifecycle scripts. Declared job permissions, including `contents` and `id-token`, are available job-wide; when `id-token: write` is declared, OIDC is not step-scoped. The only step-scoped credential controls are explicit secret or token environment variables on their listed API or mutation steps. This environment scoping is defense in depth; it does not turn job permissions into step-only capabilities. Every checkout sets `persist-credentials: false`, so checkout credentials are not persisted. +FINALIZE derives `x-access-token:` Basic authentication from its step-scoped `GITHUB_TOKEN` only for the atomic tag push and supplies it through a one-shot GitHub-scoped `git -c` extraheader. Missing, oversized, whitespace, control-character, or non-ASCII authentication fails before local tag creation. The credential is never written to checkout configuration or printed. PREFLIGHT retains only its existing read token and does not construct tag-push authentication. + The real stable publication boundary is protected `stable-release` environment review, authorization of the reviewed SHA, and npm trusted publishing bound to the repository, workflow, environment, and OIDC claims. `GITHUB_ACTIONS` is checked only as an accidental-use guard, so FINALIZE is refused outside GitHub Actions; it is not an unspoofable local security gate because a local process can set it. ## Nx release projects @@ -153,7 +155,7 @@ gh workflow run release-stable.yml --ref master \ Protected-environment approval occurs before the privileged job. FINALIZE again proves the exact expected and artifact SHA authorization, then reconciles in order: -1. exact annotated tags targeting `artifact_sha`, with one atomic explicit tag-refspec push; +1. exact annotated tags targeting `artifact_sha`, with one atomic explicit tag-refspec push using the non-persisted one-shot Basic extraheader; 2. exact non-draft, non-prerelease GitHub Releases; 3. only npm packages still missing, through Nx without a prerelease tag; 4. bounded verification of every selected npm version and `latest`. diff --git a/scripts/release-finalize-stable.mjs b/scripts/release-finalize-stable.mjs index 0639117d..053937ba 100644 --- a/scripts/release-finalize-stable.mjs +++ b/scripts/release-finalize-stable.mjs @@ -363,6 +363,14 @@ function repository() { if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY fail("GITHUB_REPOSITORY is required") } +function tagPushConfiguration() { + const token = process.env.GITHUB_TOKEN + if (typeof token !== "string" || !/^[\x21-\x7e]{1,4096}$/.test(token)) { + fail("FINALIZE tag push requires a safe non-empty GITHUB_TOKEN") + } + const basicAuth = Buffer.from(`x-access-token:${token}`, "utf8").toString("base64") + return `http.https://github.com/.extraheader=AUTHORIZATION: basic ${basicAuth}` +} async function github(method, path, body) { const controller = new AbortController(), timer = setTimeout(() => controller.abort(), httpTimeoutMs) @@ -467,6 +475,7 @@ async function main() { await verifyPublicationSource(records) const missingTags = states.filter((item) => item.tag === "absent") + const pushConfiguration = missingTags.length > 0 ? tagPushConfiguration() : "" const localTags = [] for (const item of missingTags) { const tag = `${item.name}@${item.version}`, @@ -485,7 +494,7 @@ async function main() { (item) => `refs/tags/${item.name}@${item.version}:refs/tags/${item.name}@${item.version}`, ) try { - await run("git", ["push", "--atomic", "origin", ...refs]) + await run("git", ["-c", pushConfiguration, "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 ffca2b84..c0d06906 100644 --- a/scripts/release-finalize-stable.test.mjs +++ b/scripts/release-finalize-stable.test.mjs @@ -28,7 +28,7 @@ const selectedProjects = records .join(",") 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 +const fs=require('fs'),p=require('path'),cmd=p.basename(process.argv[1]),raw=process.argv.slice(2),oneShot=cmd==='git'&&raw[0]==='-c',authConfiguration=oneShot?raw[1]:undefined,a=oneShot?raw.slice(2):raw,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)} @@ -50,6 +50,8 @@ if(cmd==='git'){ 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.artifactSha)+'\n');finish()} if(a[0]==='tag'){s.localTags[a[2]]={type:'tag',peeled:a[3]};finish()} if(a[0]==='push'){ + const token=process.env.GITHUB_TOKEN||'',basic=Buffer.from('x-access-token:'+token,'utf8').toString('base64') + s.pushAuthentication={oneShot,matchesToken:authConfiguration==='http.https://github.com/.extraheader=AUTHORIZATION: basic '+basic,tokenLiteral:Boolean(token)&&raw.some(value=>value.includes(token))} const materialize=()=>{for(const r of a.slice(3)){const t=r.split(':')[0].slice(10);s.tags[t]={peeled:s.localTags[t].peeled}}} if(s.pushMaterializesOnFailure){materialize();finish(s.pushExit||1)} if(s.pushExit)finish(s.pushExit) @@ -346,6 +348,54 @@ test("hermetic arbitrary-subset Node CLI matrix", { timeout: 120_000 }, async (t }, "absent", ) + await add( + "atomic tag push uses one-shot GitHub Basic authentication without exposing the token", + async () => {}, + (result, state) => { + assert.equal(result.status, 0, result.stderr) + assert.deepEqual(state.pushAuthentication, { + oneShot: true, + matchesToken: true, + tokenLiteral: false, + }) + assert.equal( + state.log.some( + (call) => call[0] === "git" && call[1] === "config" && /authorization|extraheader/i.test(call.join(" ")), + ), + false, + ) + assert.deepEqual(state.log.find((call) => call[0] === "git" && call[1] === "push"), [ + "git", + "push", + "--atomic", + "origin", + ...records.map( + ([project, , version]) => `refs/tags/${project}@${version}:refs/tags/${project}@${version}`, + ), + ]) + assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /tag-push-secret/) + exactState(state) + }, + "absent", + [], + { GITHUB_TOKEN: "tag-push-secret" }, + ) + for (const [name, token] of [ + ["missing", ""], + ["unsafe", "unsafe token"], + ]) + await add( + `${name} GitHub tag-push authentication fails closed before mutation`, + async () => {}, + (result, state) => { + assert.notEqual(result.status, 0) + assert.match(result.stderr, /tag push requires a safe non-empty GITHUB_TOKEN/) + assert.deepEqual(mutations(state), []) + }, + "absent", + [], + { GITHUB_TOKEN: token }, + ) await add( "stable absence with no latest publishes", async (state) => { diff --git a/scripts/release-policy-contract.test.mjs b/scripts/release-policy-contract.test.mjs index c984e19d..30e87520 100644 --- a/scripts/release-policy-contract.test.mjs +++ b/scripts/release-policy-contract.test.mjs @@ -1067,7 +1067,26 @@ const stableViolations = (source, finalizeScript = stableFinalizeScript) => { ["HTTP 404 absence", /result\.status === 404/, activeFinalize], ["unknown Release fail closed", /result\.status !== 200/, activeFinalize], ["annotated artifact tag", /\["tag", "-a", tag, artifactSha, "-m", tag\]/, activeFinalize], - ["atomic explicit push", /\["push", "--atomic", "origin", \.\.\.refs\]/, activeFinalize], + [ + "bounded printable-ASCII tag-push token", + /!\/\^\[\\x21-\\x7e\]\{1,4096\}\$\/\.test\(token\)/, + activeFinalize, + ], + [ + "Basic tag-push credential", + /Buffer\.from\(`x-access-token:\$\{token\}`, "utf8"\)\.toString\("base64"\)/, + activeFinalize, + ], + [ + "tag-push authentication before local tag mutation", + /const pushConfiguration = missingTags\.length > 0 \? tagPushConfiguration\(\) : ""[\s\S]*\["tag", "-a", tag, artifactSha, "-m", tag\]/, + activeFinalize, + ], + [ + "one-shot authenticated atomic explicit push", + /\["-c", pushConfiguration, "push", "--atomic", "origin", \.\.\.refs\]/, + activeFinalize, + ], [ "release exact postverification", /releaseState\(`\$\{item\.name\}@\$\{item\.version\}`\)\)\.kind !== "exact"/, @@ -1251,7 +1270,7 @@ const stableViolations = (source, finalizeScript = stableFinalizeScript) => { const order = [ "const states = await inspect(records)", '["tag", "-a"', - '["push", "--atomic"', + '["-c", pushConfiguration, "push", "--atomic"', 'github("POST"', "releaseState(`${item.name}", '["nx", "release", "publish"', @@ -2098,7 +2117,26 @@ test("protected stable PREPARE and FINALIZE reject independent safety mutations" ], ["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]'], + [ + "remove tag-push token validation", + 'if (typeof token !== "string" || !/^[\\x21-\\x7e]{1,4096}$/.test(token)) {', + "if (false) {", + ], + [ + "use raw tag-push token", + 'Buffer.from(`x-access-token:${token}`, "utf8").toString("base64")', + "token", + ], + [ + "remove one-shot tag-push authentication", + '["-c", pushConfiguration, "push", "--atomic", "origin", ...refs]', + '["push", "--atomic", "origin", ...refs]', + ], + [ + "non-atomic push", + '["-c", pushConfiguration, "push", "--atomic", "origin", ...refs]', + '["-c", pushConfiguration, "push", "origin", ...refs]', + ], ["publish all projects", 'states.filter((state) => state.npm === "absent")', "states"], ]) { const changed = mutate(stableFinalizeScript, before, after) @@ -2331,6 +2369,12 @@ test("protected stable documentation exposes authorization and recovery boundari /The only step-scoped credential controls are explicit secret or token environment variables on their listed API or mutation steps\./, ) assert.match(setup, /Every checkout sets `persist-credentials: false`, so checkout credentials are not persisted\./) + assert.match( + setup, + /FINALIZE derives `x-access-token:` Basic authentication from its step-scoped `GITHUB_TOKEN` only for the atomic tag push/, + ) + assert.match(setup, /one-shot GitHub-scoped `git -c` extraheader/) + assert.match(setup, /fails before local tag creation/) assert.match( setup, /The real stable publication boundary is protected `stable-release` environment review, authorization of the reviewed SHA, and npm trusted publishing bound to the repository, workflow, environment, and OIDC claims\./, @@ -2379,7 +2423,11 @@ test("protected stable promotion exposes exact PREPARE and FINALIZE contracts", /pnpm nx release version "\$NEW" "--projects=\$PROJECT" --git-commit=false --git-tag=false --git-push=false --stage-changes=false/, ) assert.match(active, /push origin "HEAD:refs\/heads\/\$BRANCH"/) - assert.match(active, /run\("git", \["push", "--atomic", "origin", \.\.\.refs\]\)/) + assert.match( + active, + /run\("git", \["-c", pushConfiguration, "push", "--atomic", "origin", \.\.\.refs\]\)/, + ) + assert.match(active, /Buffer\.from\(`x-access-token:\$\{token\}`, "utf8"\)\.toString\("base64"\)/) assert.match(active, /github\("POST", "\/releases"/) assert.match(active, /run\("pnpm", \["nx", "release", "publish"/) assert.doesNotMatch(active, /--tag=(?:alpha|beta)/)