g1t/apps/web/app/lib/starter-workflow.ts

276 lines10,881 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Fast pages, required checks on the branch, self-hosted runners, honest incidents1/**
2 * A first CI workflow for a repository that has none, worked out from what
3 * is at its root: one job per stack found (Node, Rust, Go, Python, Ruby,
4 * Java, .NET), each installing, linting where the project says how,
5 * building and testing as that stack usually does. Pure, so it is tested on
6 * its own; `add-ci.server.ts` reads the repository and opens the pull
7 * request.
8 *
9 * The workflow is named `CI`: that is the check it reports, and the name a
10 * branch requires. It runs on pull requests, on pushes to the default
11 * branch, and on the merge queue's states (`merge_group`), so that a
12 * required `CI` can pass everywhere a merge waits for it.
13 */
14
15/** Where the workflow goes. */
16export const STARTER_PATH = ".g1t/workflows/ci.yml";
17/** The check it reports. */
18export const STARTER_CHECK = "CI";
19
20export type Stack = "bun" | "pnpm" | "yarn" | "npm" | "rust" | "go" | "uv" | "python" | "ruby" | "maven" | "gradle" | "dotnet" | "make";
21
22/** What the root of a repository says about how it is built. */
23export type RootFacts = {
24 /** The names of the files and directories at the root. */
25 names: string[];
26 /** The text of `package.json`, when there is one and it could be read. */
27 packageJson?: string | null;
28};
29
30const LABELS: Record<Stack, string> = {
31 bun: "Bun",
32 pnpm: "Node (pnpm)",
33 yarn: "Node (Yarn)",
34 npm: "Node (npm)",
35 rust: "Rust",
36 go: "Go",
37 uv: "Python (uv)",
38 python: "Python",
39 ruby: "Ruby",
40 maven: "Java (Maven)",
41 gradle: "Java (Gradle)",
42 dotnet: ".NET",
43 make: "Make",
44};
45
46/** A stack's name, for people. */
47export function stackLabel(stack: Stack): string {
48 return LABELS[stack];
49}
50
51/** The stacks at a repository's root, most telling first; at most three. */
52export function detectStacks({ names }: RootFacts): Stack[] {
53 const has = (name: string) => names.includes(name);
54 const any = (test: (name: string) => boolean) => names.some(test);
55 const found: Stack[] = [];
56 if (has("package.json")) {
57 if (has("bun.lockb") || has("bun.lock")) found.push("bun");
58 else if (has("pnpm-lock.yaml")) found.push("pnpm");
59 else if (has("yarn.lock")) found.push("yarn");
60 else found.push("npm");
61 }
62 if (has("Cargo.toml")) found.push("rust");
63 if (has("go.mod")) found.push("go");
64 if (has("uv.lock")) found.push("uv");
65 else if (has("pyproject.toml") || has("requirements.txt") || has("setup.py")) found.push("python");
66 if (has("Gemfile")) found.push("ruby");
67 if (has("pom.xml")) found.push("maven");
68 else if (has("build.gradle") || has("build.gradle.kts")) found.push("gradle");
69 if (any((name) => name.endsWith(".sln") || name.endsWith(".csproj") || name.endsWith(".fsproj"))) found.push("dotnet");
70 if (found.length === 0 && (has("Makefile") || has("makefile"))) found.push("make");
71 return found.slice(0, 3);
72}
73
74/** The scripts a `package.json` names, or null when it cannot be read. */
75function scripts(packageJson: string | null | undefined): Set<string> | null {
76 if (!packageJson) return null;
77 try {
78 const parsed = JSON.parse(packageJson) as { scripts?: Record<string, unknown> };
79 return new Set(Object.keys(parsed.scripts ?? {}));
80 } catch {
81 return null;
82 }
83}
84
85/** One step, as YAML lines indented for a job's `steps:`. */
86type Step = string[];
87
88const uses = (action: string, name?: string, with_?: Record<string, string>): Step => [
89 ...(name ? [`- name: ${name}`, ` uses: ${action}`] : [`- uses: ${action}`]),
90 ...(with_ ? [" with:", ...Object.entries(with_).map(([key, value]) => ` ${key}: ${value}`)] : []),
91];
92const run = (command: string, name?: string): Step => (name ? [`- name: ${name}`, ` run: ${command}`] : [`- run: ${command}`]);
93
94/** The steps that lint, build and test a Node project, with the runner it uses. */
95function nodeSteps(stack: "bun" | "pnpm" | "yarn" | "npm", facts: RootFacts): Step[] {
96 const known = scripts(facts.packageJson);
97 // Only the scripts it has; when they cannot be read, the ones that exist run.
98 const wanted = ["lint", "typecheck", "build", "test"].filter((script) => !known || known.has(script));
99 const missing = known != null && wanted.length === 0;
100 const steps: Step[] = [];
101 if (stack === "bun") {
102 steps.push(uses("oven-sh/setup-bun@v2"));
103 steps.push(run("bun install --frozen-lockfile"));
104 for (const script of wanted) steps.push(run(`bun run ${script}`));
105 } else {
106 if (stack === "pnpm") steps.push(uses("pnpm/action-setup@v4"));
107 steps.push(uses("actions/setup-node@v5", undefined, { "node-version": "24", cache: stack }));
108 const install =
109 stack === "pnpm"
110 ? "pnpm install --frozen-lockfile"
111 : stack === "yarn"
112 ? "yarn install --frozen-lockfile"
113 : facts.names.includes("package-lock.json")
114 ? "npm ci"
115 : "npm install";
116 steps.push(run(install));
117 for (const script of wanted) {
118 steps.push(
119 run(
120 stack === "npm"
121 ? known
122 ? `npm run ${script}`
123 : `npm run ${script} --if-present`
124 : stack === "pnpm"
125 ? known
126 ? `pnpm run ${script}`
127 : `pnpm run --if-present ${script}`
128 : `yarn run ${script}`,
129 ),
130 );
131 }
132 }
133 if (missing) steps.push(run('echo "package.json has no lint, build or test script yet: add the ones this project needs."'));
134 return steps;
135}
136
137/** Each stack's job: its key, its name, and its steps after the checkout. */
138function job(stack: Stack, facts: RootFacts): { key: string; name: string; steps: Step[] } {
139 const has = (name: string) => facts.names.includes(name);
140 switch (stack) {
141 case "bun":
142 case "pnpm":
143 case "yarn":
144 case "npm":
145 return { key: "node", name: "Node", steps: nodeSteps(stack, facts) };
146 case "rust":
147 return {
148 key: "rust",
149 name: "Rust",
150 steps: [
151 uses("dtolnay/rust-toolchain@stable", undefined, { components: "clippy" }),
152 uses("Swatinem/rust-cache@v2"),
153 run("cargo clippy --all-targets"),
154 run("cargo test --all-targets"),
155 ],
156 };
157 case "go":
158 return {
159 key: "go",
160 name: "Go",
161 steps: [
162 uses("actions/setup-go@v5", undefined, { "go-version-file": "go.mod" }),
163 run("go vet ./..."),
164 run("go build ./..."),
165 run("go test ./..."),
166 ],
167 };
168 case "uv":
169 return {
170 key: "python",
171 name: "Python",
172 steps: [uses("astral-sh/setup-uv@v6"), run("uv sync"), run("uv run pytest")],
173 };
174 case "python": {
175 const install = has("requirements.txt")
176 ? "pip install -r requirements.txt pytest"
177 : "pip install -e . pytest";
178 return {
179 key: "python",
180 name: "Python",
181 steps: [uses("actions/setup-python@v5", undefined, { "python-version": '"3.12"', cache: "pip" }), run(install), run("pytest")],
182 };
183 }
184 case "ruby":
185 return {
186 key: "ruby",
187 name: "Ruby",
188 steps: [
189 uses("ruby/setup-ruby@v1", undefined, { "bundler-cache": "true" }),
190 run(has("Rakefile") ? "bundle exec rake" : "bundle exec rspec"),
191 ],
192 };
193 case "maven":
194 return {
195 key: "java",
196 name: "Java",
197 steps: [uses("actions/setup-java@v4", undefined, { distribution: "temurin", "java-version": '"21"', cache: "maven" }), run("mvn -B verify")],
198 };
199 case "gradle":
200 return {
201 key: "java",
202 name: "Java",
203 steps: [
204 uses("actions/setup-java@v4", undefined, { distribution: "temurin", "java-version": '"21"' }),
205 uses("gradle/actions/setup-gradle@v4"),
206 run(has("gradlew") ? "./gradlew build" : "gradle build"),
207 ],
208 };
209 case "dotnet":
210 return {
211 key: "dotnet",
212 name: ".NET",
213 steps: [
214 uses("actions/setup-dotnet@v4", undefined, { "dotnet-version": "8.0.x" }),
215 run("dotnet restore"),
216 run("dotnet build --no-restore"),
217 run("dotnet test --no-build"),
218 ],
219 };
220 case "make":
221 return { key: "build", name: "Build", steps: [run("make"), run("make test")] };
222 }
223}
224
225/** The starter workflow for a repository, and the stacks it was made for. */
226export function starterWorkflow(facts: RootFacts, defaultBranch: string): { yaml: string; stacks: Stack[] } {
227 const stacks = detectStacks(facts);
228 const jobs = stacks.map((stack) => job(stack, facts));
229 const lines = [
230 `# ${STARTER_CHECK}: what g1t checks on every pull request before it can merge.`,
231 "# Generated for this repository; change the steps to match how you build and test.",
232 `# Require it in Settings > Branches and merging once it has run.`,
233 `name: ${STARTER_CHECK}`,
234 "",
235 "on:",
236 " pull_request:",
237 " push:",
238 ` branches: [${JSON.stringify(defaultBranch)}]`,
239 " merge_group:",
240 "",
241 "jobs:",
242 ];
243 if (jobs.length === 0) {
244 lines.push(
245 " check:",
246 " runs-on: ubuntu-latest",
247 " steps:",
248 " - uses: actions/checkout@v5",
249 " # g1t could not tell how this project is built. Replace this step",
250 " # with the commands that build and test it.",
251 ' - run: echo "Add the commands that build and test this project." && exit 1',
252 );
253 }
254 for (const { key, name, steps } of jobs) {
255 lines.push(` ${key}:`, ` name: ${name}`, " runs-on: ubuntu-latest", " steps:", " - uses: actions/checkout@v5");
256 for (const step of steps) for (const line of step) lines.push(` ${line}`);
257 }
258 return { yaml: `${lines.join("\n")}\n`, stacks };
259}
260
261/** What the pull request that adds it says. */
262export function starterDescription(stacks: Stack[], defaultBranch: string): string {
263 const found = stacks.length > 0 ? `g1t found ${stacks.map(stackLabel).join(", ")} at the root and wrote a job for each.` : "g1t could not tell how this project is built, so the job is a placeholder: replace its last step with the commands that build and test it.";
264 return [
265 `Adds \`${STARTER_PATH}\`, a workflow named **${STARTER_CHECK}** that runs on every pull request, on pushes to \`${defaultBranch}\`, and on the merge queue. ${found}`,
266 "Its runs are this repository's checks: they show on every pull request, whoever opened it, and an agent whose change fails them is sent back with what the failing jobs printed.",
267 `Once this has run, require **${STARTER_CHECK}** in Settings, Branches and merging, so that nothing merges into \`${defaultBranch}\` unless it passes.`,
268 ].join("\n\n");
269}
270
271/** A branch name for the pull request that is not one of `taken`. */
272export function freeBranch(taken: string[], base = "add-ci"): string {
273 const names = new Set(taken);
274 if (!names.has(base)) return base;
275 for (let n = 2; ; n += 1) if (!names.has(`${base}-${n}`)) return `${base}-${n}`;
276}