Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions docs/ci-and-releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions src/config/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
13 changes: 13 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
9 changes: 9 additions & 0 deletions src/github/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
32 changes: 32 additions & 0 deletions src/glob.ts
Original file line number Diff line number Diff line change
@@ -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;
}
47 changes: 47 additions & 0 deletions src/platforms/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}),
Expand Down Expand Up @@ -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<string, unknown> {
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"),
},
],
};
}
14 changes: 14 additions & 0 deletions test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
27 changes: 27 additions & 0 deletions test/glob.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
30 changes: 30 additions & 0 deletions test/modules-ci-release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down
Loading