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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
180 changes: 180 additions & 0 deletions .github/scripts/auto-fix-steps.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
53 changes: 53 additions & 0 deletions .github/scripts/auto-format-steps.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
24 changes: 24 additions & 0 deletions .github/scripts/lib/pr-branch.ts
Original file line number Diff line number Diff line change
@@ -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}`,
]);
}
88 changes: 88 additions & 0 deletions .github/scripts/lib/workflow-step.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
/** 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<unknown>): 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<RunOptions, "env"> = {}): 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+$/, "");
}
Loading
Loading