From f92b7f469d4595d9c2ce2e8d7aeebe790d217e2a Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Mon, 5 Oct 2026 05:27:51 -0400 Subject: [PATCH 1/3] ci(workflows): move nested shell into bun scripts for the shell-complexity gate The platform's check-shell-complexity gate (ci / standard-checks, Vivswan/repo-platform/actions/check-shell-complexity@stable) refuses shell that nests one construct inside another, and any function. It failed main and every PR with 11 findings, all in repo-owned starters: auto-fix.yml (5), auto-format.yml (1), nightly.yml (3), update-release-pr.yml (1), update-release.yml (1). Each flagged run block is now a TypeScript script run by bun, one subcommand per moved step, with the same commands, GITHUB_OUTPUT lines, log lines, and exit codes: - auto-fix.yml rebuild and push -> .github/scripts/auto-fix-steps.ts rebuild | push - auto-format.yml push -> .github/scripts/auto-format-steps.ts push - nightly.yml schema and types probes -> .github/scripts/nightly-steps.ts probe-schema | probe-types - the npm floor step of both release workflows -> release-pipeline.ts npm-floor (Bun.semver against 11.5.1) .github/scripts/lib/workflow-step.ts carries what bash gave those steps (set -e with signal statuses, $(...), GITHUB_OUTPUT) and lib/pr-branch.ts the bot identity and lease push the two push steps share. The push jobs of auto-fix.yml and auto-format.yml keep their boundary: the script that holds the write token is checked out from the default branch into trusted/, bun starts there (so the bunfig.toml and .env it reads are main's), and the PR branch in branch/ receives only git. The fix-owned allowlist the push job enforces is therefore main's, which a PR cannot widen. test/scripts/auto-fix-allowlist.test.ts, which regex-parsed the shell, is replaced by test/scripts/auto-fix-steps.test.ts: the fix-owned list and the generator order pinned to the generated-output table, and each step's branches run against fixture repositories. auto-format-steps, nightly-steps (a scripted bun), and npm-floor (a scripted npm) get the same treatment. Deliberate divergences from the shell: a git diff --quiet status other than 0 or 1 fails the rebuild instead of reading as a prune; npm-floor compares the version through Bun.semver instead of sort -V and reports a failed upgrade with its exit status through the script's uniform stderr line (exit 1); probe-schema reads node_modules//package.json instead of bun -e require, since bun's resolver would auto-install a package it does not find, the pin this probe must not test. --- .github/scripts/auto-fix-steps.ts | 180 ++++++++++++ .github/scripts/auto-format-steps.ts | 53 ++++ .github/scripts/lib/pr-branch.ts | 24 ++ .github/scripts/lib/workflow-step.ts | 88 ++++++ .github/scripts/nightly-steps.ts | 113 ++++++++ .github/scripts/release-pipeline.ts | 52 +++- .github/workflows/auto-fix.yml | 123 ++------ .github/workflows/auto-format.yml | 34 ++- .github/workflows/nightly.yml | 41 +-- .github/workflows/update-release-pr.yml | 11 +- .github/workflows/update-release.yml | 11 +- test/scripts/auto-fix-allowlist.test.ts | 190 ------------- test/scripts/auto-fix-steps.test.ts | 359 ++++++++++++++++++++++++ test/scripts/auto-format-steps.test.ts | 103 +++++++ test/scripts/nightly-steps.test.ts | 202 +++++++++++++ test/scripts/release-pipeline.test.ts | 91 +++++- test/scripts/step-fixture.ts | 51 ++++ 17 files changed, 1359 insertions(+), 367 deletions(-) create mode 100644 .github/scripts/auto-fix-steps.ts create mode 100644 .github/scripts/auto-format-steps.ts create mode 100644 .github/scripts/lib/pr-branch.ts create mode 100644 .github/scripts/lib/workflow-step.ts create mode 100644 .github/scripts/nightly-steps.ts delete mode 100644 test/scripts/auto-fix-allowlist.test.ts create mode 100644 test/scripts/auto-fix-steps.test.ts create mode 100644 test/scripts/auto-format-steps.test.ts create mode 100644 test/scripts/nightly-steps.test.ts create mode 100644 test/scripts/step-fixture.ts diff --git a/.github/scripts/auto-fix-steps.ts b/.github/scripts/auto-fix-steps.ts new file mode 100644 index 000000000..dace48f43 --- /dev/null +++ b/.github/scripts/auto-fix-steps.ts @@ -0,0 +1,180 @@ +/** + * The two auto-fix.yml steps whose logic outgrew shell: the build job's rebuild, which regenerates the generated + * files and cuts the fix patch, and the push job's commit-back of that patch. One subcommand per step: + * + * rebuild build job RUNNER_TEMP, GITHUB_OUTPUT -> autofix.patch, changed=, pruned= + * push push job TOKEN, CAN_RETRIGGER, HEAD_REF, HEAD_SHA, -> the commit on the PR branch, notify= + * PRUNED, RUNNER_TEMP, GITHUB_REPOSITORY, + * GITHUB_OUTPUT + * + * FIX_OWNED_PATHS is a security boundary and stays literal here: the patch is cut on a runner PR code ran on, so + * the push job admits nothing outside it, and it runs this file from the default branch's checkout, so a PR cannot + * widen the list for its own push. test/scripts/auto-fix-steps.test.ts pins the list and GENERATORS to the + * generated-output table (.github/scripts/generated.ts), so a newly registered output cannot slip past either step. + * + * Node builtins only: the push job installs nothing, so no git hook or lifecycle script exists where the write + * token is. + */ + +import { statSync } from "node:fs"; +import { join } from "node:path"; +import { configureBotIdentity, leasePush } from "./lib/pr-branch.js"; +import { capture, requireEnv, run, setOutput, status } from "./lib/workflow-step.js"; + +/** In generated.ts table order: the later generators import the gaps index through src/, and the inputs table + * reads action.yml, so each source renders first. */ +export const GENERATORS = [ + "build:gaps-index", + "build:docs", + "build:action-docs", + "build:inputs-table", +] as const; + +/** A `dir/` entry covers everything under it, deletions included (a graduated gap file is a deletion). */ +export const FIX_OWNED_PATHS = [ + "README.md", + "action.yml", + "docs/reference/coverage.md", + "docs/reference/undeclared-policy.md", + "docs/reference/permissions.md", + "docs/operate/check-mode.md", + "docs/reference/sections.md", + "docs/reference/inputs.md", + "docs/reference/architecture.md", + "docs/start/getting-started.md", + "src/upstream-gaps/", +] as const; + +const GAPS_DIR = "src/upstream-gaps/"; +const GAPS_INDEX = "src/upstream-gaps/index.ts"; + +export function fixOwned(path: string): boolean { + return FIX_OWNED_PATHS.some((entry) => + entry.endsWith("/") ? path.startsWith(entry) : entry === path, + ); +} + +/** The refusal names every admitted path, so a red push job's log shows the boundary without this file. */ +function outsideFixOwned(path: string): string { + const files = FIX_OWNED_PATHS.filter((entry) => !entry.endsWith("/")); + const dirs = FIX_OWNED_PATHS.filter((entry) => entry.endsWith("/")); + return `::error::the fix patch staged '${path}', outside the generated docs (${files.join(", ")}), and ${dirs.join(", ")}; refusing to push`; +} + +/** `git diff --cached --quiet` answers with its status: 0 for no change, 1 for one; anything else is git failing. */ +function stagedChange(...pathspec: string[]): boolean { + const code = status(["git", "diff", "--cached", "--quiet", ...pathspec]); + if (code !== 0 && code !== 1) { + process.exit(code); + } + return code === 1; +} + +function rebuild(): void { + for (const generator of GENERATORS) { + run(["bun", "run", generator]); + } + // Anything the earlier steps left staged is not this workflow's fix: start from an empty index so the patch + // holds exactly the fix-owned paths. + run(["git", "reset", "-q"]); + run(["git", "add", "-A", "--", ...FIX_OWNED_PATHS]); + const patch = join(requireEnv("RUNNER_TEMP"), "autofix.patch"); + run(["git", "diff", "--cached", "--binary"], { stdoutFile: patch }); + if (statSync(patch).size === 0) { + console.log("docs and upstream gaps already fresh"); + setOutput("changed", "false"); + setOutput("pruned", "false"); + return; + } + run(["git", "diff", "--cached", "--stat"]); + setOutput("changed", "true"); + // A patch touching a gap file means gap files graduated (the index alone is a regeneration); the push job picks + // the commit subject from this. + setOutput("pruned", String(stagedChange("--", GAPS_DIR, `:(exclude)${GAPS_INDEX}`))); +} + +function push(): void { + // bun has already read its bunfig.toml and .env from the trusted checkout it started in; only git runs in the + // PR branch's tree from here on, and a PR cannot put a hook or a config there. + process.chdir(requireEnv("PR_CHECKOUT")); + const token = requireEnv("TOKEN"); + const canRetrigger = requireEnv("CAN_RETRIGGER"); + const headRef = requireEnv("HEAD_REF"); + const headSha = requireEnv("HEAD_SHA"); + const pruned = requireEnv("PRUNED"); + // The branch can move between the build and this push (a Dependabot rebase): the patch belongs to HEAD_SHA + // only, and the newer head's own run owns the rebuild. + const superseded = (): boolean => { + // A failed refresh leaves origin/HEAD_REF where it was, and that stale read answers: the shell ran this fetch + // inside an if condition, where a failure does not end the step either. + status(["git", "fetch", "--quiet", "origin", headRef]); + return capture(["git", "rev-parse", `origin/${headRef}`]) !== headSha; + }; + if (capture(["git", "rev-parse", "HEAD"]) !== headSha) { + console.log("::notice::head moved since the build; skipping the stale fix push"); + return; + } + run([ + "git", + "apply", + "--index", + "--binary", + join(requireEnv("RUNNER_TEMP"), "autofix", "autofix.patch"), + ]); + // --no-renames lists a staged rename as its delete and add halves, so a rename OUT of a protected path cannot + // hide behind an allowed destination. + const staged = capture(["git", "diff", "--cached", "--no-renames", "--name-only", "-z"]) + .split("\0") + .filter((path) => path !== ""); + for (const path of staged) { + if (!fixOwned(path)) { + console.log(outsideFixOwned(path)); + process.exit(1); + } + } + if (!stagedChange()) { + console.log("the fix patch stages nothing against the head commit; nothing to push"); + return; + } + configureBotIdentity(); + let subject = "build: regenerate generated files"; + if (!stagedChange("--", GAPS_DIR)) { + subject = "docs: regenerate generated docs"; + } + if (pruned === "true") { + subject = "fix: graduate upstream gaps octokit now ships"; + } + // [dependabot skip] keeps Dependabot rebasing and updating the PR over this commit; without it Dependabot stops + // touching a PR once someone else pushes to it. + run(["git", "commit", "--no-verify", "-m", subject, "-m", "[dependabot skip]"]); + if (leasePush(headRef, headSha, token) !== 0) { + if (superseded()) { + console.log("::notice::head moved during the push; skipping the stale fix push"); + return; + } + process.exit(1); + } + if (canRetrigger === "true") { + return; + } + // The fix landed via github.token, which starts no workflows, so the PR's checks will not re-run on the new + // head: surface that here and, through notify=, on the PR. + setOutput("notify", "true"); + console.log( + "::warning::auto-fix pushed without REPO_PLATFORM_TOKEN - checks will not re-run on the new head; close/reopen the PR or register the token", + ); +} + +if (import.meta.main) { + const command = process.argv[2]; + if (command === "rebuild") { + rebuild(); + } else if (command === "push") { + push(); + } else { + console.error( + `auto-fix-steps: unknown command ${JSON.stringify(command ?? null)}; expected rebuild | push`, + ); + process.exit(1); + } +} diff --git a/.github/scripts/auto-format-steps.ts b/.github/scripts/auto-format-steps.ts new file mode 100644 index 000000000..85d34083e --- /dev/null +++ b/.github/scripts/auto-format-steps.ts @@ -0,0 +1,53 @@ +/** + * The auto-format.yml push step: the formatting patch the format job cut on HEAD_SHA, committed and pushed to the + * PR branch under a lease on that commit. + * + * push push job GH_TOKEN, HEAD_REF, HEAD_SHA, RUNNER_TEMP, GITHUB_REPOSITORY -> the commit on the PR branch + * + * Node builtins only: the push job installs nothing, so no git hook or lifecycle script exists where the write + * token is. + */ + +import { join } from "node:path"; +import { configureBotIdentity, leasePush } from "./lib/pr-branch.js"; +import { capture, requireEnv, run } from "./lib/workflow-step.js"; + +function push(): void { + // bun has already read its bunfig.toml and .env from the trusted checkout it started in; only git runs in the + // PR branch's tree from here on, and a PR cannot put a hook or a config there. + process.chdir(requireEnv("PR_CHECKOUT")); + const token = requireEnv("GH_TOKEN"); + const headRef = requireEnv("HEAD_REF"); + const headSha = requireEnv("HEAD_SHA"); + // The branch can move between the two jobs; the patch was cut on HEAD_SHA and formats that tree alone, so a + // newer head gets its own run (re-apply the label) instead of a commit formatting the old one. + if (capture(["git", "rev-parse", "HEAD"]) !== headSha) { + console.log("::notice::head moved since the format; skipping the stale formatting push"); + return; + } + run([ + "git", + "apply", + "--index", + "--binary", + join(requireEnv("RUNNER_TEMP"), "format", "format.patch"), + ]); + configureBotIdentity(); + run(["git", "commit", "--no-verify", "-m", "style: apply automated formatting"]); + const pushed = leasePush(headRef, headSha, token); + if (pushed !== 0) { + process.exit(pushed); + } +} + +if (import.meta.main) { + const command = process.argv[2]; + if (command === "push") { + push(); + } else { + console.error( + `auto-format-steps: unknown command ${JSON.stringify(command ?? null)}; expected push`, + ); + process.exit(1); + } +} diff --git a/.github/scripts/lib/pr-branch.ts b/.github/scripts/lib/pr-branch.ts new file mode 100644 index 000000000..3b615022e --- /dev/null +++ b/.github/scripts/lib/pr-branch.ts @@ -0,0 +1,24 @@ +/** + * The commit-back a pull request's branch receives from auto-fix.yml and auto-format.yml: the one place the bot + * identity and the lease push are spelled, so the two workflows cannot drift apart on either. + */ + +import { requireEnv, run, status } from "./workflow-step.js"; + +/** Written into the checkout's config, as the workflows did: the runner's checkout dies with the job. */ +export function configureBotIdentity(): void { + run(["git", "config", "user.name", "github-actions[bot]"]); + run(["git", "config", "user.email", "github-actions[bot]@users.noreply.github.com"]); +} + +/** HEAD pushed to the PR branch under a lease pinned to the commit the fix was built from, so a concurrent push + * (even a force-reset to an ancestor) rejects this one instead of racing it. The status is the push's. */ +export function leasePush(headRef: string, headSha: string, token: string): number { + return status([ + "git", + "push", + `--force-with-lease=refs/heads/${headRef}:${headSha}`, + `https://x-access-token:${token}@github.com/${requireEnv("GITHUB_REPOSITORY")}.git`, + `HEAD:refs/heads/${headRef}`, + ]); +} diff --git a/.github/scripts/lib/workflow-step.ts b/.github/scripts/lib/workflow-step.ts new file mode 100644 index 000000000..5230071e5 --- /dev/null +++ b/.github/scripts/lib/workflow-step.ts @@ -0,0 +1,88 @@ +/** + * What a workflow `run:` step's bash gave the scripts that replaced it, so a moved step keeps its exit codes and + * outputs: `set -e` (a failing command ends the step with that command's status), `$(...)`, a variable the step + * cannot run without, and the `>> "$GITHUB_OUTPUT"` append. Node builtins only: a push job runs these before any + * install. + */ + +import { type SpawnSyncReturns, spawnSync } from "node:child_process"; +import { appendFileSync, closeSync, openSync } from "node:fs"; +import { constants } from "node:os"; + +export interface RunOptions { + env?: Record; + /** stdout goes to this file instead of the step's log, as `> file` would; `stderrToo` sends stderr there as well. */ + stdoutFile?: string; + stderrToo?: boolean; +} + +/** The variable's value; unset or empty ends the step naming it, as `${NAME:?}` would. */ +export function requireEnv(name: string): string { + const value = process.env[name]; + if (value === undefined || value === "") { + console.error(`${name} must be set for this step`); + process.exit(1); + } + return value; +} + +export function setOutput(name: string, value: string): void { + appendFileSync(requireEnv("GITHUB_OUTPUT"), `${name}=${value}\n`); +} + +/** The status bash would report: the exit code, or 128 plus the signal number for a child a signal ended. A command + * that cannot start ends the step, as bash's 127 would. */ +function statusOf(command: string, result: SpawnSyncReturns): number { + if (result.error !== undefined) { + console.error(`${command}: ${result.error.message}`); + process.exit(127); + } + if (result.signal !== null) { + return 128 + (constants.signals[result.signal] ?? 0); + } + return result.status ?? 1; +} + +/** The command's exit status, its output on the step's log unless redirected: for a command whose status is the + * answer (`git diff --quiet`, a push under a lease). */ +export function status(argv: readonly string[], options: RunOptions = {}): number { + const [command = "", ...args] = argv; + const fd = options.stdoutFile === undefined ? null : openSync(options.stdoutFile, "w"); + try { + const result = spawnSync(command, args, { + stdio: ["inherit", fd ?? "inherit", fd !== null && options.stderrToo ? fd : "inherit"], + env: { ...process.env, ...options.env }, + }); + return statusOf(command, result); + } finally { + if (fd !== null) { + closeSync(fd); + } + } +} + +/** Runs the command; a failure ends the step with the command's status, as `set -e` does. */ +export function run(argv: readonly string[], options: RunOptions = {}): void { + const code = status(argv, options); + if (code !== 0) { + process.exit(code); + } +} + +/** The command's stdout with its trailing newlines removed, as `$(...)` yields it; stderr stays on the step's log, + * and a failure ends the step with the command's status. Unbounded like `$(...)`: a staged-path listing or a diff + * can pass spawnSync's 1 MiB default. */ +export function capture(argv: readonly string[], options: Pick = {}): string { + const [command = "", ...args] = argv; + const result = spawnSync(command, args, { + stdio: ["inherit", "pipe", "inherit"], + env: { ...process.env, ...options.env }, + encoding: "utf8", + maxBuffer: Number.MAX_SAFE_INTEGER, + }); + const code = statusOf(command, result); + if (code !== 0) { + process.exit(code); + } + return result.stdout.replace(/\n+$/, ""); +} diff --git a/.github/scripts/nightly-steps.ts b/.github/scripts/nightly-steps.ts new file mode 100644 index 000000000..97fb1a32c --- /dev/null +++ b/.github/scripts/nightly-steps.ts @@ -0,0 +1,113 @@ +/** + * The nightly.yml probes against the latest upstream releases, one subcommand per step of the checks job: + * + * probe-schema @octokit/openapi and @octokit/graphql-schema at latest, then the lockstep tests + * probe-types @octokit/types at latest, then the typecheck, its failures sorted into tripwires and breakage + * + * Each ends the night red instead of leaving a later PR to find the break. Node builtins only. + */ + +import { mkdtempSync, readFileSync, rmSync, writeSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { capture, run, status } from "./lib/workflow-step.js"; + +const SCHEMA_PACKAGES = ["@octokit/openapi", "@octokit/graphql-schema"] as const; +const LOCKSTEP_TESTS = [ + "test/e2e/openapi/validate.test.ts", + "test/sections/graphql-queries.test.ts", +] as const; +/** tsc's one-error-per-line format under --pretty false: `src/upstream-gaps/.ts(line,col): error TS2344: ...`. */ +const TRIPWIRE_LINE = /^src\/upstream-gaps\/.+error TS2344/; + +/** The version `bun add` left under the checkout's node_modules, read from the file itself: bun's resolver would + * auto-install a package it does not find there, and that is exactly the pin this probe must not test. */ +function installedVersion(pkg: string): string { + const manifest = join(process.cwd(), "node_modules", pkg, "package.json"); + return (JSON.parse(readFileSync(manifest, "utf8")) as { version: string }).version; +} + +function probeSchema(): void { + // --no-save leaves package.json and the lockfile untouched; the upgraded node_modules stays ambient for the + // rest of the job. + run([ + "bun", + "add", + "--no-save", + "--ignore-scripts", + ...SCHEMA_PACKAGES.map((pkg) => `${pkg}@latest`), + ]); + // A pinned version left in place would make this probe pass green forever without probing anything. + for (const pkg of SCHEMA_PACKAGES) { + const installed = installedVersion(pkg); + const latest = capture(["bun", "info", pkg, "version"]); + if (installed !== latest) { + console.log( + `::error::${pkg}@${installed} is installed but ${latest} is the latest; the probe is testing the pin`, + ); + process.exit(1); + } + console.log(`${pkg}@${installed}`); + } + run(["bun", "test", ...LOCKSTEP_TESTS]); +} + +/** The typecheck's verdict with its stdout and stderr in arrival order, read back from the one file both went to. */ +function typecheck(): { clean: boolean; log: string } { + const scratch = mkdtempSync(join(tmpdir(), "nightly-typecheck-")); + try { + const file = join(scratch, "typecheck.log"); + // --pretty false keeps the one-error-per-line format TRIPWIRE_LINE parses, independent of tsc's TTY detection. + const code = status(["bun", "run", "typecheck", "--pretty", "false"], { + stdoutFile: file, + stderrToo: true, + }); + return { clean: code === 0, log: readFileSync(file, "utf8") }; + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +} + +function probeTypes(): void { + run(["bun", "add", "--no-save", "--ignore-scripts", "@octokit/types@latest"]); + const { clean, log } = typecheck(); + if (clean) { + console.log("typecheck is clean against @octokit/types@latest"); + return; + } + // One synchronous write: stdout is a pipe on the runner, and a buffered write process.exit cuts short would + // drop the tail of a large log and the annotation after it. + const tripped = [ + ...new Set( + log + .split("\n") + .filter((line) => TRIPWIRE_LINE.test(line)) + .map((line) => line.split("(")[0] ?? line), + ), + ].sort(); + const report = + tripped.length > 0 + ? [ + "::error::@octokit/types@latest fires upstream-gap tripwires; run bun .github/scripts/graduate-upstream-gaps.ts for these files:", + ...tripped, + ] + : [ + "::error::typecheck against @octokit/types@latest failed outside the upstream-gap tripwires; a types major may have broken the build (see log above)", + ]; + writeSync(1, `${log}${report.join("\n")}\n`); + process.exit(1); +} + +if (import.meta.main) { + const command = process.argv[2]; + if (command === "probe-schema") { + probeSchema(); + } else if (command === "probe-types") { + probeTypes(); + } else { + console.error( + `nightly-steps: unknown command ${JSON.stringify(command ?? null)}; expected probe-schema | probe-types`, + ); + process.exit(1); + } +} diff --git a/.github/scripts/release-pipeline.ts b/.github/scripts/release-pipeline.ts index 3efbf981c..f05d71857 100644 --- a/.github/scripts/release-pipeline.ts +++ b/.github/scripts/release-pipeline.ts @@ -21,6 +21,7 @@ * npm-verdict next update-release-pr.yml GITHUB_SHA, NPM_REGISTRY_URL (optional) * npm-confirm next update-release-pr.yml GITHUB_SHA, NPM_REGISTRY_URL (optional), NPM_CONFIRM_PAUSE_MS (optional) * npm-verdict stable update-release.yml TAG, GITHUB_SHA, NPM_REGISTRY_URL (optional) + * npm-floor both publish jobs (nothing; npm on PATH) * package, retag-major update-release.yml TAG, GITHUB_SHA, RUN_URL (optional, package only) * boundary-check, anchor-check checks.yml (the checkout alone) * prerelease-version by hand GITHUB_SHA (the version a commit's next publish carries) @@ -32,7 +33,7 @@ * Node builtins only: bun runs this before `bun install`. Tests: test/scripts/release-pipeline*.test.ts over release-pipeline-fixture.ts. */ -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; @@ -1242,6 +1243,46 @@ export async function npmConfirm(options: NpmConfirmOptions): Promise=${floor}`); +} + +/** An npm at or above `floor` on PATH: an older bundled npm is upgraded once (`npm install -g npm@latest`, on the + * step's log), then held to the floor; one still below it after that is refused. */ +export function npmFloor(floor = NPM_FLOOR): { version: string; upgraded: boolean } { + const read = (): string => + execFileSync("npm", ["--version"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }).trim(); + let version = read(); + let upgraded = false; + if (belowFloor(version, floor)) { + const install = spawnSync("npm", ["install", "-g", "npm@latest"], { stdio: "inherit" }); + if (install.status !== 0) { + const how = + install.status === null ? `died of ${install.signal}` : `exited ${install.status}`; + const detail = install.error === undefined ? "" : ` (${install.error.message})`; + throw new Error( + `npm ${version} is below ${floor} and npm install -g npm@latest ${how}${detail}; the publish needs npm ${floor} or newer.`, + ); + } + version = read(); + upgraded = true; + } + if (belowFloor(version, floor)) { + throw new Error( + `npm ${version} cannot publish through OIDC; trusted publishing needs npm ${floor} or newer.`, + ); + } + return { version, upgraded }; +} + const DEFAULT_REGISTRY = "https://registry.npmjs.org"; /** 15 reads 20 s apart (up to 280 s): three of the first five publishes were still unreadable after 80 s. */ const CONFIRM_READS = 15; @@ -1336,6 +1377,13 @@ async function main(): Promise { console.log(verdict.publish ? `publish ${verdict.version}` : `skip ${verdict.reason}`); break; } + case "npm-floor": { + const result = npmFloor(); + console.error( + `npm ${result.version} is at or above ${NPM_FLOOR}${result.upgraded ? " after an upgrade" : ""}; it publishes through OIDC`, + ); + break; + } case "npm-confirm": { if (argument !== "next") { throw new Error( @@ -1358,7 +1406,7 @@ async function main(): Promise { } default: throw new Error( - `unknown command ${JSON.stringify(command ?? null)}; expected package | retag-major | anchor | boundary-check | anchor-check | package-commit | prerelease-version | npm-verdict | npm-confirm`, + `unknown command ${JSON.stringify(command ?? null)}; expected package | retag-major | anchor | boundary-check | anchor-check | package-commit | prerelease-version | npm-verdict | npm-floor | npm-confirm`, ); } } diff --git a/.github/workflows/auto-fix.yml b/.github/workflows/auto-fix.yml index 4ae72d1dd..127a99de7 100644 --- a/.github/workflows/auto-fix.yml +++ b/.github/workflows/auto-fix.yml @@ -8,9 +8,11 @@ # # Regenerating the generated files executes PR-controlled code, so the fix is computed in an unprivileged job and crosses to # the push job as a git patch over the fix-owned paths only; applying a patch executes nothing, and the push job -# re-checks the applied paths before committing. +# re-checks the applied paths before committing. Both steps' logic is .github/scripts/auto-fix-steps.ts. # --ignore-scripts on the build job's install -> lefthook and dependency lifecycle scripts never run -# no install at all on the push job -> no git hooks exist where the write token is +# no install at all on the push job -> no git hooks exist where the write token is; bun alone is set up +# the push script from the default branch -> the job executes nothing the PR branch holds: the PR authors the +# patch alone, and the fix-owned list the job enforces is main's # # Dependabot-triggered runs read the Dependabot secrets store, not the Actions one, so REPO_PLATFORM_TOKEN goes in # both. A push made with github.token starts no workflows. @@ -82,38 +84,7 @@ jobs: - name: Regenerate the gaps index, docs, and inputs table and stage the fix patch id: rebuild shell: bash - run: | - # One line per generator, in .github/scripts/generated.ts table order: the later generators import the - # gaps index through src/, and the inputs table reads action.yml, so each source renders first. - # test/scripts/auto-fix-allowlist.test.ts pins the list and the order. - bun run build:gaps-index - bun run build:docs - bun run build:action-docs - bun run build:inputs-table - # Anything the earlier steps left staged is not this workflow's fix: start from an empty index so the - # patch holds exactly the allowed paths. - git reset -q - git add -A -- README.md action.yml \ - docs/reference/coverage.md docs/reference/undeclared-policy.md docs/reference/permissions.md \ - docs/operate/check-mode.md docs/reference/sections.md docs/reference/inputs.md \ - docs/reference/architecture.md docs/start/getting-started.md src/upstream-gaps/ - git diff --cached --binary > "$RUNNER_TEMP/autofix.patch" - if [ ! -s "$RUNNER_TEMP/autofix.patch" ]; then - echo "docs and upstream gaps already fresh" - echo "changed=false" >> "$GITHUB_OUTPUT" - echo "pruned=false" >> "$GITHUB_OUTPUT" - else - git diff --cached --stat - echo "changed=true" >> "$GITHUB_OUTPUT" - # A patch touching a gap file means gap files graduated (the index alone is a regeneration); the push - # job picks the commit subject from this. --quiet exits 1 on a staged change there and, unlike piping - # --name-only into grep -q, cannot trip pipefail on an early-exiting reader. - if git diff --cached --quiet -- src/upstream-gaps/ ':(exclude)src/upstream-gaps/index.ts'; then - echo "pruned=false" >> "$GITHUB_OUTPUT" - else - echo "pruned=true" >> "$GITHUB_OUTPUT" - fi - fi + run: bun .github/scripts/auto-fix-steps.ts rebuild - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: steps.rebuild.outputs.changed == 'true' with: @@ -130,15 +101,28 @@ jobs: contents: write pull-requests: write # the sticky no-retrigger comment below steps: + # The script that holds the write token comes from the default branch, which a PR cannot edit; the PR branch + # is checked out beside it, as the tree the patch applies to. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + path: trusted + persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.pull_request.head.ref }} + path: branch persist-credentials: false - # The patch lands OUTSIDE the checkout so nothing in the work tree can shadow or stage it. + # The patch lands OUTSIDE both checkouts so nothing in a work tree can shadow or stage it. - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: autofix-patch path: ${{ runner.temp }}/autofix + # bun alone, no install: no git hook or lifecycle script exists where the write token is. It starts in the + # trusted checkout, so the bunfig.toml and .env it reads at startup are main's, never the PR branch's. + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: trusted/.bun-version - name: Commit and push the fix id: push env: @@ -147,73 +131,10 @@ jobs: HEAD_REF: ${{ github.event.pull_request.head.ref }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} PRUNED: ${{ needs.build.outputs.pruned }} + PR_CHECKOUT: ${{ github.workspace }}/branch shell: bash - run: | - # The branch can move between the build and this push (a Dependabot rebase): the patch belongs to - # HEAD_SHA only, and the newer head's own run owns the rebuild. - superseded() { - git fetch --quiet origin "$HEAD_REF" - [ "$(git rev-parse "origin/$HEAD_REF")" != "$HEAD_SHA" ] - } - if [ "$(git rev-parse HEAD)" != "$HEAD_SHA" ]; then - echo "::notice::head moved since the build; skipping the stale fix push" - exit 0 - fi - git apply --index --binary "$RUNNER_TEMP/autofix/autofix.patch" - # The patch was cut on a runner PR code ran on, so it is untrusted: nothing staged outside the fix-owned - # paths may be committed. --no-renames lists a staged rename as its delete and add halves, so a rename OUT - # of a protected path cannot hide behind an allowed destination. - while IFS= read -r -d '' path; do - case "$path" in - README.md | action.yml | \ - docs/reference/coverage.md | docs/reference/undeclared-policy.md | docs/reference/permissions.md | \ - docs/operate/check-mode.md | docs/reference/sections.md | docs/reference/inputs.md | \ - docs/reference/architecture.md | docs/start/getting-started.md | src/upstream-gaps/*) ;; - *) - echo "::error::the fix patch staged '$path', outside the generated docs" \ - "(README.md, action.yml, docs/reference/coverage.md, docs/reference/undeclared-policy.md," \ - "docs/reference/permissions.md, docs/operate/check-mode.md, docs/reference/sections.md," \ - "docs/reference/inputs.md, docs/reference/architecture.md, docs/start/getting-started.md)," \ - "and src/upstream-gaps/; refusing to push" - exit 1 - ;; - esac - done < <(git diff --cached --no-renames --name-only -z) - if git diff --cached --quiet; then - echo "the fix patch stages nothing against the head commit; nothing to push" - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - subject="build: regenerate generated files" - if git diff --cached --quiet -- src/upstream-gaps/; then - subject="docs: regenerate generated docs" - fi - if [ "$PRUNED" = "true" ]; then - subject="fix: graduate upstream gaps octokit now ships" - fi - # [dependabot skip] keeps Dependabot rebasing and updating the PR over this commit; without it Dependabot - # stops touching a PR once someone else pushes to it. - git commit --no-verify -m "$subject" -m "[dependabot skip]" - # The lease pins the remote to the exact commit the fix was built from, so a concurrent push (even a - # force-reset to an ancestor) rejects this one instead of racing it. - if ! git push --force-with-lease="refs/heads/${HEAD_REF}:${HEAD_SHA}" \ - "https://x-access-token:${TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ - HEAD:"refs/heads/${HEAD_REF}"; then - if superseded; then - echo "::notice::head moved during the push; skipping the stale fix push" - exit 0 - fi - exit 1 - fi - if [ "$CAN_RETRIGGER" = "true" ]; then - exit 0 - fi - # The fix landed via github.token, which starts no workflows, so the PR's checks will not re-run on the new - # head: surface that here and, in the step below, on the PR. - echo "notify=true" >> "$GITHUB_OUTPUT" - echo "::warning::auto-fix pushed without REPO_PLATFORM_TOKEN - checks will not re-run on the new head;" \ - "close/reopen the PR or register the token" + working-directory: trusted + run: bun .github/scripts/auto-fix-steps.ts push # One sticky comment per PR (the header is its identity), updated in place on later pushes; a failed post fails # this step, not the push. - name: Tell the PR its checks will not re-run diff --git a/.github/workflows/auto-format.yml b/.github/workflows/auto-format.yml index f3cb58576..c9c1bdcd8 100644 --- a/.github/workflows/auto-format.yml +++ b/.github/workflows/auto-format.yml @@ -74,37 +74,41 @@ jobs: contents: write pull-requests: write steps: + # The script that holds the write token comes from the default branch, which a PR cannot edit; the PR branch + # is checked out beside it, as the tree the patch applies to. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: needs.format.outputs.changed == 'true' + with: + ref: ${{ github.event.repository.default_branch }} + path: trusted + persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 if: needs.format.outputs.changed == 'true' with: ref: ${{ github.event.pull_request.head.ref }} + path: branch persist-credentials: false - # The patch lands OUTSIDE the checkout so nothing in the work tree can shadow or stage it. + # The patch lands OUTSIDE both checkouts so nothing in a work tree can shadow or stage it. - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 if: needs.format.outputs.changed == 'true' with: name: format-patch path: ${{ runner.temp }}/format + # bun alone, no install: no git hook or lifecycle script exists where the write token is. It starts in the + # trusted checkout, so the bunfig.toml and .env it reads at startup are main's, never the PR branch's. + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + if: needs.format.outputs.changed == 'true' + with: + bun-version-file: trusted/.bun-version - name: Commit and push the formatting if: needs.format.outputs.changed == 'true' env: GH_TOKEN: ${{ github.token }} HEAD_REF: ${{ github.event.pull_request.head.ref }} HEAD_SHA: ${{ needs.format.outputs.head }} - run: | - # The branch can move between the two jobs; the patch was cut on HEAD_SHA and formats that tree alone, so a - # newer head gets its own run (re-apply the label) instead of a commit formatting the old one. - if [ "$(git rev-parse HEAD)" != "$HEAD_SHA" ]; then - echo "::notice::head moved since the format; skipping the stale formatting push" - exit 0 - fi - git apply --index --binary "$RUNNER_TEMP/format/format.patch" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git commit --no-verify -m "style: apply automated formatting" - # The lease pins the remote to the commit the patch was cut on, so a push that lands in between rejects this one. - git push --force-with-lease="refs/heads/${HEAD_REF}:${HEAD_SHA}" \ - "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:"refs/heads/${HEAD_REF}" + PR_CHECKOUT: ${{ github.workspace }}/branch + working-directory: trusted + run: bun .github/scripts/auto-format-steps.ts push - name: Remove the fix-lint label if: always() env: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 48bfe0bb6..d4398c3ff 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -40,51 +40,16 @@ jobs: # @octokit/graphql-schema, here against each package's latest release: # a used path removed, an upstream gap now documented, or a selected # field retired fails the night before Dependabot's bump PR does. - # --no-save leaves package.json and the lockfile untouched; the upgraded - # node_modules stays ambient for the rest of this job. + # Both probes are .github/scripts/nightly-steps.ts. - name: Probe the latest OpenAPI descriptor and GraphQL schema - run: | - bun add --no-save --ignore-scripts @octokit/openapi@latest @octokit/graphql-schema@latest - # A pinned version left in place would make this probe pass green - # forever without probing anything. - for pkg in @octokit/openapi @octokit/graphql-schema; do - installed="$(bun -e "console.log(require('$pkg/package.json').version)")" - latest="$(bun info "$pkg" version)" - if [ "$installed" != "$latest" ]; then - echo "::error::$pkg@$installed is installed but $latest is the latest; the probe is testing the pin" - exit 1 - fi - echo "$pkg@$installed" - done - bun test test/e2e/openapi/validate.test.ts test/sections/graphql-queries.test.ts + run: bun .github/scripts/nightly-steps.ts probe-schema # always() so a red schema probe cannot mask this one (the job still # fails if either step failed); gated on the setup step because there # is nothing to probe when setup itself broke. - name: Probe the latest @octokit/types if: ${{ !cancelled() && steps.setup.outcome == 'success' }} - run: | - bun add --no-save --ignore-scripts @octokit/types@latest - log="$(mktemp)" - # --pretty false keeps the one-error-per-line format the grep below - # parses, independent of tsc's TTY detection. - if bun run typecheck --pretty false >"$log" 2>&1; then - echo "typecheck is clean against @octokit/types@latest" - else - cat "$log" - # `|| true`: inert under the default shell (the pipeline ends - # in sort -u), but guards the general-breakage branch below if - # this step ever declares shell: bash (which adds pipefail). - tripped="$(grep -E '^src/upstream-gaps/.+error TS2344' "$log" | cut -d'(' -f1 | sort -u || true)" - if [ -n "$tripped" ]; then - echo "::error::@octokit/types@latest fires upstream-gap tripwires; run bun .github/scripts/graduate-upstream-gaps.ts for these files:" - echo "$tripped" - else - echo "::error::typecheck against @octokit/types@latest failed outside the upstream-gap tripwires;" \ - "a types major may have broken the build (see log above)" - fi - exit 1 - fi + run: bun .github/scripts/nightly-steps.ts probe-types # The only from-scratch resolve in this repository: package.json is pinned # exactly (the package-pins test guards that), so only transitive ranges diff --git a/.github/workflows/update-release-pr.yml b/.github/workflows/update-release-pr.yml index cc4f456fc..74ced9749 100644 --- a/.github/workflows/update-release-pr.yml +++ b/.github/workflows/update-release-pr.yml @@ -93,16 +93,7 @@ jobs: # older bundled npm is upgraded once, then held to the floor. - name: Require an npm that publishes through OIDC if: steps.oidc.outputs.proceed == 'true' - run: | - floor=11.5.1 - below_floor() { [ "$(printf '%s\n' "$floor" "$(npm --version)" | sort -V | head -n1)" != "$floor" ]; } - if below_floor; then - npm install -g npm@latest - fi - if below_floor; then - echo "::error::npm $(npm --version) cannot publish through OIDC; trusted publishing needs npm $floor or newer." - exit 1 - fi + run: bun .github/scripts/release-pipeline.ts npm-floor # The tarball's files list names the schema beside lib/pkg/. - name: Build the library if: steps.oidc.outputs.proceed == 'true' diff --git a/.github/workflows/update-release.yml b/.github/workflows/update-release.yml index a96b3c49b..9a702ba2c 100644 --- a/.github/workflows/update-release.yml +++ b/.github/workflows/update-release.yml @@ -148,16 +148,7 @@ jobs: # Trusted publishing (and its provenance) exists from npm 11.5.1 on; an # older bundled npm is upgraded once, then held to the floor. - name: Require an npm that publishes through OIDC - run: | - floor=11.5.1 - below_floor() { [ "$(printf '%s\n' "$floor" "$(npm --version)" | sort -V | head -n1)" != "$floor" ]; } - if below_floor; then - npm install -g npm@latest - fi - if below_floor; then - echo "::error::npm $(npm --version) cannot publish through OIDC; trusted publishing needs npm $floor or newer." - exit 1 - fi + run: bun .github/scripts/release-pipeline.ts npm-floor # The tarball's files list names the schema beside lib/pkg/. - name: Build the library run: bun run build:schema && bun run build:lib diff --git a/test/scripts/auto-fix-allowlist.test.ts b/test/scripts/auto-fix-allowlist.test.ts deleted file mode 100644 index ae1e6604c..000000000 --- a/test/scripts/auto-fix-allowlist.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { parse } from "yaml"; -import { - generatedPaths, - generatorEntryPoint, - generatorScripts, -} from "../../.github/scripts/generated.js"; -import { ROOT } from "../root.js"; - -/** - * auto-fix.yml enumerates the generated outputs by hand four times (the trigger paths, the build job's `git add`, - * the push job's `case` allowlist and its error message) and runs the generators by hand once. Each list is a - * security boundary, so it stays literal in the workflow; this test pins every list to GENERATED_OUTPUTS, the one - * table the generators derive, so a newly registered output cannot slip past the workflow. - * - * The lists are read with regexes over the two `run:` scripts, `#` lines stripped, as the workflow writes them - * today: one `git add -A --` command whose paths continue over `\`-newlines, and one `case "$path" in` with one - * accepting arm before `*)`. A parse yields an empty list unless its shape occurs exactly once among the live - * commands, so a second staging command or accepting arm, a commented-out command, or a rewrite of either shape - * fails the "parser sees both allowlists" test instead of passing on the copy the regex happened to find. - */ - -const WORKFLOW_PATH = ".github/workflows/auto-fix.yml"; -const workflow = parse(readFileSync(join(ROOT, WORKFLOW_PATH), "utf8")) as { - on: { pull_request: { paths: string[] } }; - jobs: Record<"build" | "push", { steps: { id?: string; run?: string }[] }>; -}; -const scripts = ( - JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as { - scripts: Record; - } -).scripts; - -const triggerPaths = workflow.on.pull_request.paths; -const rebuildRun = workflow.jobs.build.steps.find((step) => step.id === "rebuild")?.run ?? ""; -const pushRun = workflow.jobs.push.steps.find((step) => step.id === "push")?.run ?? ""; - -/** Splits a shell word list that may continue over `\`-newlines. */ -function words(text: string): string[] { - return text - .replace(/\\\n/g, " ") - .split(/\s+/) - .filter((word) => word !== ""); -} - -/** A `git add` inside a `#` comment is not a staging command. */ -function live(script: string): string { - return script - .split("\n") - .filter((line) => !/^\s*#/.test(line)) - .join("\n"); -} - -/** A second `git add`, however spaced, would stage around the allowlist, so it empties the list. */ -function stagedPaths(script: string): string[] { - const commands = live(script); - if ((commands.match(/\bgit\s+add\b/g) ?? []).length !== 1) return []; - return words(/git add -A -- ((?:[^\n\\]|\\\n)+)/.exec(commands)?.[1] ?? ""); -} - -/** The patterns of the push script's one `case` statement's one accepting arm before `*)`. A second arm there, a - * second `case` anywhere (nested inside the default arm, it would accept before the error), or any `git add` (the - * patch is the push job's only route into the index) empties the list. */ -function admittedPatterns(script: string): string[] { - const commands = live(script); - if (/\bgit\s+add\b/.test(commands) || (commands.match(/\bcase\s/g) ?? []).length !== 1) return []; - const arms = /case "\$path" in\n([\s\S]*?)^\s*\*\)/m.exec(commands)?.[1] ?? ""; - if ((arms.match(/;;/g) ?? []).length !== 1) return []; - return words(arms.replace(/\)\s*;;\s*$/, "")).filter((word) => word !== "|"); -} - -const gitAddPaths = stagedPaths(rebuildRun); -const casePatterns = admittedPatterns(pushRun); -const errorMessage = /::error::([\s\S]*?)refusing to push/.exec(pushRun)?.[1] ?? ""; - -/** `dir/` stages everything under dir, deletions included. A `dir/*` glob would not: the shell expands it to the - * files that still exist before git runs, so a gap file the graduation deleted would stay out of the patch. */ -function staged(entry: string, path: string): boolean { - return entry.endsWith("/") ? path.startsWith(entry) : entry === path; -} - -/** `dir/*` admits everything under dir: an unquoted case pattern's `*` matches `/` too. */ -function admitted(pattern: string, path: string): boolean { - return pattern.endsWith("/*") ? path.startsWith(pattern.slice(0, -1)) : pattern === path; -} - -const paths = generatedPaths(); -const generators = generatorScripts(); -/** Each generator script with the repository file it runs; null for a shape the census cannot read. */ -const generatorFiles = generators.map( - (name) => [name, generatorEntryPoint(scripts[name] ?? "")] as const, -); - -describe("auto-fix.yml tracks the generated-output table", () => { - test("the parser sees both allowlists, and they name the same paths", () => { - expect(gitAddPaths.length).toBeGreaterThan(0); - expect(casePatterns.length).toBeGreaterThan(0); - expect(gitAddPaths.map((entry) => (entry.endsWith("/") ? `${entry}*` : entry)).sort()).toEqual( - [...casePatterns].sort(), - ); - }); - - // Each mutation leaves the original lines in place, so a parser taking the first regex match still returns the - // original list: the extra command or arm sits behind the match, and a commented-out command matches inside it. - test.each([ - [ - "a second git add in the rebuild step", - () => stagedPaths(`${rebuildRun}git add -A -- package.json\n`), - ], - [ - "a second git add spaced past the word boundary", - () => stagedPaths(`${rebuildRun}git add -A -- package.json\n`), - ], - [ - "the git add commented out", - () => stagedPaths(rebuildRun.replace(/^(\s*)(git add -A --)/m, "$1# $2")), - ], - [ - "a second accepting arm before *)", - () => admittedPatterns(pushRun.replace(/^(\s*)\*\)/m, "$1package.json) ;;\n$1*)")), - ], - [ - "a git add in the push script before its commit", - () => - admittedPatterns( - pushRun.replace(/^(\s*)git commit /m, "$1git add -A -- package.json\n$1git commit "), - ), - ], - [ - "a nested case accepting inside the default arm", - () => - admittedPatterns( - pushRun.replace( - /^(\s*)\*\)\n/m, - '$1*)\n$1 case "$path" in package.json) continue ;; esac\n', - ), - ), - ], - ])("%s empties the parsed list", (_, mutated) => { - expect(mutated()).toEqual([]); - }); - - test("every generated output is staged by the build job and admitted by the push job", () => { - for (const path of paths) { - expect( - gitAddPaths.some((entry) => staged(entry, path)), - `git add: ${path}`, - ).toBe(true); - expect( - casePatterns.some((pattern) => admitted(pattern, path)), - `case: ${path}`, - ).toBe(true); - } - }); - - test("every allowlist entry covers a generated output, and the error message names each entry", () => { - for (const pattern of casePatterns) { - expect( - paths.some((path) => admitted(pattern, path)), - pattern, - ).toBe(true); - // A `dir/*` arm reads as `dir/` in the message. - expect(errorMessage, pattern).toContain(pattern.replace(/\*$/, "")); - } - }); - - test("a hand edit to a generated output or its generator triggers the fix", () => { - for (const [name, file] of generatorFiles) { - expect(file, `${name} is a package.json script of the shape bun .ts`).not.toBeNull(); - } - for (const path of [...paths, ...generatorFiles.map(([, file]) => file ?? "")]) { - expect( - triggerPaths.some((pattern) => new Bun.Glob(pattern).match(path)), - `on.paths: ${path}`, - ).toBe(true); - } - }); - - test("the rebuild step runs exactly the generators, in table order", () => { - // The graduation step regenerates the gaps index only when a gap graduates, so the index generator runs here - // too. Every generator is a package.json script, so a bare `bun ` line here is a generator the table - // does not know and fails the comparison. - const run = [...rebuildRun.matchAll(/^\s*bun (\S+)(?: (\S+))?$/gm)].map(([, word, name]) => - word === "run" ? name : `${word}${name === undefined ? "" : ` ${name}`}`, - ); - expect(run).toEqual(generators); - }); -}); diff --git a/test/scripts/auto-fix-steps.test.ts b/test/scripts/auto-fix-steps.test.ts new file mode 100644 index 000000000..e4da2dc06 --- /dev/null +++ b/test/scripts/auto-fix-steps.test.ts @@ -0,0 +1,359 @@ +/** + * auto-fix.yml's two steps against local fixture repositories: the fix-owned paths pinned to the generated-output + * table (a security boundary the script keeps literal, so a newly registered output would otherwise slip past it), + * and the branches each step takes that the workflow text cannot show: what the patch holds, which commit subject + * a push gets, and which push is refused, skipped, or warned about. + */ + +import { describe, expect, setDefaultTimeout, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parse } from "yaml"; +import { FIX_OWNED_PATHS, fixOwned, GENERATORS } from "../../.github/scripts/auto-fix-steps.js"; +import { + generatedPaths, + generatorEntryPoint, + generatorScripts, +} from "../../.github/scripts/generated.js"; +import { ROOT } from "../root.js"; +import { + clone, + commitAll, + type Fixture, + git, + installReleasePipelineFixture, + seedFixture, + withPushPlans, + write, +} from "./release-pipeline-fixture.js"; +import { runStep } from "./step-fixture.js"; + +setDefaultTimeout(60_000); +installReleasePipelineFixture(); + +const workflow = parse(readFileSync(join(ROOT, ".github/workflows/auto-fix.yml"), "utf8")) as { + on: { pull_request: { paths: string[] } }; +}; +const packageScripts = ( + JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as { + scripts: Record; + } +).scripts; + +describe("the fix-owned paths and generators track the generated-output table", () => { + test("every generated output is fix-owned, every fix-owned entry covers a generated output, and the generators run in table order", () => { + const paths = generatedPaths(); + for (const path of paths) { + expect(fixOwned(path), path).toBe(true); + } + for (const entry of FIX_OWNED_PATHS) { + expect( + paths.some((path) => (entry.endsWith("/") ? path.startsWith(entry) : path === entry)), + entry, + ).toBe(true); + } + expect(generatorScripts()).toEqual([...GENERATORS]); + }); + + test("a hand edit to a generated output or its generator triggers the fix", () => { + const files = generatorScripts().map((name) => generatorEntryPoint(packageScripts[name] ?? "")); + for (const file of files) { + expect(file).not.toBeNull(); + } + for (const path of [...generatedPaths(), ...files.map((file) => file ?? "")]) { + expect( + workflow.on.pull_request.paths.some((pattern) => new Bun.Glob(pattern).match(path)), + `on.paths: ${path}`, + ).toBe(true); + } + }); +}); + +const GENERATOR_ENV: Record<(typeof GENERATORS)[number], string> = { + "build:gaps-index": "GEN_GAPS_INDEX", + "build:docs": "GEN_DOCS", + "build:action-docs": "GEN_ACTION_DOCS", + "build:inputs-table": "GEN_INPUTS_TABLE", +}; +const GAP_FILE = "src/upstream-gaps/one.ts"; +const GAPS_INDEX = "src/upstream-gaps/index.ts"; +const REMOTE_URL = "https://x-access-token:t0ken@github.com/o/r.git"; + +/** A same-repo PR branch as both jobs check it out: every fix-owned path tracked, one gap file beside the index, + * and a package.json whose generator scripts run whatever the matching GEN_* variable says. */ +function prFixture(): { fx: Fixture; pr: string; headSha: string; temp: string } { + const fx = seedFixture(); + const pr = clone(fx.root, fx.origin, "pr"); + for (const path of FIX_OWNED_PATHS) { + if (!path.endsWith("/")) { + write(pr, path, `${path}\n`); + } + } + write(pr, GAP_FILE, "export const one = 1;\n"); + write(pr, GAPS_INDEX, "export * from './one.js';\n"); + write( + pr, + "package.json", + `${JSON.stringify( + { + name: "fixture", + version: "0.0.0", + scripts: Object.fromEntries( + GENERATORS.map((name) => [name, `sh -c "$${GENERATOR_ENV[name]}"`]), + ), + }, + null, + 2, + )}\n`, + ); + const headSha = commitAll(pr, "feat: a pull request"); + git(pr, "push", "--quiet", "origin", "HEAD:refs/heads/pr"); + git(pr, "checkout", "--quiet", "-b", "pr"); + // The lease push names github.com; the fixture origin answers in its place. + git(pr, "config", `url.${fx.origin}.insteadOf`, REMOTE_URL); + return { fx, pr, headSha, temp: join(fx.root, "runner-temp") }; +} + +const rebuild = (pr: string, temp: string, env: Record) => + runStep("auto-fix-steps", pr, temp, env, "rebuild"); + +function patchPaths(temp: string): string[] { + return [...readFileSync(join(temp, "autofix.patch"), "utf8").matchAll(/^diff --git a\/(\S+)/gm)] + .map((match) => match[1] ?? "") + .sort(); +} + +describe("rebuild", () => { + test("generators that change nothing leave an empty patch and changed=false, and an index entry the earlier steps left staged is not the fix", () => { + const { pr, temp } = prFixture(); + write(pr, "package.json", `${readFileSync(join(pr, "package.json"), "utf8")}\n`); + git(pr, "add", "package.json"); + const result = rebuild(pr, temp, {}); + expect(result.status).toBe(0); + expect(result.stdout).toBe("docs and upstream gaps already fresh\n"); + expect(result.outputs).toEqual(["changed=false", "pruned=false"]); + expect(readFileSync(join(temp, "autofix.patch"), "utf8")).toBe(""); + expect(git(pr, "diff", "--cached", "--name-only")).toBe(""); + }); + + test("the generators run in table order and only the fix-owned paths enter the patch; a docs change alone is not a prune", () => { + const { pr, temp } = prFixture(); + const result = rebuild( + pr, + temp, + Object.fromEntries( + GENERATORS.map((name) => [ + GENERATOR_ENV[name], + `echo ${name} >> README.md && echo ${name} >> package.json`, + ]), + ), + ); + expect(result.status).toBe(0); + expect(result.outputs).toEqual(["changed=true", "pruned=false"]); + expect(result.stdout).toContain("1 file changed, 4 insertions(+)"); + expect(patchPaths(temp)).toEqual(["README.md"]); + expect( + git(pr, "diff", "--cached", "README.md") + .split("\n") + .filter((line) => /^\+[^+]/.test(line)), + ).toEqual(GENERATORS.map((name) => `+${name}`)); + }); + + test("a graduated gap file (a deletion under src/upstream-gaps/) is a prune; the index alone regenerated is not", () => { + const { pr, temp } = prFixture(); + const graduated = rebuild(pr, temp, { + GEN_GAPS_INDEX: `rm ${GAP_FILE} && echo 'export {};' > ${GAPS_INDEX}`, + }); + expect(graduated.status).toBe(0); + expect(graduated.outputs).toEqual(["changed=true", "pruned=true"]); + expect(patchPaths(temp)).toEqual([GAPS_INDEX, GAP_FILE].sort()); + git(pr, "reset", "--hard", "--quiet"); + const regenerated = rebuild(pr, temp, { + GEN_GAPS_INDEX: `echo '// regenerated' >> ${GAPS_INDEX}`, + }); + expect(regenerated.status).toBe(0); + expect(regenerated.outputs).toEqual(["changed=true", "pruned=false"]); + expect(patchPaths(temp)).toEqual([GAPS_INDEX]); + }); + + test("a failing generator ends the step with its status before the later generators run or any output is written", () => { + const { pr, temp } = prFixture(); + const result = rebuild(pr, temp, { + GEN_DOCS: "exit 3", + GEN_ACTION_DOCS: "echo ran >> README.md", + }); + expect(result.status).toBe(3); + expect(result.outputs).toEqual([]); + expect(readFileSync(join(pr, "README.md"), "utf8")).toBe("README.md\n"); + }); +}); + +/** The patch the build job would upload for `mutate`'s changes to the PR branch's tree, at the path the push job + * downloads it to. */ +function cutPatch(pr: string, temp: string, mutate: () => void): void { + mutate(); + git(pr, "add", "-A"); + // Untrimmed: the fixture's git() drops the final newline, which corrupts a patch. + write( + join(temp, "autofix"), + "autofix.patch", + execFileSync("git", ["diff", "--cached", "--binary"], { cwd: pr, encoding: "utf8" }), + ); + git(pr, "reset", "--hard", "--quiet"); +} + +const PUSH_ENV = { + TOKEN: "t0ken", + CAN_RETRIGGER: "true", + HEAD_REF: "pr", + PRUNED: "false", + GITHUB_REPOSITORY: "o/r", +}; + +/** Run from outside the PR clone, as the push job runs it from the trusted checkout. */ +const push = (pr: string, temp: string, env: Record) => + runStep("auto-fix-steps", temp, temp, { ...PUSH_ENV, PR_CHECKOUT: pr, ...env }, "push"); + +const originTip = (fx: Fixture): string => git(fx.origin, "rev-parse", "refs/heads/pr"); +const messageOf = (fx: Fixture, sha: string): string => + git(fx.origin, "log", "-1", "--format=%B", sha); +const authorOf = (fx: Fixture, sha: string): string => + git(fx.origin, "log", "-1", "--format=%an <%ae>", sha); + +describe("push", () => { + test("a checkout that is not the build's head pushes nothing, with a notice", () => { + const { fx, pr, headSha, temp } = prFixture(); + cutPatch(pr, temp, () => write(pr, "README.md", "fresh\n")); + const result = push(pr, temp, { HEAD_SHA: fx.mergeSha }); + expect(result).toMatchObject({ + status: 0, + stdout: "::notice::head moved since the build; skipping the stale fix push\n", + outputs: [], + }); + expect(originTip(fx)).toBe(headSha); + expect(git(pr, "status", "--porcelain")).toBe(""); + }); + + test.each([ + [ + "a file outside the fix-owned paths", + (pr: string) => write(pr, "package.json", "{}\n"), + "package.json", + ], + [ + "a rename out of a protected path behind an allowed destination", + // Rename detection would list the destination alone, inside src/upstream-gaps/; --no-renames shows the + // deleted half. + (pr: string) => git(pr, "mv", "src/marker.ts", "src/upstream-gaps/marker.ts"), + "src/marker.ts", + ], + ])( + "a patch staging %s is refused naming it, and nothing is committed or pushed", + (_, mutate, named) => { + const { fx, pr, headSha, temp } = prFixture(); + cutPatch(pr, temp, () => mutate(pr)); + const result = push(pr, temp, { HEAD_SHA: headSha }); + expect(result.status).toBe(1); + expect(result.stdout).toBe( + [ + `::error::the fix patch staged '${named}', outside the generated docs (README.md, action.yml,`, + "docs/reference/coverage.md, docs/reference/undeclared-policy.md, docs/reference/permissions.md,", + "docs/operate/check-mode.md, docs/reference/sections.md, docs/reference/inputs.md,", + "docs/reference/architecture.md, docs/start/getting-started.md), and src/upstream-gaps/; refusing to push\n", + ].join(" "), + ); + expect(originTip(fx)).toBe(headSha); + expect(git(pr, "rev-parse", "HEAD")).toBe(headSha); + }, + ); + + test.each([ + [ + "docs alone", + () => ({ "docs/reference/inputs.md": "fresh\n" }), + "false", + "docs: regenerate generated docs", + ], + [ + "the gaps index", + () => ({ [GAPS_INDEX]: "export {};\n" }), + "false", + "build: regenerate generated files", + ], + [ + "a graduated gap file", + () => ({ [GAPS_INDEX]: "export {};\n", [GAP_FILE]: null }), + "true", + "fix: graduate upstream gaps octokit now ships", + ], + ])( + "a patch over %s lands on the PR branch as the bot under the matching subject with the Dependabot trailer", + (_, changes, pruned, subject) => { + const { fx, pr, headSha, temp } = prFixture(); + cutPatch(pr, temp, () => { + for (const [path, content] of Object.entries(changes())) { + if (content === null) { + git(pr, "rm", "--quiet", path); + } else { + write(pr, path, content); + } + } + }); + const result = push(pr, temp, { HEAD_SHA: headSha, PRUNED: pruned }); + expect(result).toMatchObject({ status: 0, outputs: [] }); + expect(result.stdout).not.toContain("::"); + const tip = originTip(fx); + expect(git(fx.origin, "rev-parse", `${tip}^`)).toBe(headSha); + expect(messageOf(fx, tip)).toBe(`${subject}\n\n[dependabot skip]`); + expect(authorOf(fx, tip)).toBe( + "github-actions[bot] ", + ); + expect(git(fx.origin, "diff", "--name-only", headSha, tip).split("\n").sort()).toEqual( + Object.keys(changes()).sort(), + ); + }, + ); + + test("a push with the default token lands and sets notify=true with a warning, since it starts no workflows", () => { + const { fx, pr, headSha, temp } = prFixture(); + cutPatch(pr, temp, () => write(pr, "README.md", "fresh\n")); + const result = push(pr, temp, { HEAD_SHA: headSha, CAN_RETRIGGER: "false" }); + expect(result).toMatchObject({ status: 0, outputs: ["notify=true"] }); + expect(result.stdout).toEndWith( + "::warning::auto-fix pushed without REPO_PLATFORM_TOKEN - checks will not re-run on the new head; close/reopen the PR or register the token\n", + ); + expect(git(fx.origin, "rev-parse", `${originTip(fx)}^`)).toBe(headSha); + }); + + test("a lease the branch outgrew during the push is a skipped stale fix when the head moved, and a failure when it did not", () => { + const moved = prFixture(); + cutPatch(moved.pr, moved.temp, () => write(moved.pr, "README.md", "fresh\n")); + const rival = clone(moved.fx.root, moved.fx.origin, "rival"); + git(rival, "checkout", "--quiet", "pr"); + write(rival, "src/marker.ts", "export const marker = 'rival';\n"); + const rivalSha = commitAll(rival, "feat: the rival push"); + git(rival, "push", "--quiet", "origin", "HEAD:refs/heads/pr"); + const skipped = push(moved.pr, moved.temp, { HEAD_SHA: moved.headSha }); + expect(skipped).toMatchObject({ status: 0, outputs: [] }); + expect(skipped.stdout).toEndWith( + "::notice::head moved during the push; skipping the stale fix push\n", + ); + expect(originTip(moved.fx)).toBe(rivalSha); + + const unmoved = prFixture(); + cutPatch(unmoved.pr, unmoved.temp, () => write(unmoved.pr, "README.md", "fresh\n")); + let failed = { status: -1, stdout: "", outputs: [""] }; + const pushes = withPushPlans( + unmoved.fx, + [{ fail: { stderr: "remote: Write access to repository not granted.\n", status: 128 } }], + () => { + failed = push(unmoved.pr, unmoved.temp, { HEAD_SHA: unmoved.headSha }); + }, + ); + expect(pushes).toHaveLength(1); + expect(failed).toMatchObject({ status: 1, outputs: [] }); + expect(failed.stdout).not.toContain("::"); + expect(originTip(unmoved.fx)).toBe(unmoved.headSha); + }); +}); diff --git a/test/scripts/auto-format-steps.test.ts b/test/scripts/auto-format-steps.test.ts new file mode 100644 index 000000000..800ce0d49 --- /dev/null +++ b/test/scripts/auto-format-steps.test.ts @@ -0,0 +1,103 @@ +/** + * auto-format.yml's push step against local fixture repositories: the branches the workflow text cannot show, + * which checkout the patch is committed on, under whose identity, and what a moved branch does to the push. + */ + +import { describe, expect, setDefaultTimeout, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { + clone, + commitAll, + type Fixture, + git, + installReleasePipelineFixture, + seedFixture, + write, +} from "./release-pipeline-fixture.js"; +import { runStep } from "./step-fixture.js"; + +setDefaultTimeout(60_000); +installReleasePipelineFixture(); + +const REMOTE_URL = "https://x-access-token:t0ken@github.com/o/r.git"; + +/** The push job's checkout of a same-repo PR branch, with the format job's patch downloaded beside it. */ +function prFixture(): { fx: Fixture; pr: string; headSha: string; temp: string } { + const fx = seedFixture(); + const pr = clone(fx.root, fx.origin, "pr"); + write(pr, "src/marker.ts", "export const marker=1\n"); + const headSha = commitAll(pr, "feat: a pull request"); + git(pr, "push", "--quiet", "origin", "HEAD:refs/heads/pr"); + git(pr, "checkout", "--quiet", "-b", "pr"); + git(pr, "config", `url.${fx.origin}.insteadOf`, REMOTE_URL); + const temp = join(fx.root, "runner-temp"); + write(pr, "src/marker.ts", "export const marker = 1;\n"); + git(pr, "add", "-u"); + write( + join(temp, "format"), + "format.patch", + execFileSync("git", ["diff", "--cached", "--binary"], { cwd: pr, encoding: "utf8" }), + ); + git(pr, "reset", "--hard", "--quiet"); + return { fx, pr, headSha, temp }; +} + +/** Run from outside the PR clone, as the push job runs it from the trusted checkout. */ +const push = (pr: string, temp: string, headSha: string) => + runStep( + "auto-format-steps", + temp, + temp, + { + GH_TOKEN: "t0ken", + HEAD_REF: "pr", + HEAD_SHA: headSha, + GITHUB_REPOSITORY: "o/r", + PR_CHECKOUT: pr, + }, + "push", + ); + +const originTip = (fx: Fixture): string => git(fx.origin, "rev-parse", "refs/heads/pr"); + +describe("push", () => { + test("the patch lands on the PR branch as the bot's formatting commit over the head it was cut on", () => { + const { fx, pr, headSha, temp } = prFixture(); + const result = push(pr, temp, headSha); + expect(result).toMatchObject({ status: 0, outputs: [] }); + expect(result.stdout).not.toContain("::"); + const tip = originTip(fx); + expect(git(fx.origin, "rev-parse", `${tip}^`)).toBe(headSha); + expect(git(fx.origin, "log", "-1", "--format=%B%n%an <%ae>", tip)).toBe( + "style: apply automated formatting\n\ngithub-actions[bot] ", + ); + expect(git(fx.origin, "show", `${tip}:src/marker.ts`)).toBe("export const marker = 1;"); + }); + + test("a checkout that is not the head the patch was cut on pushes nothing, with a notice", () => { + const { fx, pr, headSha, temp } = prFixture(); + const result = push(pr, temp, fx.mergeSha); + expect(result).toEqual({ + status: 0, + stdout: "::notice::head moved since the format; skipping the stale formatting push\n", + stderr: "", + outputs: [], + }); + expect(originTip(fx)).toBe(headSha); + expect(git(pr, "status", "--porcelain")).toBe(""); + }); + + test("a branch that moved after the patch was cut rejects the lease push, and the step fails with git's status", () => { + const { fx, pr, headSha, temp } = prFixture(); + const rival = clone(fx.root, fx.origin, "rival"); + git(rival, "checkout", "--quiet", "pr"); + write(rival, "src/other.ts", "export const other = 1;\n"); + const rivalSha = commitAll(rival, "feat: the rival push"); + git(rival, "push", "--quiet", "origin", "HEAD:refs/heads/pr"); + const result = push(pr, temp, headSha); + expect(result).toMatchObject({ status: 1, outputs: [] }); + expect(result.stderr).toContain("[rejected]"); + expect(originTip(fx)).toBe(rivalSha); + }); +}); diff --git a/test/scripts/nightly-steps.test.ts b/test/scripts/nightly-steps.test.ts new file mode 100644 index 000000000..ce5f8ff01 --- /dev/null +++ b/test/scripts/nightly-steps.test.ts @@ -0,0 +1,202 @@ +/** + * nightly.yml's probes with a scripted bun in place of the real one (an install from the registry has no place in + * a unit test): what each probe does with the versions it finds and with tsc's output, which the workflow text + * cannot show. + */ + +import { describe, expect, setDefaultTimeout, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { withTempDir } from "../temp-dir.js"; +import { runStep } from "./step-fixture.js"; + +setDefaultTimeout(60_000); + +interface Plan { + /** `bun add` fails with this status instead of installing. */ + addStatus?: number; + /** What `bun add` installs under node_modules, by package. */ + installed?: Record; + /** What `bun info version` prints, by package. */ + latest?: Record; + /** `bun run typecheck`'s stdout, stderr, and status. */ + typecheck?: { stdout: string; stderr?: string; status: number }; +} + +/** The scripted bun: every call logged, `add` writes the planned package.json files (or fails as planned), `info` + * answers the planned latest, `test` passes, and `run` replays the planned typecheck. */ +const SHIM = `#!/bin/sh +plan="$NIGHTLY_PLAN" +printf '%s\\n' "$*" >> "$plan/calls.log" +case "$1" in + add) + if [ -f "$plan/add.status" ]; then exit "$(cat "$plan/add.status")"; fi + for spec in "$@"; do + case "$spec" in + @*@latest) + name="\${spec%@latest}" + mkdir -p "node_modules/$name" + printf '{"name":"%s","version":"%s"}\\n' "$name" "$(cat "$plan/installed/$name")" > "node_modules/$name/package.json" ;; + esac + done ;; + info) cat "$plan/latest/$2" ;; + test) exit 0 ;; + run) + cat "$plan/typecheck.stdout" + cat "$plan/typecheck.stderr" >&2 + exit "$(cat "$plan/typecheck.status")" ;; +esac +`; + +function probe(dir: string, plan: Plan, step: "probe-schema" | "probe-types") { + const shimDir = join(dir, "shim"); + const planDir = join(dir, "plan"); + const checkout = join(dir, "checkout"); + for (const sub of [shimDir, planDir, checkout]) { + mkdirSync(sub, { recursive: true }); + } + writeFileSync(join(shimDir, "bun"), SHIM, { mode: 0o755 }); + if (plan.addStatus !== undefined) { + writeFileSync(join(planDir, "add.status"), `${plan.addStatus}\n`); + } + for (const [kind, versions] of [ + ["installed", plan.installed ?? {}], + ["latest", plan.latest ?? {}], + ] as const) { + for (const [name, version] of Object.entries(versions)) { + mkdirSync(dirname(join(planDir, kind, name)), { recursive: true }); + writeFileSync(join(planDir, kind, name), `${version}\n`); + } + } + if (plan.typecheck !== undefined) { + writeFileSync(join(planDir, "typecheck.stdout"), plan.typecheck.stdout); + writeFileSync(join(planDir, "typecheck.stderr"), plan.typecheck.stderr ?? ""); + writeFileSync(join(planDir, "typecheck.status"), `${plan.typecheck.status}\n`); + } + const result = runStep( + "nightly-steps", + checkout, + join(dir, "runner-temp"), + { PATH: `${shimDir}:${process.env.PATH ?? ""}`, NIGHTLY_PLAN: planDir }, + step, + ); + const log = join(planDir, "calls.log"); + const calls = existsSync(log) ? readFileSync(log, "utf8").split("\n").filter(Boolean) : []; + return { ...result, calls }; +} + +const SCHEMA_ADD = + "add --no-save --ignore-scripts @octokit/openapi@latest @octokit/graphql-schema@latest"; +const LOCKSTEP_TESTS = + "test test/e2e/openapi/validate.test.ts test/sections/graphql-queries.test.ts"; + +describe("probe-schema", () => { + test("both packages at their latest: each is reported and the lockstep tests run", () => + withTempDir("nightly-steps-", (dir) => { + const result = probe( + dir, + { + installed: { "@octokit/openapi": "20.1.0", "@octokit/graphql-schema": "15.26.0" }, + latest: { "@octokit/openapi": "20.1.0", "@octokit/graphql-schema": "15.26.0" }, + }, + "probe-schema", + ); + expect(result).toMatchObject({ + status: 0, + stdout: "@octokit/openapi@20.1.0\n@octokit/graphql-schema@15.26.0\n", + calls: [ + SCHEMA_ADD, + "info @octokit/openapi version", + "info @octokit/graphql-schema version", + LOCKSTEP_TESTS, + ], + }); + })); + + test("a package the install left behind its latest fails the probe naming both versions, before the tests run", () => + withTempDir("nightly-steps-", (dir) => { + const result = probe( + dir, + { + installed: { "@octokit/openapi": "20.1.0", "@octokit/graphql-schema": "15.25.0" }, + latest: { "@octokit/openapi": "20.1.0", "@octokit/graphql-schema": "15.26.0" }, + }, + "probe-schema", + ); + expect(result).toMatchObject({ + status: 1, + stdout: + "@octokit/openapi@20.1.0\n::error::@octokit/graphql-schema@15.25.0 is installed but 15.26.0 is the latest; the probe is testing the pin\n", + calls: [ + SCHEMA_ADD, + "info @octokit/openapi version", + "info @octokit/graphql-schema version", + ], + }); + })); + + test("a failing install ends the probe with its status", () => + withTempDir("nightly-steps-", (dir) => { + const result = probe(dir, { addStatus: 7 }, "probe-schema"); + expect(result).toMatchObject({ status: 7, stdout: "", calls: [SCHEMA_ADD] }); + })); +}); + +const TYPES_ADD = "add --no-save --ignore-scripts @octokit/types@latest"; +const TYPECHECK = "run typecheck --pretty false"; +const TRIPPED_LOG = [ + "src/upstream-gaps/b.ts(12,3): error TS2344: Type 'X' does not satisfy the constraint 'never'.", + "src/upstream-gaps/a.ts(4,1): error TS2344: Type 'Y' does not satisfy the constraint 'never'.", + "src/upstream-gaps/a.ts(9,1): error TS2344: Type 'Z' does not satisfy the constraint 'never'.", + "src/sections/labels/plan.ts(3,3): error TS2344: Type 'W' does not satisfy the constraint 'never'.", + "src/upstream-gaps/c.ts(1,1): error TS2322: Type 'string' is not assignable to type 'number'.", + "", +].join("\n"); + +describe("probe-types", () => { + test("a clean typecheck passes, saying so", () => + withTempDir("nightly-steps-", (dir) => { + const result = probe( + dir, + { typecheck: { stdout: "", stderr: "$ bun x tsc -p . --pretty false\n", status: 0 } }, + "probe-types", + ); + expect(result).toMatchObject({ + status: 0, + stdout: "typecheck is clean against @octokit/types@latest\n", + calls: [TYPES_ADD, TYPECHECK], + }); + })); + + test("TS2344 under src/upstream-gaps/ is the tripwire branch: the log is echoed, then the files, sorted and deduplicated", () => + withTempDir("nightly-steps-", (dir) => { + const result = probe( + dir, + { + typecheck: { + stdout: TRIPPED_LOG, + stderr: 'error: script "typecheck" exited with code 2\n', + status: 2, + }, + }, + "probe-types", + ); + expect(result).toMatchObject({ + status: 1, + stdout: `${TRIPPED_LOG}error: script "typecheck" exited with code 2\n::error::@octokit/types@latest fires upstream-gap tripwires; run bun .github/scripts/graduate-upstream-gaps.ts for these files:\nsrc/upstream-gaps/a.ts\nsrc/upstream-gaps/b.ts\n`, + calls: [TYPES_ADD, TYPECHECK], + }); + })); + + test("a typecheck failure with no tripwire is reported as breakage", () => + withTempDir("nightly-steps-", (dir) => { + const log = + "src/main.ts(1,1): error TS2322: Type 'string' is not assignable to type 'number'.\n"; + const result = probe(dir, { typecheck: { stdout: log, status: 2 } }, "probe-types"); + expect(result).toMatchObject({ + status: 1, + stdout: `${log}::error::typecheck against @octokit/types@latest failed outside the upstream-gap tripwires; a types major may have broken the build (see log above)\n`, + calls: [TYPES_ADD, TYPECHECK], + }); + })); +}); diff --git a/test/scripts/release-pipeline.test.ts b/test/scripts/release-pipeline.test.ts index 620ce6b1b..46a4c586a 100644 --- a/test/scripts/release-pipeline.test.ts +++ b/test/scripts/release-pipeline.test.ts @@ -5,7 +5,7 @@ import { describe, expect, setDefaultTimeout, test } from "bun:test"; import { execFileSync } from "node:child_process"; -import { mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { basename, join } from "node:path"; import { anchorCheck, @@ -1850,3 +1850,92 @@ describe("prereleaseVersion", () => { }); }); }); + +describe("npmFloor", () => { + /** A scripted npm first on PATH: `--version` prints the version file, `install -g npm@latest` logs itself and + * replaces that file with the planned upgrade, or exits with the planned status when the plan says `fail `. */ + function withNpm(dir: string, version: string, after: string): string { + writeFileSync(join(dir, "version"), `${version}\n`); + writeFileSync(join(dir, "after"), `${after}\n`); + writeFileSync( + join(dir, "npm"), + [ + "#!/bin/sh", + `here="${dir}"`, + 'if [ "$1" = "--version" ]; then cat "$here/version"; exit 0; fi', + 'printf "%s\\n" "$*" >> "$here/calls.log"', + 'read -r kind code < "$here/after"', + 'if [ "$kind" = "fail" ]; then exit "$code"; fi', + 'cp "$here/after" "$here/version"', + "", + ].join("\n"), + { mode: 0o755 }, + ); + return dir; + } + const installs = (dir: string): string[] => + existsSync(join(dir, "calls.log")) + ? readFileSync(join(dir, "calls.log"), "utf8").split("\n").filter(Boolean) + : []; + + test.each([ + [ + "at the floor", + "11.5.1", + "11.5.1", + 0, + [], + "npm 11.5.1 is at or above 11.5.1; it publishes through OIDC\n", + ], + [ + "above it by a two-digit minor (numeric, not textual, order)", + "11.10.0", + "11.10.0", + 0, + [], + "npm 11.10.0 is at or above 11.5.1; it publishes through OIDC\n", + ], + [ + "below it, with an upgrade that reaches it", + "10.9.2", + "11.6.0", + 0, + ["install -g npm@latest"], + "npm 11.6.0 is at or above 11.5.1 after an upgrade; it publishes through OIDC\n", + ], + [ + "below it, with an upgrade that does not", + "10.9.2", + "10.9.2", + 1, + ["install -g npm@latest"], + "release-pipeline npm-floor: npm 10.9.2 cannot publish through OIDC; trusted publishing needs npm 11.5.1 or newer.\n", + ], + [ + "below it, with an upgrade that fails", + "10.9.2", + "fail 7", + 1, + ["install -g npm@latest"], + "release-pipeline npm-floor: npm 10.9.2 is below 11.5.1 and npm install -g npm@latest exited 7; the publish needs npm 11.5.1 or newer.\n", + ], + [ + "at a prerelease of the floor, with an upgrade that still prints no version", + "11.5.1-pre.0", + "not-a-version", + 1, + ["install -g npm@latest"], + "release-pipeline npm-floor: npm not-a-version cannot publish through OIDC; trusted publishing needs npm 11.5.1 or newer.\n", + ], + ])( + "an npm %s: the upgrade runs once at most, and the step passes only once the floor holds", + (_, version, after, status, upgrades, stderr) => + withTempDir("npm-floor-", async (dir) => { + const npm = withNpm(dir, version, after); + expect( + await subcommand(ROOT, { PATH: `${npm}:${process.env.PATH ?? ""}` }, "npm-floor"), + ).toEqual({ stdout: "", stderr, status }); + expect(installs(npm)).toEqual(upgrades); + }), + ); +}); diff --git a/test/scripts/step-fixture.ts b/test/scripts/step-fixture.ts new file mode 100644 index 000000000..a584f5cdb --- /dev/null +++ b/test/scripts/step-fixture.ts @@ -0,0 +1,51 @@ +/** + * Runs a workflow step script under this bun as the workflow does, with a GITHUB_OUTPUT file of its own: stdout, + * stderr, status, and the outputs the step wrote, as they were. Synchronous, so the release fixture's push plans + * (which wrap a synchronous body) apply to the step's pushes too. + */ + +import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; + +export interface StepResult { + stdout: string; + stderr: string; + status: number; + /** The GITHUB_OUTPUT file's lines. */ + outputs: string[]; +} + +export function runStep( + script: string, + cwd: string, + runnerTemp: string, + env: Record, + ...args: string[] +): StepResult { + const path = join(import.meta.dir, "..", "..", ".github", "scripts", `${script}.ts`); + mkdirSync(runnerTemp, { recursive: true }); + const outputFile = join(runnerTemp, "github-output"); + rmSync(outputFile, { force: true }); + const result = Bun.spawnSync([process.execPath, path, ...args], { + cwd, + env: Object.fromEntries( + Object.entries({ + ...process.env, + RUNNER_TEMP: runnerTemp, + GITHUB_OUTPUT: outputFile, + ...env, + }).filter(([, value]) => value !== undefined), + ) as Record, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + return { + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + status: result.exitCode, + outputs: existsSync(outputFile) + ? readFileSync(outputFile, "utf8").split("\n").filter(Boolean) + : [], + }; +} From 2d66c46eb5267d87a28705213b07ee2ffaa5f87c Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:18:19 -0400 Subject: [PATCH 2/3] ci(workflows): restore the npm-floor outputs, pin the push-job boundary as shaped, and reword its claims The lead's gate and CI on the first commit asked for three claim and output fixes and two test rewrites, folded into one push. The npm-floor subcommand prints nothing when the floor holds and, when npm is still below it after the upgrade, the stdout annotation the shell step printed (::error::npm X cannot publish through OIDC; trusted publishing needs npm 11.5.1 or newer.) with exit 1; the npmFloor cases pin stdout, stderr, and status per case. The auto-fix.yml header, both push jobs' step comments, and the push scripts' comments now say what the trusted/branch split does (bun starts in and imports only default-branch code, no PR bunfig, .env, hook, or lifecycle script runs there, the fix-owned list the push job enforces is main's) and no longer say a PR cannot reach the token: a same-repository author can still edit the workflow file, which predates the split. test/docs/repo-owned-workflows.test.ts pins the boundary as shaped (the default-branch checkout in trusted, the PR head in branch, bun pinned from trusted with no install, every runtime step in trusted, no local action, no run text naming branch or PR_CHECKOUT, no git push outside the script, one push-script step carrying HEAD_SHA, HEAD_REF, and PR_CHECKOUT) with in-memory negative controls for each. test/docs/npm-publish-workflows.test.ts pins both publishers to the one npm-floor step the script dispatches and to a single NPM_FLOOR not below 11.5.1, in place of the floor= line it read from shell that no longer exists; its bash run of that shell is gone, the npmFloor tests cover those branches. Three lines the platform's warn tier flagged: .github/actions/setup/action.yml and post-green.yml each had a line over 150 characters, re-cut into two; the comment block before update-release-pr.yml's publish step shrank from 14 lines to 9. --- .github/actions/setup/action.yml | 3 +- .github/scripts/auto-fix-steps.ts | 6 +- .github/scripts/auto-format-steps.ts | 2 +- .github/scripts/release-pipeline.ts | 23 ++- .github/workflows/auto-fix.yml | 10 +- .github/workflows/auto-format.yml | 4 +- .github/workflows/post-green.yml | 3 +- .github/workflows/update-release-pr.yml | 23 ++- test/docs/npm-publish-workflows.test.ts | 108 ++++--------- test/docs/repo-owned-workflows.test.ts | 197 +++++++++++++++++++----- test/scripts/release-pipeline.test.ts | 28 ++-- 11 files changed, 241 insertions(+), 166 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 5c6b4c276..7078a8486 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -4,7 +4,8 @@ description: | The bun version is the one .bun-version pins. - The install runs bun install --frozen-lockfile --ignore-scripts, so the lockfile never changes and the prepare script (lefthook, a git hook no runner uses) never runs. + The install runs bun install --frozen-lockfile --ignore-scripts, so the lockfile never changes and the prepare + script (lefthook, a git hook no runner uses) never runs. The caller keeps its own checkout and setup-node steps, whose options differ per job. diff --git a/.github/scripts/auto-fix-steps.ts b/.github/scripts/auto-fix-steps.ts index dace48f43..0f028ca49 100644 --- a/.github/scripts/auto-fix-steps.ts +++ b/.github/scripts/auto-fix-steps.ts @@ -8,8 +8,8 @@ * GITHUB_OUTPUT * * FIX_OWNED_PATHS is a security boundary and stays literal here: the patch is cut on a runner PR code ran on, so - * the push job admits nothing outside it, and it runs this file from the default branch's checkout, so a PR cannot - * widen the list for its own push. test/scripts/auto-fix-steps.test.ts pins the list and GENERATORS to the + * the push job admits nothing outside it, and it runs this file from the default branch's checkout, so the list it + * enforces is main's. test/scripts/auto-fix-steps.test.ts pins the list and GENERATORS to the * generated-output table (.github/scripts/generated.ts), so a newly registered output cannot slip past either step. * * Node builtins only: the push job installs nothing, so no git hook or lifecycle script exists where the write @@ -95,7 +95,7 @@ function rebuild(): void { function push(): void { // bun has already read its bunfig.toml and .env from the trusted checkout it started in; only git runs in the - // PR branch's tree from here on, and a PR cannot put a hook or a config there. + // PR branch's tree from here on, with main's environment and no hook or config of that branch. process.chdir(requireEnv("PR_CHECKOUT")); const token = requireEnv("TOKEN"); const canRetrigger = requireEnv("CAN_RETRIGGER"); diff --git a/.github/scripts/auto-format-steps.ts b/.github/scripts/auto-format-steps.ts index 85d34083e..9000a3f57 100644 --- a/.github/scripts/auto-format-steps.ts +++ b/.github/scripts/auto-format-steps.ts @@ -14,7 +14,7 @@ import { capture, requireEnv, run } from "./lib/workflow-step.js"; function push(): void { // bun has already read its bunfig.toml and .env from the trusted checkout it started in; only git runs in the - // PR branch's tree from here on, and a PR cannot put a hook or a config there. + // PR branch's tree from here on, with main's environment and no hook or config of that branch. process.chdir(requireEnv("PR_CHECKOUT")); const token = requireEnv("GH_TOKEN"); const headRef = requireEnv("HEAD_REF"); diff --git a/.github/scripts/release-pipeline.ts b/.github/scripts/release-pipeline.ts index f05d71857..c68be2b36 100644 --- a/.github/scripts/release-pipeline.ts +++ b/.github/scripts/release-pipeline.ts @@ -1253,15 +1253,14 @@ function belowFloor(version: string, floor: string): boolean { } /** An npm at or above `floor` on PATH: an older bundled npm is upgraded once (`npm install -g npm@latest`, on the - * step's log), then held to the floor; one still below it after that is refused. */ -export function npmFloor(floor = NPM_FLOOR): { version: string; upgraded: boolean } { + * step's log), then held to the floor; `atFloor` false is the version still below it after that. */ +export function npmFloor(floor = NPM_FLOOR): { version: string; atFloor: boolean } { const read = (): string => execFileSync("npm", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"], }).trim(); let version = read(); - let upgraded = false; if (belowFloor(version, floor)) { const install = spawnSync("npm", ["install", "-g", "npm@latest"], { stdio: "inherit" }); if (install.status !== 0) { @@ -1273,14 +1272,8 @@ export function npmFloor(floor = NPM_FLOOR): { version: string; upgraded: boolea ); } version = read(); - upgraded = true; } - if (belowFloor(version, floor)) { - throw new Error( - `npm ${version} cannot publish through OIDC; trusted publishing needs npm ${floor} or newer.`, - ); - } - return { version, upgraded }; + return { version, atFloor: !belowFloor(version, floor) }; } const DEFAULT_REGISTRY = "https://registry.npmjs.org"; @@ -1378,10 +1371,14 @@ async function main(): Promise { break; } case "npm-floor": { + // Silent when the floor holds, as the shell step was; the refusal is the annotation it printed. const result = npmFloor(); - console.error( - `npm ${result.version} is at or above ${NPM_FLOOR}${result.upgraded ? " after an upgrade" : ""}; it publishes through OIDC`, - ); + if (!result.atFloor) { + console.log( + `::error::npm ${result.version} cannot publish through OIDC; trusted publishing needs npm ${NPM_FLOOR} or newer.`, + ); + process.exit(1); + } break; } case "npm-confirm": { diff --git a/.github/workflows/auto-fix.yml b/.github/workflows/auto-fix.yml index 127a99de7..451fe8fd1 100644 --- a/.github/workflows/auto-fix.yml +++ b/.github/workflows/auto-fix.yml @@ -11,8 +11,10 @@ # re-checks the applied paths before committing. Both steps' logic is .github/scripts/auto-fix-steps.ts. # --ignore-scripts on the build job's install -> lefthook and dependency lifecycle scripts never run # no install at all on the push job -> no git hooks exist where the write token is; bun alone is set up -# the push script from the default branch -> the job executes nothing the PR branch holds: the PR authors the -# patch alone, and the fix-owned list the job enforces is main's +# the push script from the default branch -> bun starts in and imports only default-branch code, so no bunfig, +# .env, hook, or script of the PR branch runs there; the fix-owned +# list the job enforces is main's (a same-repo author can still edit +# this workflow file, which predates the split) # # Dependabot-triggered runs read the Dependabot secrets store, not the Actions one, so REPO_PLATFORM_TOKEN goes in # both. A push made with github.token starts no workflows. @@ -101,8 +103,8 @@ jobs: contents: write pull-requests: write # the sticky no-retrigger comment below steps: - # The script that holds the write token comes from the default branch, which a PR cannot edit; the PR branch - # is checked out beside it, as the tree the patch applies to. + # The script that holds the write token comes from the default branch, so nothing checked out from the PR + # branch is executed here; that branch is checked out beside it, as the tree the patch applies to. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.repository.default_branch }} diff --git a/.github/workflows/auto-format.yml b/.github/workflows/auto-format.yml index c9c1bdcd8..009761486 100644 --- a/.github/workflows/auto-format.yml +++ b/.github/workflows/auto-format.yml @@ -74,8 +74,8 @@ jobs: contents: write pull-requests: write steps: - # The script that holds the write token comes from the default branch, which a PR cannot edit; the PR branch - # is checked out beside it, as the tree the patch applies to. + # The script that holds the write token comes from the default branch, so nothing checked out from the PR + # branch is executed here; that branch is checked out beside it, as the tree the patch applies to. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 if: needs.format.outputs.changed == 'true' with: diff --git a/.github/workflows/post-green.yml b/.github/workflows/post-green.yml index 0d7034d98..f5dcd4d96 100644 --- a/.github/workflows/post-green.yml +++ b/.github/workflows/post-green.yml @@ -26,7 +26,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.sha }} - # The script names the tag by this commit's position on main and judges latest's source against its history; a shallow checkout can do neither. + # The script names the tag by this commit's position on main and judges latest's source against its + # history; a shallow checkout can do neither. fetch-depth: 0 token: ${{ secrets.REPO_PLATFORM_TOKEN || github.token }} # A dry-run push asks origin for its receive-pack advertisement over the channel the real push uses, which a diff --git a/.github/workflows/update-release-pr.yml b/.github/workflows/update-release-pr.yml index 74ced9749..94dd70932 100644 --- a/.github/workflows/update-release-pr.yml +++ b/.github/workflows/update-release-pr.yml @@ -98,20 +98,15 @@ jobs: - name: Build the library if: steps.oidc.outputs.proceed == 'true' run: bun run build:schema && bun run build:lib - # scripts.prepare is dropped from the published manifest: it installs - # lefthook, a devDependency the tarball does not carry, and npm blocks - # install scripts from a provenance-attested package anyway. - # The guard is a safety on npm state, not a decision about whether to - # publish (the refresh that called this workflow decided that): it reads - # the registry's origin record (the CDN's copy lags a publish by up to - # 300 s) and places every published pre-release by the ancestry of the - # source sha it names. A version already there (a rerun of this run) or - # a pre-release of a descendant of this commit (a stale retry after - # newer runs published) publishes nothing, so next never moves back on - # what the run can see; a source the checkout cannot place is ignored - # with a notice. A registry error stops the job instead of publishing - # blind. npm's provenance names the commit in GITHUB_SHA, so the source - # is passed in. + # scripts.prepare is dropped from the published manifest: it installs lefthook, a devDependency the tarball + # does not carry, and npm blocks install scripts from a provenance-attested package anyway. The verdict is a + # safety on npm state, not the decision to publish (the refresh that called this workflow made that); npm's + # provenance names the commit in GITHUB_SHA, so the source is passed in. What the verdict reads and answers is + # nextPublishVerdict in .github/scripts/release-pipeline.ts: + # the version already on the record (a rerun) -> skip + # a pre-release of a descendant on the record (a stale retry) -> skip, so next never moves back + # a source the checkout cannot place -> ignored with a notice + # a registry error -> the job stops instead of publishing blind - name: Publish the pre-release under the next dist-tag id: publish if: steps.oidc.outputs.proceed == 'true' diff --git a/test/docs/npm-publish-workflows.test.ts b/test/docs/npm-publish-workflows.test.ts index 74bef129f..e961dfcd3 100644 --- a/test/docs/npm-publish-workflows.test.ts +++ b/test/docs/npm-publish-workflows.test.ts @@ -2,8 +2,9 @@ * The two npm publishers (update-release-pr.yml's publish-next, update-release.yml's publish-npm) share one contract, trusted * publishing through the runner's OIDC token and nothing else, and one was copied from the other. The relations here: both are guarded to the * repository package.json names; both take one lane; the stable one runs after every other job of its workflow; neither hands npm a - * token, as the library page promises; the steps they share are the same text; both publish to one registry. The probe, the floor - * guard, and the publish blocks also run under bash against stubs, since no pin shows what a branch does. + * token, as the library page promises; the steps they share are the same text; both publish to one registry. The + * probe and the publish blocks also run under bash against stubs, since no pin shows what a branch does; the floor + * guard is the script's npm-floor subcommand, whose branches test/scripts/release-pipeline.test.ts runs. * * The static guards catch ACCIDENTAL drift: a guard, lane, or env edited in plain YAML. Deliberately hiding a token or a second * publisher behind other syntax is out of scope. @@ -55,9 +56,12 @@ const stepNamed = (job: RunJob, name: string): Step => job.steps.find((step) => step.name === name), `step ${name}`, ); -/** The npm the guard step's script refuses to publish below: its `floor=` line. */ -const guardFloor = (guard: Step): string => - must(guard.run?.match(/^floor=(\S+)$/m)?.[1], "floor= line in the guard"); +/** The one floor guard both publishers run; the floor itself is NPM_FLOOR in release-pipeline.ts. */ +const FLOOR_GUARD = "bun .github/scripts/release-pipeline.ts npm-floor"; +const FLOOR_GUARD_NAME = "Require an npm that publishes through OIDC"; +/** The pipeline script's text, where the floor and the confirm outcomes are defined. */ +const pipelineSource = (): string => + readFileSync(join(ROOT, ".github", "scripts", "release-pipeline.ts"), "utf8"); const setupNode = (job: RunJob): Step => must( job.steps.find((step) => step.uses?.startsWith("actions/setup-node@")), @@ -283,17 +287,10 @@ describe("the npm publish jobs", () => { /publish-npm: "Build the library" runs after the publish/, ], [ - "a floor guard fixed in one job only", + "a floor guard changed in one job only", (w) => { - const guard = stepNamed( - runJob(w.jobs[NEXT_JOB], "next"), - "Require an npm that publishes through OIDC", - ); - const floor = guardFloor(guard); - guard.run = guard.run?.replace( - `floor=${floor}`, - `floor=${Number(floor.split(".")[0]) + 1}.0.0`, - ); + stepNamed(runJob(w.jobs[NEXT_JOB], "next"), FLOOR_GUARD_NAME).run = + `${FLOOR_GUARD} --floor 99.0.0`; }, /"Require an npm that publishes through OIDC" diverged/, ], @@ -381,75 +378,36 @@ describe("the OIDC probe under bash", () => { }); }); -describe("the npm floor guard under bash", () => { - const stable = runJob(readWorkflow(STABLE_FILE).jobs[STABLE_JOB], `${STABLE_JOB} job`); - const guard = stepNamed(stable, "Require an npm that publishes through OIDC"); - const run = must(guard.run, "guard run"); - /** Trusted publishing exists from this npm on (npm's changelog for 11.5.1); the script must hold that floor or a newer one. */ +describe("the npm floor guard", () => { + const { next, stableWorkflow } = publishers(); + const stable = runJob(stableWorkflow.jobs[STABLE_JOB], `${STABLE_JOB} job`); + /** Trusted publishing exists from this npm on (npm's changelog for 11.5.1); the script's floor must be it or + * newer. */ const OIDC_NPM = "11.5.1"; - const floor = guardFloor(guard); - test("the script's floor is not below the npm that introduced trusted publishing", () => { - const [scriptFloor] = [floor, OIDC_NPM].sort((a, b) => - a.localeCompare(b, undefined, { numeric: true }), + test("both publishers run the one npm-floor subcommand, which the script dispatches, and its floor is defined once, not below the npm that introduced trusted publishing", () => { + for (const job of [next, stable]) { + expect(stepNamed(job, FLOOR_GUARD_NAME).run?.trim()).toBe(FLOOR_GUARD); + } + const source = pipelineSource(); + expect(source).toContain('case "npm-floor":'); + const floors = [...source.matchAll(/^export const NPM_FLOOR = "(\d+\.\d+\.\d+)";$/gm)].map( + (m) => m[1] ?? "", ); - expect(scriptFloor, `floor=${floor} admits an npm that cannot publish through OIDC`).toBe( - OIDC_NPM, + expect(floors).toHaveLength(1); + const [scriptFloor] = [floors[0] ?? "", OIDC_NPM].sort((a, b) => + a.localeCompare(b, undefined, { numeric: true }), ); - }); - - /** An npm stub reporting `before` until `npm install -g` runs, then `after`. */ - const stubNpm = - (before: string, after: string) => - (bin: string): void => { - writeFileSync(join(bin, "version"), before); - writeFileSync( - join(bin, "npm"), - [ - "#!/bin/sh", - `here="$(dirname "$0")"`, - 'case "$1" in', - ` --version) cat "$here/version"; echo ;;`, - ` install) printf '%s' "${after}" > "$here/version"; echo "installed $*" ;;`, - ' *) echo "unexpected npm $*" >&2; exit 2 ;;', - "esac", - "", - ].join("\n"), - { mode: 0o755 }, - ); - }; - - const floors: [string, string, string, { lines: string[]; status: number }][] = [ - ["at the floor", floor, floor, { lines: [], status: 0 }], - ["above it", "99.0.0", "99.0.0", { lines: [], status: 0 }], - [ - "just below the OIDC floor, upgraded past it", - "11.4.2", - "99.0.0", - { lines: ["installed install -g npm@latest"], status: 0 }, - ], - [ - "below it and still below after the upgrade", - "1.0.0", - "1.0.0", - { - lines: [ - "installed install -g npm@latest", - `::error::npm 1.0.0 cannot publish through OIDC; trusted publishing needs npm ${floor} or newer.`, - ], - status: 1, - }, - ], - ]; - test.each(floors)("an npm %s", async (_name, before, after, expected) => { - const guard = await runStep(run, {}, stubNpm(before, after)); - expect({ lines: guard.lines, status: guard.status }).toEqual(expected); + expect( + scriptFloor, + `NPM_FLOOR ${floors[0]} admits an npm that cannot publish through OIDC`, + ).toBe(OIDC_NPM); }); }); /** The outcome literals of the pipeline's ConfirmVerdict union, read from the type the script exports; the case block must name each. */ function confirmOutcomes(): string[] { - const source = readFileSync(join(ROOT, ".github", "scripts", "release-pipeline.ts"), "utf8"); + const source = pipelineSource(); const union = must( source.match(/export type ConfirmVerdict =([\s\S]*?);\n/)?.[1], "the ConfirmVerdict union", diff --git a/test/docs/repo-owned-workflows.test.ts b/test/docs/repo-owned-workflows.test.ts index da159293e..049d0fcd4 100644 --- a/test/docs/repo-owned-workflows.test.ts +++ b/test/docs/repo-owned-workflows.test.ts @@ -1,52 +1,175 @@ /** - * A property of the repo-owned commit-back workflows a later edit breaks with no other check noticing: the push jobs - * run no PR code under their write token and push only over the head they patched. + * A property of the repo-owned commit-back workflows a later edit breaks with no other check noticing: under the + * write token, the push jobs execute code from the default branch alone (bun starts there, imports from there, and + * runs no local action, install, or PR-branch file), the PR branch is a second checkout that only git touches, and + * the push script gets the lease's inputs (HEAD_SHA, HEAD_REF) from the step's env. */ import { describe, expect, test } from "bun:test"; -import { readWorkflow, type Step } from "./workflow-loader.js"; +import { type Job, readWorkflow, type Step } from "./workflow-loader.js"; -/** A runtime or package manager at a command position: each reads the checkout's manifest or scripts and runs what it finds there. */ +const TRUSTED = "trusted"; +const BRANCH = "branch"; +const DEFAULT_BRANCH_REF = `\${{ github.event.repository.default_branch }}`; +const HEAD_REF = `\${{ github.event.pull_request.head.ref }}`; +const PR_CHECKOUT = `\${{ github.workspace }}/${BRANCH}`; +const PUSH_SCRIPT = /^bun \.github\/scripts\/[\w-]+-steps\.ts push$/; + +/** A runtime or package manager at a command position: each reads the checkout's manifest or scripts and runs + * what it finds there. */ const RUNS_CHECKOUT = /(?:^|[\s;&|(])(?:bun|bunx|node|npm|npx|pnpm|yarn|deno|tsx)(?=\s|$)/m; /** The script with its quoted strings blanked, so a word inside an echo is not read as a command. */ const commandsOf = (run: string) => run.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""'); -/** A step that runs code from the checkout: a run step invoking a runtime, or any local action (its action.yml is PR-editable). */ -const runsCheckoutCode = (step: Step) => - RUNS_CHECKOUT.test(commandsOf(step.run ?? "")) || (step.uses ?? "").startsWith("./"); +const label = (step: Step): string => step.name ?? step.uses ?? step.run ?? "unnamed step"; +const workingDirectory = (step: Step): unknown => + (step as Step & { "working-directory"?: unknown })["working-directory"]; + +/** Every way a push job breaks the boundary; the assertion and the negative controls read this one list. */ +function boundaryProblems(push: Job | undefined): string[] { + const problems: string[] = []; + const steps = push?.steps ?? []; + if (push?.permissions?.contents !== "write") problems.push("the push job has no write token"); + const checkouts = steps.filter((step) => (step.uses ?? "").startsWith("actions/checkout@")); + const trusted = checkouts.find((step) => step.with?.path === TRUSTED); + const branch = checkouts.find((step) => step.with?.path === BRANCH); + if (trusted?.with?.ref !== DEFAULT_BRANCH_REF) { + problems.push(`the ${TRUSTED} checkout is not the default branch`); + } + if (branch?.with?.ref !== HEAD_REF) problems.push(`the ${BRANCH} checkout is not the PR head`); + for (const step of checkouts) { + if (step.with?.["persist-credentials"] !== false) { + problems.push(`the ${String(step.with?.path)} checkout keeps its credentials`); + } + } + const setupBun = steps.find((step) => (step.uses ?? "").startsWith("oven-sh/setup-bun@")); + if (setupBun?.with?.["bun-version-file"] !== `${TRUSTED}/.bun-version`) { + problems.push(`bun is not pinned from ${TRUSTED}/.bun-version`); + } + let pushes = 0; + for (const step of steps) { + if ((step.uses ?? "").startsWith("./")) { + problems.push(`"${label(step)}" is a local action, run from a checkout`); + } + if (step.run === undefined) continue; + const commands = commandsOf(step.run); + if (RUNS_CHECKOUT.test(commands) && workingDirectory(step) !== TRUSTED) { + problems.push(`"${label(step)}" runs checkout code outside ${TRUSTED}`); + } + if (/\binstall\b/.test(commands)) + problems.push(`"${label(step)}" installs under the write token`); + // The raw text: a quoted path is still a path. + if (new RegExp(`\\b${BRANCH}\\b`).test(step.run)) { + problems.push(`"${label(step)}" names the ${BRANCH} checkout; only PR_CHECKOUT may`); + } + // The one push is leasePush() inside the script; a push written here has no lease the tests exercise. + if (/\bgit\s+push\b/.test(step.run)) { + problems.push(`"${label(step)}" pushes outside the push script`); + } + // The PR tree's path reaches the push script through its env alone; a run step that reads it would run from it. + if (/PR_CHECKOUT/.test(step.run)) { + problems.push(`"${label(step)}" reads PR_CHECKOUT; only the push script may`); + } + if (!PUSH_SCRIPT.test(step.run.trim())) continue; + pushes += 1; + for (const name of ["HEAD_SHA", "HEAD_REF", "PR_CHECKOUT"]) { + if (step.env?.[name] === undefined) problems.push(`the push step has no ${name} in its env`); + } + if (step.env?.PR_CHECKOUT !== PR_CHECKOUT) problems.push(`PR_CHECKOUT is not ${PR_CHECKOUT}`); + if (step.env?.HEAD_REF !== HEAD_REF) problems.push(`HEAD_REF is not the PR head`); + } + if (pushes !== 1) problems.push(`${pushes} push script steps, not one`); + return problems; +} describe("the commit-back push jobs", () => { test.each(["auto-fix.yml", "auto-format.yml"])( - "%s: no PR code runs under the write token, and the push is leased to the patched head", + "%s: under the write token only default-branch code runs, the PR branch is git's tree alone, and the push script gets the lease's inputs", (file) => { - const push = readWorkflow(file).jobs.push; - expect(push?.permissions?.contents).toBe("write"); - expect((push?.steps ?? []).filter(runsCheckoutCode)).toEqual([]); - // The lease names the sha the job verified and guards one ref, so a push under the write token is exactly: that lease as its only - // option, one https remote, and that ref as its only destination. Every push command of every step is judged. - const pushes = (push?.steps ?? []).flatMap((step) => - [...(step.run ?? "").replace(/\\\n/g, " ").matchAll(/\bgit\s+push\b[^;&|()\n]*/g)].map( - (m) => ({ - step, - words: m[0] - .split(/\s+/) - .slice(2) - .map((w) => w.replace(/["']/g, "").replace(/\$\{(\w+)\}/g, "$$$1")), - }), - ), - ); - expect(pushes.length, `${file} has no git push`).toBeGreaterThan(0); - for (const { step, words } of pushes) { - expect( - step.env?.HEAD_SHA, - `${file}: a push step without HEAD_SHA in its env`, - ).toBeDefined(); - expect(words.filter((word) => word.startsWith("-"))).toEqual([ - "--force-with-lease=refs/heads/$HEAD_REF:$HEAD_SHA", - ]); - const operands = words.filter((word) => !word.startsWith("-")); - expect(operands[0], "the remote is not a URL").toMatch(/^https:\/\//); - expect(operands.slice(1)).toEqual(["HEAD:refs/heads/$HEAD_REF"]); - } + expect(boundaryProblems(readWorkflow(file).jobs.push)).toEqual([]); }, ); + + const pushStep = (push: Job): Step => + (push.steps ?? []).find((step) => PUSH_SCRIPT.test((step.run ?? "").trim())) as Step; + test.each<[string, (push: Job) => void, RegExp]>([ + [ + "the trusted checkout taken from the PR head", + (push) => { + const trusted = (push.steps ?? []).find((step) => step.with?.path === TRUSTED) as Step; + trusted.with = { ...trusted.with, ref: HEAD_REF }; + }, + /trusted checkout is not the default branch/, + ], + [ + "the push script run from the PR branch", + (push) => { + (pushStep(push) as Step & { "working-directory"?: string })["working-directory"] = BRANCH; + }, + /runs checkout code outside trusted/, + ], + [ + "a step running a PR-branch file by path", + (push) => { + push.steps?.push({ + run: "bun ../branch/postinstall.ts", + "working-directory": TRUSTED, + } as Step); + }, + /names the branch checkout/, + ], + [ + "a step running a PR-branch file by a quoted path", + (push) => { + push.steps?.push({ + run: 'bun "../branch/postinstall.ts"', + "working-directory": TRUSTED, + } as Step); + }, + /names the branch checkout/, + ], + [ + "a step running a PR-branch file through PR_CHECKOUT", + (push) => { + push.steps?.push({ + run: 'bun "$PR_CHECKOUT/postinstall.ts"', + "working-directory": TRUSTED, + env: { PR_CHECKOUT }, + } as Step); + }, + /reads PR_CHECKOUT/, + ], + [ + "a push written in the workflow beside the script's leased one", + (push) => { + push.steps?.push({ + run: "git push --force https://x-access-token:$TOKEN@github.com/$GITHUB_REPOSITORY.git HEAD:refs/heads/$HEAD_REF", + "working-directory": TRUSTED, + env: { TOKEN: `\${{ github.token }}`, HEAD_REF }, + } as Step); + }, + /pushes outside the push script/, + ], + [ + "the setup composite action, read from a checkout", + (push) => { + push.steps?.push({ uses: "./.github/actions/setup" }); + }, + /is a local action/, + ], + [ + "a push step without the lease's head", + (push) => { + const step = pushStep(push); + const { HEAD_SHA: _dropped, ...env } = step.env ?? {}; + step.env = env; + }, + /no HEAD_SHA in its env/, + ], + ])("%s fails the boundary (negative control)", (_case, mutate, message) => { + for (const file of ["auto-fix.yml", "auto-format.yml"]) { + const push = structuredClone(readWorkflow(file).jobs.push) as Job; + mutate(push); + expect(boundaryProblems(push).join("\n"), file).toMatch(message); + } + }); }); diff --git a/test/scripts/release-pipeline.test.ts b/test/scripts/release-pipeline.test.ts index 46a4c586a..cd6375f70 100644 --- a/test/scripts/release-pipeline.test.ts +++ b/test/scripts/release-pipeline.test.ts @@ -1879,21 +1879,15 @@ describe("npmFloor", () => { : []; test.each([ - [ - "at the floor", - "11.5.1", - "11.5.1", - 0, - [], - "npm 11.5.1 is at or above 11.5.1; it publishes through OIDC\n", - ], + ["at the floor", "11.5.1", "11.5.1", 0, [], "", ""], [ "above it by a two-digit minor (numeric, not textual, order)", "11.10.0", "11.10.0", 0, [], - "npm 11.10.0 is at or above 11.5.1; it publishes through OIDC\n", + "", + "", ], [ "below it, with an upgrade that reaches it", @@ -1901,7 +1895,8 @@ describe("npmFloor", () => { "11.6.0", 0, ["install -g npm@latest"], - "npm 11.6.0 is at or above 11.5.1 after an upgrade; it publishes through OIDC\n", + "", + "", ], [ "below it, with an upgrade that does not", @@ -1909,7 +1904,8 @@ describe("npmFloor", () => { "10.9.2", 1, ["install -g npm@latest"], - "release-pipeline npm-floor: npm 10.9.2 cannot publish through OIDC; trusted publishing needs npm 11.5.1 or newer.\n", + "::error::npm 10.9.2 cannot publish through OIDC; trusted publishing needs npm 11.5.1 or newer.\n", + "", ], [ "below it, with an upgrade that fails", @@ -1917,6 +1913,7 @@ describe("npmFloor", () => { "fail 7", 1, ["install -g npm@latest"], + "", "release-pipeline npm-floor: npm 10.9.2 is below 11.5.1 and npm install -g npm@latest exited 7; the publish needs npm 11.5.1 or newer.\n", ], [ @@ -1925,16 +1922,17 @@ describe("npmFloor", () => { "not-a-version", 1, ["install -g npm@latest"], - "release-pipeline npm-floor: npm not-a-version cannot publish through OIDC; trusted publishing needs npm 11.5.1 or newer.\n", + "::error::npm not-a-version cannot publish through OIDC; trusted publishing needs npm 11.5.1 or newer.\n", + "", ], ])( - "an npm %s: the upgrade runs once at most, and the step passes only once the floor holds", - (_, version, after, status, upgrades, stderr) => + "an npm %s: the upgrade runs once at most, the floor holding is silent, and the floor failing is the annotation", + (_, version, after, status, upgrades, stdout, stderr) => withTempDir("npm-floor-", async (dir) => { const npm = withNpm(dir, version, after); expect( await subcommand(ROOT, { PATH: `${npm}:${process.env.PATH ?? ""}` }, "npm-floor"), - ).toEqual({ stdout: "", stderr, status }); + ).toEqual({ stdout, stderr, status }); expect(installs(npm)).toEqual(upgrades); }), ); From e41886bedc1dc0bde0533f70968181513087e400 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:35:38 -0400 Subject: [PATCH 3/3] test(docs): state the push-job boundary test as the lexical tripwire it is The docblock on boundaryProblems() claimed every way a push job breaks the boundary. The checks are lexical: the default-branch ref into trusted, the PR head into branch, the working directory, no literal branch or PR_CHECKOUT reference, one push-script step. A path computed through an env alias, another runtime spelling, inherited runtime configuration, or action indirection passes it, and the scan is not widened for them: the comment now says what the test is. --- test/docs/repo-owned-workflows.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/docs/repo-owned-workflows.test.ts b/test/docs/repo-owned-workflows.test.ts index 049d0fcd4..c7aa56f8c 100644 --- a/test/docs/repo-owned-workflows.test.ts +++ b/test/docs/repo-owned-workflows.test.ts @@ -24,7 +24,9 @@ const label = (step: Step): string => step.name ?? step.uses ?? step.run ?? "unn const workingDirectory = (step: Step): unknown => (step as Step & { "working-directory"?: unknown })["working-directory"]; -/** Every way a push job breaks the boundary; the assertion and the negative controls read this one list. */ +/** A drift tripwire over the named lexical shapes of a push job (the default-branch ref into trusted, the PR head + * into branch, the working directory, no literal branch or PR_CHECKOUT reference, one push-script step), not an + * enumeration of every bypass; the assertion and the negative controls read this one list. */ function boundaryProblems(push: Job | undefined): string[] { const problems: string[] = []; const steps = push?.steps ?? [];