Skip to content

Commit 5a704a3

Browse files
authored
ci(workflows): move nested shell into bun scripts for the shell-complexity gate (#469)
1 parent 3051c79 commit 5a704a3

21 files changed

Lines changed: 1564 additions & 495 deletions

‎.github/actions/setup/action.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ description: |
44
55
The bun version is the one .bun-version pins.
66
7-
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.
7+
The install runs bun install --frozen-lockfile --ignore-scripts, so the lockfile never changes and the prepare
8+
script (lefthook, a git hook no runner uses) never runs.
89
910
The caller keeps its own checkout and setup-node steps, whose options differ per job.
1011

‎.github/scripts/auto-fix-steps.ts‎

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/**
2+
* The two auto-fix.yml steps whose logic outgrew shell: the build job's rebuild, which regenerates the generated
3+
* files and cuts the fix patch, and the push job's commit-back of that patch. One subcommand per step:
4+
*
5+
* rebuild build job RUNNER_TEMP, GITHUB_OUTPUT -> autofix.patch, changed=, pruned=
6+
* push push job TOKEN, CAN_RETRIGGER, HEAD_REF, HEAD_SHA, -> the commit on the PR branch, notify=
7+
* PRUNED, RUNNER_TEMP, GITHUB_REPOSITORY,
8+
* GITHUB_OUTPUT
9+
*
10+
* FIX_OWNED_PATHS is a security boundary and stays literal here: the patch is cut on a runner PR code ran on, so
11+
* the push job admits nothing outside it, and it runs this file from the default branch's checkout, so the list it
12+
* enforces is main's. test/scripts/auto-fix-steps.test.ts pins the list and GENERATORS to the
13+
* generated-output table (.github/scripts/generated.ts), so a newly registered output cannot slip past either step.
14+
*
15+
* Node builtins only: the push job installs nothing, so no git hook or lifecycle script exists where the write
16+
* token is.
17+
*/
18+
19+
import { statSync } from "node:fs";
20+
import { join } from "node:path";
21+
import { configureBotIdentity, leasePush } from "./lib/pr-branch.js";
22+
import { capture, requireEnv, run, setOutput, status } from "./lib/workflow-step.js";
23+
24+
/** In generated.ts table order: the later generators import the gaps index through src/, and the inputs table
25+
* reads action.yml, so each source renders first. */
26+
export const GENERATORS = [
27+
"build:gaps-index",
28+
"build:docs",
29+
"build:action-docs",
30+
"build:inputs-table",
31+
] as const;
32+
33+
/** A `dir/` entry covers everything under it, deletions included (a graduated gap file is a deletion). */
34+
export const FIX_OWNED_PATHS = [
35+
"README.md",
36+
"action.yml",
37+
"docs/reference/coverage.md",
38+
"docs/reference/undeclared-policy.md",
39+
"docs/reference/permissions.md",
40+
"docs/operate/check-mode.md",
41+
"docs/reference/sections.md",
42+
"docs/reference/inputs.md",
43+
"docs/reference/architecture.md",
44+
"docs/start/getting-started.md",
45+
"src/upstream-gaps/",
46+
] as const;
47+
48+
const GAPS_DIR = "src/upstream-gaps/";
49+
const GAPS_INDEX = "src/upstream-gaps/index.ts";
50+
51+
export function fixOwned(path: string): boolean {
52+
return FIX_OWNED_PATHS.some((entry) =>
53+
entry.endsWith("/") ? path.startsWith(entry) : entry === path,
54+
);
55+
}
56+
57+
/** The refusal names every admitted path, so a red push job's log shows the boundary without this file. */
58+
function outsideFixOwned(path: string): string {
59+
const files = FIX_OWNED_PATHS.filter((entry) => !entry.endsWith("/"));
60+
const dirs = FIX_OWNED_PATHS.filter((entry) => entry.endsWith("/"));
61+
return `::error::the fix patch staged '${path}', outside the generated docs (${files.join(", ")}), and ${dirs.join(", ")}; refusing to push`;
62+
}
63+
64+
/** `git diff --cached --quiet` answers with its status: 0 for no change, 1 for one; anything else is git failing. */
65+
function stagedChange(...pathspec: string[]): boolean {
66+
const code = status(["git", "diff", "--cached", "--quiet", ...pathspec]);
67+
if (code !== 0 && code !== 1) {
68+
process.exit(code);
69+
}
70+
return code === 1;
71+
}
72+
73+
function rebuild(): void {
74+
for (const generator of GENERATORS) {
75+
run(["bun", "run", generator]);
76+
}
77+
// Anything the earlier steps left staged is not this workflow's fix: start from an empty index so the patch
78+
// holds exactly the fix-owned paths.
79+
run(["git", "reset", "-q"]);
80+
run(["git", "add", "-A", "--", ...FIX_OWNED_PATHS]);
81+
const patch = join(requireEnv("RUNNER_TEMP"), "autofix.patch");
82+
run(["git", "diff", "--cached", "--binary"], { stdoutFile: patch });
83+
if (statSync(patch).size === 0) {
84+
console.log("docs and upstream gaps already fresh");
85+
setOutput("changed", "false");
86+
setOutput("pruned", "false");
87+
return;
88+
}
89+
run(["git", "diff", "--cached", "--stat"]);
90+
setOutput("changed", "true");
91+
// A patch touching a gap file means gap files graduated (the index alone is a regeneration); the push job picks
92+
// the commit subject from this.
93+
setOutput("pruned", String(stagedChange("--", GAPS_DIR, `:(exclude)${GAPS_INDEX}`)));
94+
}
95+
96+
function push(): void {
97+
// bun has already read its bunfig.toml and .env from the trusted checkout it started in; only git runs in the
98+
// PR branch's tree from here on, with main's environment and no hook or config of that branch.
99+
process.chdir(requireEnv("PR_CHECKOUT"));
100+
const token = requireEnv("TOKEN");
101+
const canRetrigger = requireEnv("CAN_RETRIGGER");
102+
const headRef = requireEnv("HEAD_REF");
103+
const headSha = requireEnv("HEAD_SHA");
104+
const pruned = requireEnv("PRUNED");
105+
// The branch can move between the build and this push (a Dependabot rebase): the patch belongs to HEAD_SHA
106+
// only, and the newer head's own run owns the rebuild.
107+
const superseded = (): boolean => {
108+
// A failed refresh leaves origin/HEAD_REF where it was, and that stale read answers: the shell ran this fetch
109+
// inside an if condition, where a failure does not end the step either.
110+
status(["git", "fetch", "--quiet", "origin", headRef]);
111+
return capture(["git", "rev-parse", `origin/${headRef}`]) !== headSha;
112+
};
113+
if (capture(["git", "rev-parse", "HEAD"]) !== headSha) {
114+
console.log("::notice::head moved since the build; skipping the stale fix push");
115+
return;
116+
}
117+
run([
118+
"git",
119+
"apply",
120+
"--index",
121+
"--binary",
122+
join(requireEnv("RUNNER_TEMP"), "autofix", "autofix.patch"),
123+
]);
124+
// --no-renames lists a staged rename as its delete and add halves, so a rename OUT of a protected path cannot
125+
// hide behind an allowed destination.
126+
const staged = capture(["git", "diff", "--cached", "--no-renames", "--name-only", "-z"])
127+
.split("\0")
128+
.filter((path) => path !== "");
129+
for (const path of staged) {
130+
if (!fixOwned(path)) {
131+
console.log(outsideFixOwned(path));
132+
process.exit(1);
133+
}
134+
}
135+
if (!stagedChange()) {
136+
console.log("the fix patch stages nothing against the head commit; nothing to push");
137+
return;
138+
}
139+
configureBotIdentity();
140+
let subject = "build: regenerate generated files";
141+
if (!stagedChange("--", GAPS_DIR)) {
142+
subject = "docs: regenerate generated docs";
143+
}
144+
if (pruned === "true") {
145+
subject = "fix: graduate upstream gaps octokit now ships";
146+
}
147+
// [dependabot skip] keeps Dependabot rebasing and updating the PR over this commit; without it Dependabot stops
148+
// touching a PR once someone else pushes to it.
149+
run(["git", "commit", "--no-verify", "-m", subject, "-m", "[dependabot skip]"]);
150+
if (leasePush(headRef, headSha, token) !== 0) {
151+
if (superseded()) {
152+
console.log("::notice::head moved during the push; skipping the stale fix push");
153+
return;
154+
}
155+
process.exit(1);
156+
}
157+
if (canRetrigger === "true") {
158+
return;
159+
}
160+
// The fix landed via github.token, which starts no workflows, so the PR's checks will not re-run on the new
161+
// head: surface that here and, through notify=, on the PR.
162+
setOutput("notify", "true");
163+
console.log(
164+
"::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",
165+
);
166+
}
167+
168+
if (import.meta.main) {
169+
const command = process.argv[2];
170+
if (command === "rebuild") {
171+
rebuild();
172+
} else if (command === "push") {
173+
push();
174+
} else {
175+
console.error(
176+
`auto-fix-steps: unknown command ${JSON.stringify(command ?? null)}; expected rebuild | push`,
177+
);
178+
process.exit(1);
179+
}
180+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* The auto-format.yml push step: the formatting patch the format job cut on HEAD_SHA, committed and pushed to the
3+
* PR branch under a lease on that commit.
4+
*
5+
* push push job GH_TOKEN, HEAD_REF, HEAD_SHA, RUNNER_TEMP, GITHUB_REPOSITORY -> the commit on the PR branch
6+
*
7+
* Node builtins only: the push job installs nothing, so no git hook or lifecycle script exists where the write
8+
* token is.
9+
*/
10+
11+
import { join } from "node:path";
12+
import { configureBotIdentity, leasePush } from "./lib/pr-branch.js";
13+
import { capture, requireEnv, run } from "./lib/workflow-step.js";
14+
15+
function push(): void {
16+
// bun has already read its bunfig.toml and .env from the trusted checkout it started in; only git runs in the
17+
// PR branch's tree from here on, with main's environment and no hook or config of that branch.
18+
process.chdir(requireEnv("PR_CHECKOUT"));
19+
const token = requireEnv("GH_TOKEN");
20+
const headRef = requireEnv("HEAD_REF");
21+
const headSha = requireEnv("HEAD_SHA");
22+
// The branch can move between the two jobs; the patch was cut on HEAD_SHA and formats that tree alone, so a
23+
// newer head gets its own run (re-apply the label) instead of a commit formatting the old one.
24+
if (capture(["git", "rev-parse", "HEAD"]) !== headSha) {
25+
console.log("::notice::head moved since the format; skipping the stale formatting push");
26+
return;
27+
}
28+
run([
29+
"git",
30+
"apply",
31+
"--index",
32+
"--binary",
33+
join(requireEnv("RUNNER_TEMP"), "format", "format.patch"),
34+
]);
35+
configureBotIdentity();
36+
run(["git", "commit", "--no-verify", "-m", "style: apply automated formatting"]);
37+
const pushed = leasePush(headRef, headSha, token);
38+
if (pushed !== 0) {
39+
process.exit(pushed);
40+
}
41+
}
42+
43+
if (import.meta.main) {
44+
const command = process.argv[2];
45+
if (command === "push") {
46+
push();
47+
} else {
48+
console.error(
49+
`auto-format-steps: unknown command ${JSON.stringify(command ?? null)}; expected push`,
50+
);
51+
process.exit(1);
52+
}
53+
}

‎.github/scripts/lib/pr-branch.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* The commit-back a pull request's branch receives from auto-fix.yml and auto-format.yml: the one place the bot
3+
* identity and the lease push are spelled, so the two workflows cannot drift apart on either.
4+
*/
5+
6+
import { requireEnv, run, status } from "./workflow-step.js";
7+
8+
/** Written into the checkout's config, as the workflows did: the runner's checkout dies with the job. */
9+
export function configureBotIdentity(): void {
10+
run(["git", "config", "user.name", "github-actions[bot]"]);
11+
run(["git", "config", "user.email", "github-actions[bot]@users.noreply.github.com"]);
12+
}
13+
14+
/** HEAD pushed to the PR branch under a lease pinned to the commit the fix was built from, so a concurrent push
15+
* (even a force-reset to an ancestor) rejects this one instead of racing it. The status is the push's. */
16+
export function leasePush(headRef: string, headSha: string, token: string): number {
17+
return status([
18+
"git",
19+
"push",
20+
`--force-with-lease=refs/heads/${headRef}:${headSha}`,
21+
`https://x-access-token:${token}@github.com/${requireEnv("GITHUB_REPOSITORY")}.git`,
22+
`HEAD:refs/heads/${headRef}`,
23+
]);
24+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* What a workflow `run:` step's bash gave the scripts that replaced it, so a moved step keeps its exit codes and
3+
* outputs: `set -e` (a failing command ends the step with that command's status), `$(...)`, a variable the step
4+
* cannot run without, and the `>> "$GITHUB_OUTPUT"` append. Node builtins only: a push job runs these before any
5+
* install.
6+
*/
7+
8+
import { type SpawnSyncReturns, spawnSync } from "node:child_process";
9+
import { appendFileSync, closeSync, openSync } from "node:fs";
10+
import { constants } from "node:os";
11+
12+
export interface RunOptions {
13+
env?: Record<string, string>;
14+
/** stdout goes to this file instead of the step's log, as `> file` would; `stderrToo` sends stderr there as well. */
15+
stdoutFile?: string;
16+
stderrToo?: boolean;
17+
}
18+
19+
/** The variable's value; unset or empty ends the step naming it, as `${NAME:?}` would. */
20+
export function requireEnv(name: string): string {
21+
const value = process.env[name];
22+
if (value === undefined || value === "") {
23+
console.error(`${name} must be set for this step`);
24+
process.exit(1);
25+
}
26+
return value;
27+
}
28+
29+
export function setOutput(name: string, value: string): void {
30+
appendFileSync(requireEnv("GITHUB_OUTPUT"), `${name}=${value}\n`);
31+
}
32+
33+
/** The status bash would report: the exit code, or 128 plus the signal number for a child a signal ended. A command
34+
* that cannot start ends the step, as bash's 127 would. */
35+
function statusOf(command: string, result: SpawnSyncReturns<unknown>): number {
36+
if (result.error !== undefined) {
37+
console.error(`${command}: ${result.error.message}`);
38+
process.exit(127);
39+
}
40+
if (result.signal !== null) {
41+
return 128 + (constants.signals[result.signal] ?? 0);
42+
}
43+
return result.status ?? 1;
44+
}
45+
46+
/** The command's exit status, its output on the step's log unless redirected: for a command whose status is the
47+
* answer (`git diff --quiet`, a push under a lease). */
48+
export function status(argv: readonly string[], options: RunOptions = {}): number {
49+
const [command = "", ...args] = argv;
50+
const fd = options.stdoutFile === undefined ? null : openSync(options.stdoutFile, "w");
51+
try {
52+
const result = spawnSync(command, args, {
53+
stdio: ["inherit", fd ?? "inherit", fd !== null && options.stderrToo ? fd : "inherit"],
54+
env: { ...process.env, ...options.env },
55+
});
56+
return statusOf(command, result);
57+
} finally {
58+
if (fd !== null) {
59+
closeSync(fd);
60+
}
61+
}
62+
}
63+
64+
/** Runs the command; a failure ends the step with the command's status, as `set -e` does. */
65+
export function run(argv: readonly string[], options: RunOptions = {}): void {
66+
const code = status(argv, options);
67+
if (code !== 0) {
68+
process.exit(code);
69+
}
70+
}
71+
72+
/** The command's stdout with its trailing newlines removed, as `$(...)` yields it; stderr stays on the step's log,
73+
* and a failure ends the step with the command's status. Unbounded like `$(...)`: a staged-path listing or a diff
74+
* can pass spawnSync's 1 MiB default. */
75+
export function capture(argv: readonly string[], options: Pick<RunOptions, "env"> = {}): string {
76+
const [command = "", ...args] = argv;
77+
const result = spawnSync(command, args, {
78+
stdio: ["inherit", "pipe", "inherit"],
79+
env: { ...process.env, ...options.env },
80+
encoding: "utf8",
81+
maxBuffer: Number.MAX_SAFE_INTEGER,
82+
});
83+
const code = statusOf(command, result);
84+
if (code !== 0) {
85+
process.exit(code);
86+
}
87+
return result.stdout.replace(/\n+$/, "");
88+
}

0 commit comments

Comments
 (0)