diff --git a/docs/ci-and-releases.md b/docs/ci-and-releases.md index 7a3be32..575aa81 100644 --- a/docs/ci-and-releases.md +++ b/docs/ci-and-releases.md @@ -36,6 +36,32 @@ github: refers to this repository. The copies are yours: repokeeper writes them once and never changes them, so updates are yours to take, by comparing with a newer `repokeeper eject --to`. +## Pull requests that change only documentation + +A pull request that only fixes a typo in the README still waits for every stack job. To skip +them, list the paths that cannot change what the jobs test: + +```yaml +github: + ci: + skip_when_only: + - "*.md" # Markdown at the root only, as in GitHub's path filters + - "docs/**" +``` + +`ci.yml` then gets a `changes` job that asks the API for the pull request's files. When every +file matches, the stack jobs are skipped; GitHub counts a skipped job as passed. The commit +message check and the drift check still run on every pull request, and pushes to the default +branch always run everything. If the `changes` job cannot tell (the API call fails, say), the stack +jobs run. + +- Leave out any file your tests read. A test that checks the README's examples, for instance, + needs `README.md` kept out of the list. +- A skipped job that calls a reusable workflow reports only its own name, never the checks inside + it (`node / test (22)`), so a required check on a stack job would wait forever. repokeeper refuses + such a `github.protect.required_checks` entry while `skip_when_only` is set. +- GitHub only: GitLab's `rules:changes` can say "any of these changed", not "only these". + ## Shell and PowerShell scripts The script stack runs ShellCheck and `shfmt -d` on `*.sh` (format with `shfmt -w` before pushing) diff --git a/src/config/load.ts b/src/config/load.ts index 090fd2e..946549b 100644 --- a/src/config/load.ts +++ b/src/config/load.ts @@ -42,6 +42,21 @@ export function parseConfig(text: string): RepokeeperConfig { `${CONFIG_FILE}:${lineOf(doc, lineCounter, [other])}: ${other} is not used on the ${typed.platform} platform`, ); } + const skipped = typed.github?.ci?.skip_when_only; + const protect = typed.github?.protect; + if (skipped && protect) { + // a skipped job that calls a reusable workflow reports only its own name, never the checks inside it + const gated = protect.required_checks?.find((check) => + typed.stacks.some((stack) => check === stack || check.startsWith(`${stack} / `)), + ); + if (gated) { + throw new ConfigError( + `${CONFIG_FILE}:${lineOf(doc, lineCounter, ["github", "protect", "required_checks"])}: required_checks names ` + + `"${gated}", a stack job that github.ci.skip_when_only skips on docs-only pull requests; ` + + "such a pull request would wait for it forever", + ); + } + } return withDefaults(typed); } diff --git a/src/config/schema.ts b/src/config/schema.ts index 39bb4bb..f7929b9 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -62,6 +62,19 @@ export const configSchema = { ref: { type: "string", pattern: "^[A-Za-z0-9_./-]+$" }, }, }, + ci: { + type: "object", + additionalProperties: false, + properties: { + // globs; a pull request that changes only matching paths skips the stack jobs + skip_when_only: { + type: "array", + minItems: 1, + uniqueItems: true, + items: { type: "string", pattern: "^[^'\\s]+$" }, + }, + }, + }, description: { type: "string" }, topics: { type: "array", uniqueItems: true, items: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,49}$" } }, merge: { diff --git a/src/github/settings.ts b/src/github/settings.ts index 5bec12c..ce83dff 100644 --- a/src/github/settings.ts +++ b/src/github/settings.ts @@ -9,6 +9,15 @@ export interface GithubSettings { * setting of the repository on GitHub: `github apply` leaves it alone. */ workflows?: WorkflowSource; + /** How the generated CI workflow runs. Not a setting of the repository either. */ + ci?: { + /** + * Globs, read as GitHub's path filters. A pull request that changes only matching paths + * skips the stack jobs; the commit message and drift checks still run, so a required check + * on them is still reported. GitHub only: GitLab's `rules:changes` cannot say "only". + */ + skip_when_only?: string[]; + }; description?: string; topics?: string[]; merge?: { squash?: boolean; merge_commit?: boolean; rebase?: boolean; delete_branch_on_merge?: boolean }; diff --git a/src/glob.ts b/src/glob.ts new file mode 100644 index 0000000..1c64164 --- /dev/null +++ b/src/glob.ts @@ -0,0 +1,32 @@ +/** + * One POSIX extended regular expression, anchored, that matches a path when any of the globs + * does. The globs read as GitHub's path filters do: `*` is any run of characters within one path + * segment, `**` any depth (and `**` with a slash after it, no directory or any), `?` one + * character other than `/`. + */ +export function globsToRegex(globs: string[]): string { + return `^(${globs.map(globToRegex).join("|")})$`; +} + +function globToRegex(glob: string): string { + let out = ""; + for (let i = 0; i < glob.length; i++) { + const char = glob[i] as string; + if (char === "*" && glob[i + 1] === "*") { + if (glob[i + 2] === "/") { + out += "(.*/)?"; + i += 2; + } else { + out += ".*"; + i += 1; + } + } else if (char === "*") { + out += "[^/]*"; + } else if (char === "?") { + out += "[^/]"; + } else { + out += /[.+^$()[\]{}|\\]/.test(char) ? `\\${char}` : char; + } + } + return out; +} diff --git a/src/platforms/github.ts b/src/platforms/github.ts index 20fa010..3f15e19 100644 --- a/src/platforms/github.ts +++ b/src/platforms/github.ts @@ -2,6 +2,7 @@ import { stringify } from "yaml"; import { CONFIG_FILE } from "../config/load.js"; import { defaultBranch, STACK_IDS, type StackId } from "../config/types.js"; import { ConfigError } from "../errors.js"; +import { globsToRegex } from "../glob.js"; import { type DependencyUpdate, MANAGED_HEADER, @@ -257,10 +258,16 @@ export const githubPlatform: PlatformAdapter = { ciWorkflow(ctx: ModuleContext): Output[] { const path = ".github/workflows/ci.yml"; const jobs: Output[] = []; + const skipWhenOnly = ctx.config.github?.ci?.skip_when_only; + const gated = skipWhenOnly !== undefined && ctx.stacks.some((stack) => stack.ci); + if (gated) { + jobs.push(workflowKey("ci", path, ["jobs", "changes"], changesJob(skipWhenOnly))); + } for (const stack of ctx.stacks) { if (stack.ci) { jobs.push( workflowKey("ci", path, ["jobs", stack.id], { + ...(gated ? { needs: "changes", if: SKIP_DOCS_ONLY } : {}), uses: workflowRef(ctx, stack.ci.workflow), with: stack.directory ? { ...stack.ci.with, "working-directory": stack.directory } : stack.ci.with, }), @@ -435,3 +442,43 @@ export const githubPlatform: PlatformAdapter = { ]; }, }; + +// Runs the stack job unless the changes job said "only listed paths". A changes job that failed +// gives no answer, and then the stack job runs: an error never skips the tests. +// biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, not a JS template +const SKIP_DOCS_ONLY = "${{ !cancelled() && needs.changes.outputs.code != 'false' }}"; + +/** + * The job that tells whether a pull request changes anything outside `globs`. It asks the API for + * the pull request's files instead of checking out the history, which also works for a fork. + */ +function changesJob(globs: string[]): Record { + return { + "runs-on": "ubuntu-latest", + permissions: { "pull-requests": "read" }, + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, not a JS template + outputs: { code: "${{ steps.files.outputs.code }}" }, + steps: [ + { + id: "files", + env: { + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, not a JS template + GH_TOKEN: "${{ github.token }}", + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, not a JS template + EVENT: "${{ github.event_name }}", + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression, not a JS template + PR: "${{ github.event.pull_request.number }}", + }, + run: [ + "code=true", + 'if [ "$EVENT" = pull_request ] && files=$(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR/files" --jq \'.[].filename\'); then', + " printf '%s\\n' \"$files\"", + ` if [ -n "$files" ] && ! printf '%s\\n' "$files" | grep -Evq '${globsToRegex(globs)}'; then code=false; fi`, + "fi", + 'echo "code=$code" >> "$GITHUB_OUTPUT"', + "", + ].join("\n"), + }, + ], + }; +} diff --git a/test/config.test.ts b/test/config.test.ts index 10e391d..de92efc 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -57,6 +57,20 @@ describe("parseConfig", () => { expect(errorOf(text)).toBe(".repokeeper.yml:7: modules.hoks is not a known key"); }); + it("refuses a required check on a stack job that docs-only pull requests skip", () => { + const base = "schema: 1\nstandard: 1.0.0\nplatform: github\nstacks: [node]\nmodules:\n health: false\n"; + const github = + "github:\n ci:\n skip_when_only: ['*.md']\n protect:\n required_checks: ['node / test (22)']\n"; + expect(errorOf(base + github)).toMatch(/required_checks.*node \/ test \(22\).*skip_when_only/); + }); + + it("accepts skip_when_only next to required checks outside the stack jobs", () => { + const base = "schema: 1\nstandard: 1.0.0\nplatform: github\nstacks: [node]\nmodules:\n health: false\n"; + const github = + "github:\n ci:\n skip_when_only: ['*.md', 'docs/**']\n protect:\n required_checks: ['commits / commitlint']\n"; + expect(parseConfig(base + github).github?.ci).toEqual({ skip_when_only: ["*.md", "docs/**"] }); + }); + it("checks the github section key by key", () => { const base = "schema: 1\nstandard: 1.0.0\nplatform: github\nstacks: [node]\nmodules:\n health: false\n"; expect(errorOf(`${base}github:\n merge:\n squosh: true\n`)).toBe( diff --git a/test/glob.test.ts b/test/glob.test.ts new file mode 100644 index 0000000..646dace --- /dev/null +++ b/test/glob.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { globsToRegex } from "../src/glob.js"; + +describe("globsToRegex", () => { + const matches = (globs: string[], path: string) => new RegExp(globsToRegex(globs)).test(path); + + it("reads * as one path segment and ** as any depth, like GitHub's path filters", () => { + const docs = ["*.md", "docs/**", "LICENSE"]; + expect(matches(docs, "README.md")).toBe(true); + expect(matches(docs, "docs/guide/setup.md")).toBe(true); + expect(matches(docs, "LICENSE")).toBe(true); + expect(matches(docs, "src/README.md")).toBe(false); + expect(matches(docs, "LICENSE.txt")).toBe(false); + expect(matches(docs, "src/index.ts")).toBe(false); + }); + + it("matches **/ as no directory or any", () => { + expect(matches(["**/*.md"], "README.md")).toBe(true); + expect(matches(["**/*.md"], "a/b/c.md")).toBe(true); + expect(matches(["**/*.md"], "a/b/c.ts")).toBe(false); + }); + + it("escapes what a regular expression would read", () => { + expect(matches(["a+b.(txt)"], "a+b.(txt)")).toBe(true); + expect(matches(["a+b.(txt)"], "aab.(txt)")).toBe(false); + }); +}); diff --git a/test/modules-ci-release.test.ts b/test/modules-ci-release.test.ts index 8b2f727..9b2d155 100644 --- a/test/modules-ci-release.test.ts +++ b/test/modules-ci-release.test.ts @@ -57,6 +57,36 @@ describe("ci module", () => { }); }); + it("skips the stack jobs of a pull request that changes only the listed paths, when asked", () => { + const ctx = makeContext({ + config: { github: { ci: { skip_when_only: ["*.md", "docs/**"] } } }, + modules: { drift: true }, + }); + const out = keys(ciModule.outputs(ctx)); + expect(out["jobs.changes"]).toMatchObject({ + "runs-on": "ubuntu-latest", + permissions: { "pull-requests": "read" }, + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression + outputs: { code: "${{ steps.files.outputs.code }}" }, + }); + const script = (out["jobs.changes"] as { steps: { run: string }[] }).steps[0]?.run ?? ""; + expect(script).toContain("grep -Evq '^([^/]*\\.md|docs/.*)$'"); + expect(out["jobs.node"]).toMatchObject({ + needs: "changes", + // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitHub Actions expression + if: "${{ !cancelled() && needs.changes.outputs.code != 'false' }}", + }); + // the required check and the drift check run on every pull request + expect(out["jobs.commits"]).not.toHaveProperty("needs"); + expect(out["jobs.repokeeper"]).not.toHaveProperty("needs"); + }); + + it("leaves the stack jobs ungated without the setting", () => { + const out = keys(ciModule.outputs(makeContext())); + expect(out["jobs.changes"]).toBeUndefined(); + expect(out["jobs.node"]).not.toHaveProperty("needs"); + }); + it("produces nothing when no job would run", () => { const ctx = makeContext({ modules: { commits: false }, stacks: [nodeResolved({ ci: null })] }); expect(ciModule.outputs(ctx)).toEqual([]);