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 incidents | 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. */ | |
| 16 | export const STARTER_PATH = ".g1t/workflows/ci.yml"; | |
| 17 | /** The check it reports. */ | |
| 18 | export const STARTER_CHECK = "CI"; | |
| 19 | ||
| 20 | export 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. */ | |
| 23 | export 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 | ||
| 30 | const 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. */ | |
| 47 | export 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. */ | |
| 52 | export 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. */ | |
| 75 | function 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:`. */ | |
| 86 | type Step = string[]; | |
| 87 | ||
| 88 | const 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 | ]; | |
| 92 | const 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. */ | |
| 95 | function 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. */ | |
| 138 | function 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. */ | |
| 226 | export 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. */ | |
| 262 | export 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`. */ | |
| 272 | export 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 | } |