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
4 changes: 3 additions & 1 deletion .github/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<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
Expand Down Expand Up @@ -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`.
Expand Down
11 changes: 10 additions & 1 deletion scripts/release-finalize-stable.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}`,
Expand All @@ -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 */
}
Expand Down
52 changes: 51 additions & 1 deletion scripts/release-finalize-stable.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand All @@ -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)
Expand Down Expand Up @@ -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) => {
Expand Down
56 changes: 52 additions & 4 deletions scripts/release-policy-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"/,
Expand Down Expand Up @@ -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"',
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:<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\./,
Expand Down Expand Up @@ -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)/)
Expand Down
Loading