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 new file mode 100644 index 000000000..0f028ca49 --- /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 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 + * 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, 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"); + 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..9000a3f57 --- /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, 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"); + 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..c68be2b36 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,39 @@ 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; `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(); + 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(); + } + return { version, atFloor: !belowFloor(version, floor) }; +} + 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 +1370,17 @@ async function main(): Promise { console.log(verdict.publish ? `publish ${verdict.version}` : `skip ${verdict.reason}`); break; } + case "npm-floor": { + // Silent when the floor holds, as the shell step was; the refusal is the annotation it printed. + const result = npmFloor(); + 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": { if (argument !== "next") { throw new Error( @@ -1358,7 +1403,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..451fe8fd1 100644 --- a/.github/workflows/auto-fix.yml +++ b/.github/workflows/auto-fix.yml @@ -8,9 +8,13 @@ # # 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 -> 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. @@ -82,38 +86,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 +103,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, 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 }} + 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 +133,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..009761486 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, 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: + 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/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 cc4f456fc..94dd70932 100644 --- a/.github/workflows/update-release-pr.yml +++ b/.github/workflows/update-release-pr.yml @@ -93,34 +93,20 @@ 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' 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/.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/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..c7aa56f8c 100644 --- a/test/docs/repo-owned-workflows.test.ts +++ b/test/docs/repo-owned-workflows.test.ts @@ -1,52 +1,177 @@ /** - * 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"]; + +/** 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 ?? []; + 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/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..cd6375f70 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,90 @@ 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, [], "", ""], + [ + "above it by a two-digit minor (numeric, not textual, order)", + "11.10.0", + "11.10.0", + 0, + [], + "", + "", + ], + [ + "below it, with an upgrade that reaches it", + "10.9.2", + "11.6.0", + 0, + ["install -g npm@latest"], + "", + "", + ], + [ + "below it, with an upgrade that does not", + "10.9.2", + "10.9.2", + 1, + ["install -g npm@latest"], + "::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", + "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"], + "::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, 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 }); + 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) + : [], + }; +}