-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathplan.ts
More file actions
121 lines (113 loc) · 3.33 KB
/
Copy pathplan.ts
File metadata and controls
121 lines (113 loc) · 3.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
import { appendFileSync } from "node:fs";
const rootDocs = new Set([
"README.md",
"AGENTS.md",
"POLICY.md",
"CONTRIBUTING.md",
"THIRD_PARTY_NOTICES.md",
]);
const fullCommitId = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i;
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value?.trim()) throw Error(`${name} is required`);
return value;
}
function git(...args: string[]): string {
const result = Bun.spawnSync(["git", ...args]);
if (result.exitCode !== 0) {
const detail =
`${result.stdout.toString()}${result.stderr.toString()}`.trim();
throw Error(`git ${args[0]} failed: ${detail}`);
}
return result.stdout.toString();
}
function commitId(name: string): string {
const sha = requiredEnv(name);
if (!fullCommitId.test(sha)) {
throw Error(`${name} must be a full hexadecimal commit ID`);
}
try {
if (git("cat-file", "-t", sha).trim() !== "commit") {
throw Error("object is not a commit");
}
} catch (error) {
throw Error(
`${name}: ${error instanceof Error ? error.message : String(error)}`,
);
}
return sha;
}
function changedPaths(
base: string,
head: string,
): { mergeBase: string; paths: string[] } {
const mergeBase = git("merge-base", base, head).trim();
if (!fullCommitId.test(mergeBase))
throw Error("Invalid git merge-base output");
// Disabling rename detection keeps both the deleted and added path in scope.
const diff = git(
"diff",
"--name-only",
"--no-renames",
"-z",
mergeBase,
head,
"--",
);
if (diff === "") return { mergeBase, paths: [] };
if (!diff.endsWith("\0")) throw Error("Git diff is not NUL-terminated");
const paths = diff.slice(0, -1).split("\0");
if (paths.some((path) => path === ""))
throw Error("Git diff contains an empty path");
return { mergeBase, paths };
}
function isProse(path: string): boolean {
return (
rootDocs.has(path) ||
((path.startsWith("docs/") || path.startsWith("devlog/")) &&
path.endsWith(".md"))
);
}
function main(): void {
const baseRef = requiredEnv("BASE_REF");
const headRef = requiredEnv("HEAD_REF");
const baseRepo = requiredEnv("BASE_REPO");
const headRepo = requiredEnv("HEAD_REPO");
const base = commitId("BASE_SHA");
const head = commitId("HEAD_SHA");
const release = baseRef === "main";
if (release && (headRef !== "dev" || headRepo !== baseRepo)) {
throw Error("main requires a same-repository dev promotion");
}
const { mergeBase, paths } = changedPaths(base, head);
git("diff", "--check", mergeBase, "HEAD", "--");
const candidate = git("rev-parse", "HEAD").trim();
const app = release || paths.length === 0 || !paths.every(isProse);
const output = `app=${app}\nrelease=${release}\n`;
const summary = [
"## CI selection",
`Base commit: \`${base}\``,
`Head commit: \`${head}\``,
`Merge base: \`${mergeBase}\``,
`Merge candidate: \`${candidate}\``,
`Changed paths: ${paths.length}`,
`Application checks: ${app}; release: ${release}`,
"",
].join("\n");
const summaryFile = process.env["GITHUB_STEP_SUMMARY"];
if (summaryFile) appendFileSync(summaryFile, summary);
const outputFile = process.env["GITHUB_OUTPUT"];
if (outputFile) appendFileSync(outputFile, output);
process.stdout.write(output);
}
if (import.meta.main) {
try {
main();
} catch (error) {
console.error(
"[ci-plan]",
error instanceof Error ? error.message : String(error),
);
process.exitCode = 1;
}
}