Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

269 linesCodeBlameRaw
1/**
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: [uses("actions/setup-go@v5", undefined, { "go-version-file": "go.mod" }), run("go vet ./..."), run("go build ./..."), run("go test ./...")],
162 };
163 case "uv":
164 return {
165 key: "python",
166 name: "Python",
167 steps: [uses("astral-sh/setup-uv@v6"), run("uv sync"), run("uv run pytest")],
168 };
169 case "python": {
170 const install = has("requirements.txt") ? "pip install -r requirements.txt pytest" : "pip install -e . pytest";
171 return {
172 key: "python",
173 name: "Python",
174 steps: [uses("actions/setup-python@v5", undefined, { "python-version": '"3.12"', cache: "pip" }), run(install), run("pytest")],
175 };
176 }
177 case "ruby":
178 return {
179 key: "ruby",
180 name: "Ruby",
181 steps: [uses("ruby/setup-ruby@v1", undefined, { "bundler-cache": "true" }), run(has("Rakefile") ? "bundle exec rake" : "bundle exec rspec")],
182 };
183 case "maven":
184 return {
185 key: "java",
186 name: "Java",
187 steps: [uses("actions/setup-java@v4", undefined, { distribution: "temurin", "java-version": '"21"', cache: "maven" }), run("mvn -B verify")],
188 };
189 case "gradle":
190 return {
191 key: "java",
192 name: "Java",
193 steps: [
194 uses("actions/setup-java@v4", undefined, { distribution: "temurin", "java-version": '"21"' }),
195 uses("gradle/actions/setup-gradle@v4"),
196 run(has("gradlew") ? "./gradlew build" : "gradle build"),
197 ],
198 };
199 case "dotnet":
200 return {
201 key: "dotnet",
202 name: ".NET",
203 steps: [
204 uses("actions/setup-dotnet@v4", undefined, { "dotnet-version": "8.0.x" }),
205 run("dotnet restore"),
206 run("dotnet build --no-restore"),
207 run("dotnet test --no-build"),
208 ],
209 };
210 case "make":
211 return { key: "build", name: "Build", steps: [run("make"), run("make test")] };
212 }
213}
214
215/** The starter workflow for a repository, and the stacks it was made for. */
216export function starterWorkflow(facts: RootFacts, defaultBranch: string): { yaml: string; stacks: Stack[] } {
217 const stacks = detectStacks(facts);
218 const jobs = stacks.map((stack) => job(stack, facts));
219 const lines = [
220 `# ${STARTER_CHECK}: what g1t checks on every pull request before it can merge.`,
221 "# Generated for this repository; change the steps to match how you build and test.",
222 `# Require it in Settings > Branches and merging once it has run.`,
223 `name: ${STARTER_CHECK}`,
224 "",
225 "on:",
226 " pull_request:",
227 " push:",
228 ` branches: [${JSON.stringify(defaultBranch)}]`,
229 " merge_group:",
230 "",
231 "jobs:",
232 ];
233 if (jobs.length === 0) {
234 lines.push(
235 " check:",
236 " runs-on: ubuntu-latest",
237 " steps:",
238 " - uses: actions/checkout@v5",
239 " # g1t could not tell how this project is built. Replace this step",
240 " # with the commands that build and test it.",
241 ' - run: echo "Add the commands that build and test this project." && exit 1',
242 );
243 }
244 for (const { key, name, steps } of jobs) {
245 lines.push(` ${key}:`, ` name: ${name}`, " runs-on: ubuntu-latest", " steps:", " - uses: actions/checkout@v5");
246 for (const step of steps) for (const line of step) lines.push(` ${line}`);
247 }
248 return { yaml: `${lines.join("\n")}\n`, stacks };
249}
250
251/** What the pull request that adds it says. */
252export function starterDescription(stacks: Stack[], defaultBranch: string): string {
253 const found =
254 stacks.length > 0
255 ? `g1t found ${stacks.map(stackLabel).join(", ")} at the root and wrote a job for each.`
256 : "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.";
257 return [
258 `Adds \`${STARTER_PATH}\`, a workflow named **${STARTER_CHECK}** that runs on every pull request, on pushes to \`${defaultBranch}\`, and on the merge queue. ${found}`,
259 "Its runs are this repository's checks: they show on every pull request, whoever or whatever opened it.",
260 `Once this has run, require **${STARTER_CHECK}** in Settings, Branches and merging, so that nothing merges into \`${defaultBranch}\` unless it passes.`,
261 ].join("\n\n");
262}
263
264/** A branch name for the pull request that is not one of `taken`. */
265export function freeBranch(taken: string[], base = "add-ci"): string {
266 const names = new Set(taken);
267 if (!names.has(base)) return base;
268 for (let n = 2; ; n += 1) if (!names.has(`${base}-${n}`)) return `${base}-${n}`;
269}