Skip to content

Commit 827c7c8

Browse files
authored
ci(auto-format): run the format step through the steps script (#496)
1 parent 0971c6c commit 827c7c8

3 files changed

Lines changed: 95 additions & 24 deletions

File tree

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

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,37 @@
11
/**
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.
2+
* The two auto-format.yml steps: the format job's run of the formatter, cut into a patch for the push job, and
3+
* that job's commit of the patch to the PR branch under a lease on the commit it was cut on.
44
*
5-
* push push job GH_TOKEN, HEAD_REF, HEAD_SHA, RUNNER_TEMP, GITHUB_REPOSITORY -> the commit on the PR branch
5+
* format format job RUNNER_TEMP, GITHUB_OUTPUT -> format.patch, head=, changed=
6+
* push push job GH_TOKEN, HEAD_REF, HEAD_SHA, RUNNER_TEMP, GITHUB_REPOSITORY -> the commit on the PR branch
67
*
78
* Node builtins only: the push job installs nothing, so no git hook or lifecycle script exists where the write
89
* token is.
910
*/
1011

12+
import { statSync } from "node:fs";
1113
import { join } from "node:path";
1214
import { configureBotIdentity, leasePush } from "./lib/pr-branch.js";
13-
import { capture, requireEnv, run } from "./lib/workflow-step.js";
15+
import { capture, requireEnv, run, setOutput } from "./lib/workflow-step.js";
16+
17+
function format(): void {
18+
// The patch belongs to this commit alone; the push job refuses any other head.
19+
setOutput("head", capture(["git", "rev-parse", "HEAD"]));
20+
run(["bun", "run", "lint:fix"]);
21+
// Anything the formatter left staged is not this workflow's fix: start from an empty index, then stage
22+
// modifications and deletions of tracked files alone (a formatter adds nothing).
23+
run(["git", "reset", "-q"]);
24+
run(["git", "add", "-u"]);
25+
const patch = join(requireEnv("RUNNER_TEMP"), "format.patch");
26+
run(["git", "diff", "--cached", "--binary"], { stdoutFile: patch });
27+
if (statSync(patch).size === 0) {
28+
console.log("nothing to format");
29+
setOutput("changed", "false");
30+
return;
31+
}
32+
run(["git", "diff", "--cached", "--stat"]);
33+
setOutput("changed", "true");
34+
}
1435

1536
function push(): void {
1637
// bun has already read its bunfig.toml and .env from the trusted checkout it started in; only git runs in the
@@ -42,11 +63,13 @@ function push(): void {
4263

4364
if (import.meta.main) {
4465
const command = process.argv[2];
45-
if (command === "push") {
66+
if (command === "format") {
67+
format();
68+
} else if (command === "push") {
4669
push();
4770
} else {
4871
console.error(
49-
`auto-format-steps: unknown command ${JSON.stringify(command ?? null)}; expected push`,
72+
`auto-format-steps: unknown command ${JSON.stringify(command ?? null)}; expected format | push`,
5073
);
5174
process.exit(1);
5275
}

‎.github/workflows/auto-format.yml‎

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,22 +40,7 @@ jobs:
4040
- uses: ./.github/actions/setup
4141
- name: Format (biome) and stage the patch
4242
id: format
43-
run: |
44-
# The patch belongs to this commit alone; the push job refuses any other head.
45-
echo "head=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
46-
bun run lint:fix
47-
# Anything the formatter left staged is not this workflow's fix: start from an empty index, then stage
48-
# modifications and deletions of tracked files alone (a formatter adds nothing).
49-
git reset -q
50-
git add -u
51-
git diff --cached --binary > "$RUNNER_TEMP/format.patch"
52-
if [ -s "$RUNNER_TEMP/format.patch" ]; then
53-
git diff --cached --stat
54-
echo "changed=true" >> "$GITHUB_OUTPUT"
55-
else
56-
echo "nothing to format"
57-
echo "changed=false" >> "$GITHUB_OUTPUT"
58-
fi
43+
run: bun .github/scripts/auto-format-steps.ts format
5944
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
6045
if: steps.format.outputs.changed == 'true'
6146
with:

‎test/scripts/auto-format-steps.test.ts‎

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
/**
2-
* auto-format.yml's push step against local fixture repositories: the branches the workflow text cannot show,
3-
* which checkout the patch is committed on, under whose identity, and what a moved branch does to the push.
2+
* auto-format.yml's two steps against local fixture repositories: the branches the workflow text cannot show.
3+
* For the format step, what the patch holds and when the step stops; for the push step, which checkout the patch
4+
* is committed on, under whose identity, and what a moved branch does to the push.
45
*/
56

67
import { describe, expect, setDefaultTimeout, test } from "bun:test";
78
import { execFileSync } from "node:child_process";
9+
import { existsSync, readFileSync } from "node:fs";
810
import { join } from "node:path";
911
import {
1012
clone,
@@ -22,6 +24,67 @@ installReleasePipelineFixture();
2224

2325
const REMOTE_URL = "https://x-access-token:t0ken@github.com/o/r.git";
2426

27+
/** The format job's checkout of a same-repo PR branch: its lint:fix runs whatever LINT_FIX says. */
28+
function formatFixture(): { pr: string; headSha: string; temp: string } {
29+
const fx = seedFixture();
30+
const pr = clone(fx.root, fx.origin, "pr");
31+
write(
32+
pr,
33+
"package.json",
34+
`${JSON.stringify(
35+
{ name: "fixture", version: "0.0.0", scripts: { "lint:fix": 'sh -c "$LINT_FIX"' } },
36+
null,
37+
2,
38+
)}\n`,
39+
);
40+
const headSha = commitAll(pr, "feat: a pull request");
41+
return { pr, headSha, temp: join(fx.root, "runner-temp") };
42+
}
43+
44+
const format = (pr: string, temp: string, lintFix: string) =>
45+
runStep("auto-format-steps", pr, temp, { LINT_FIX: lintFix }, "format");
46+
47+
function patchPaths(temp: string): string[] {
48+
return [...readFileSync(join(temp, "format.patch"), "utf8").matchAll(/^diff --git a\/(\S+)/gm)]
49+
.map((match) => match[1] ?? "")
50+
.sort();
51+
}
52+
53+
describe("format", () => {
54+
test("a formatter that changes nothing leaves an empty patch and changed=false, and an addition staged before it is not the fix", () => {
55+
const { pr, headSha, temp } = formatFixture();
56+
write(pr, "staged.ts", "export const staged = 1;\n");
57+
git(pr, "add", "staged.ts");
58+
const result = format(pr, temp, "true");
59+
expect(result).toMatchObject({
60+
status: 0,
61+
stdout: "nothing to format\n",
62+
outputs: [`head=${headSha}`, "changed=false"],
63+
});
64+
expect(readFileSync(join(temp, "format.patch"), "utf8")).toBe("");
65+
expect(git(pr, "diff", "--cached", "--name-only")).toBe("");
66+
});
67+
68+
test("the formatter's modifications and deletions of tracked files enter the patch; a file it adds does not", () => {
69+
const { pr, headSha, temp } = formatFixture();
70+
const result = format(
71+
pr,
72+
temp,
73+
"echo 'export const marker=1' > src/marker.ts && rm .github/dependabot.yml && echo new > added.ts",
74+
);
75+
expect(result).toMatchObject({ status: 0, outputs: [`head=${headSha}`, "changed=true"] });
76+
expect(result.stdout).toContain("2 files changed, 1 insertion(+), 3 deletions(-)");
77+
expect(patchPaths(temp)).toEqual([".github/dependabot.yml", "src/marker.ts"]);
78+
});
79+
80+
test("a failing formatter ends the step with its status after head= is written, with no patch cut", () => {
81+
const { pr, headSha, temp } = formatFixture();
82+
const result = format(pr, temp, "exit 3");
83+
expect(result).toMatchObject({ status: 3, outputs: [`head=${headSha}`] });
84+
expect(existsSync(join(temp, "format.patch"))).toBe(false);
85+
});
86+
});
87+
2588
/** The push job's checkout of a same-repo PR branch, with the format job's patch downloaded beside it. */
2689
function prFixture(): { fx: Fixture; pr: string; headSha: string; temp: string } {
2790
const fx = seedFixture();

0 commit comments

Comments
 (0)