repokeeper 0.2.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,8 +5,9 @@ files, editor and gitignore settings, Dependabot, CI and releases — applied on
5
5
  the standard evolves.
6
6
 
7
7
  > Status: early development. Supported stacks: Node.js (including NestJS), Python, Dart and
8
- > Flutter, shell and PowerShell scripts, Java (Maven and Gradle) and .NET, each with CI and releases.
9
- > GitHub settings are on the way. See the [design](docs/superpowers/specs/2026-09-25-repokeeper-design.md).
8
+ > Flutter, shell and PowerShell scripts, Java (Maven and Gradle), Kotlin, .NET, Go, Rust, PHP and
9
+ > Ruby, each with CI and releases, plus GitHub settings through `repokeeper github apply`. See the
10
+ > [design](docs/superpowers/specs/2026-09-25-repokeeper-design.md).
10
11
 
11
12
  ## Usage
12
13
 
@@ -51,15 +52,19 @@ Requires Node.js 22.12 or newer.
51
52
 
52
53
  `ci.yml` and `release.yml` call reusable workflows from this repository (`stack-node.yml`,
53
54
  `commitlint.yml`, `release-please.yml`) at the moving major tag, so fixes reach every repository
54
- without a pull request. repokeeper owns the `name`, `on` and `permissions` keys and the jobs it
55
- adds; jobs you add yourself are left alone, and so is the formatting of the rest of the file.
55
+ without a pull request. repokeeper owns the `name`, `on`, `permissions` and `concurrency` keys and
56
+ the jobs it adds; jobs you add yourself are left alone, and so is the formatting of the rest of the
57
+ file. A new push to a pull request cancels that pull request's earlier `ci` run; runs on the default
58
+ branch always finish.
56
59
 
57
60
  The script stack runs ShellCheck and `shfmt -d` on `*.sh` (format with `shfmt -w` before pushing)
58
61
  and PSScriptAnalyzer on `*.ps1`, which fails on errors and warnings (not on information-level rules). To
59
- choose the rules yourself, add a `PSScriptAnalyzerSettings.psd1` at the repository root; the job then uses it:
62
+ choose the rules yourself, add a `PSScriptAnalyzerSettings.psd1` at the repository root; the job then
63
+ uses it instead of its own filter, so keep `Severity` in it unless you want information-level rules too:
60
64
 
61
65
  ```powershell
62
66
  @{
67
+ Severity = @('Error', 'Warning')
63
68
  # installers print for the person running them
64
69
  ExcludeRules = @('PSAvoidUsingWriteHost')
65
70
  }
@@ -75,6 +80,33 @@ work:
75
80
  and issues write access). Without it the release pull request is opened with `GITHUB_TOKEN`, and
76
81
  GitHub does not run CI on pull requests opened that way.
77
82
 
83
+ Set `modules.drift: true` to add a `repokeeper` job to `ci.yml` that runs `repokeeper check` with
84
+ the version that wrote the standard, so a pull request that edits a managed file fails until the edit
85
+ is resolved.
86
+
87
+ ## GitHub settings
88
+
89
+ `repokeeper github apply` brings the repository's settings in line with the `github:` section of
90
+ `.repokeeper.yml`. Only the keys you write are managed; anything left out stays as it is.
91
+
92
+ ```yaml
93
+ github:
94
+ description: Keeps repositories on one standard
95
+ topics: [cli, conventional-commits]
96
+ merge: { squash: true, merge_commit: false, rebase: false, delete_branch_on_merge: true }
97
+ security: { dependabot_alerts: true, dependabot_security_updates: true }
98
+ protect: # a ruleset named "repokeeper" on the default branch; false removes it
99
+ require_pull_request: true
100
+ required_approvals: 0
101
+ required_checks: ["commits / commitlint"] # check names exactly as pull requests show them
102
+ allow_force_push: false
103
+ ```
104
+
105
+ It prints every change first and applies them with `--yes`, or after you confirm in a terminal;
106
+ `--dry-run` only prints. The token comes from `GITHUB_TOKEN` or `gh auth token` and needs admin
107
+ access to the repository. Legacy branch protection, visibility, secrets and collaborators are never
108
+ touched.
109
+
78
110
  ## License
79
111
 
80
112
  MIT
package/dist/cli.js CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from "node:fs";
3
+ import { createInterface } from "node:readline/promises";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { parseArgs } from "node:util";
5
6
  import { checkCommand } from "./commands/check.js";
7
+ import { githubApplyCommand } from "./commands/github.js";
6
8
  import { initCommand } from "./commands/init.js";
7
9
  import { updateCommand } from "./commands/update.js";
8
10
  import { STACK_IDS } from "./config/types.js";
@@ -15,6 +17,7 @@ export const USAGE = [
15
17
  " init detect stacks, write .repokeeper.yml and apply the standard",
16
18
  " check report drift from the standard without writing (exit 1 on drift)",
17
19
  " update move to the standard of this repokeeper version and resync",
20
+ " github apply diff the GitHub settings against .repokeeper.yml and apply them",
18
21
  "",
19
22
  "options:",
20
23
  " --dry-run show what would change without writing",
@@ -24,6 +27,7 @@ export const USAGE = [
24
27
  " --stack <id> stack to use instead of detection (init, repeatable)",
25
28
  " --relock rebuild .repokeeper/lock.json from the current files (init)",
26
29
  " --json machine-readable output (check)",
30
+ " -y, --yes apply without asking (github apply)",
27
31
  " -v, --version print the version",
28
32
  ].join("\n");
29
33
  const toPosix = (path) => path.replace(/\\/g, "/").replace(/^\.\//, "");
@@ -42,6 +46,7 @@ export async function run(argv, io) {
42
46
  stack: { type: "string", multiple: true, default: [] },
43
47
  relock: { type: "boolean", default: false },
44
48
  json: { type: "boolean", default: false },
49
+ yes: { type: "boolean", short: "y", default: false },
45
50
  version: { type: "boolean", short: "v", default: false },
46
51
  help: { type: "boolean", short: "h", default: false },
47
52
  },
@@ -70,6 +75,7 @@ export async function run(argv, io) {
70
75
  stacks: stacks,
71
76
  relock: values.relock,
72
77
  json: values.json,
78
+ yes: values.yes,
73
79
  };
74
80
  if (command === "init")
75
81
  return await initCommand(io.cwd, options, io);
@@ -77,6 +83,11 @@ export async function run(argv, io) {
77
83
  return await checkCommand(io.cwd, options, io);
78
84
  if (command === "update")
79
85
  return await updateCommand(io.cwd, options, io);
86
+ if (command === "github") {
87
+ if (positionals[1] !== "apply")
88
+ throw new UsageError("usage: repokeeper github apply [--dry-run] [--yes]");
89
+ return await githubApplyCommand(io.cwd, options, io);
90
+ }
80
91
  throw new UsageError(`unknown command: ${command}`);
81
92
  }
82
93
  catch (error) {
@@ -94,12 +105,22 @@ export async function run(argv, io) {
94
105
  throw error;
95
106
  }
96
107
  }
108
+ async function askYesNo(question) {
109
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
110
+ try {
111
+ return /^y(es)?$/i.test((await rl.question(`${question} [y/N] `)).trim());
112
+ }
113
+ finally {
114
+ rl.close();
115
+ }
116
+ }
97
117
  const invokedPath = process.argv[1];
98
118
  if (invokedPath !== undefined && realpathSync(invokedPath) === fileURLToPath(import.meta.url)) {
99
119
  run(process.argv.slice(2), {
100
120
  cwd: process.cwd(),
101
121
  out: (line) => process.stdout.write(`${line}\n`),
102
122
  err: (line) => process.stderr.write(`${line}\n`),
123
+ ...(process.stdin.isTTY ? { confirm: askYesNo } : {}),
103
124
  }).then((code) => {
104
125
  process.exitCode = code;
105
126
  });
@@ -0,0 +1,36 @@
1
+ import { loadConfig } from "../config/load.js";
2
+ import { UsageError } from "../errors.js";
3
+ import { repoInfo } from "../git.js";
4
+ import { resolveToken, restApi } from "../github/api.js";
5
+ import { planGithub } from "../github/settings.js";
6
+ /** `repokeeper github apply`: diff the repository's GitHub settings against `github:` and apply on confirmation. */
7
+ export async function githubApplyCommand(root, options, io) {
8
+ const config = await loadConfig(root);
9
+ const repo = await repoInfo(root);
10
+ if (!repo.owner)
11
+ throw new UsageError("the origin remote is not a GitHub repository");
12
+ const slug = `${repo.owner}/${repo.name}`;
13
+ const api = io.githubApi ?? restApi(await resolveToken());
14
+ const changes = await planGithub(api, repo.owner, repo.name, config.github ?? {});
15
+ if (changes.length === 0) {
16
+ io.out(`GitHub settings of ${slug} match .repokeeper.yml`);
17
+ return 0;
18
+ }
19
+ for (const change of changes)
20
+ io.out(`change ${change.setting}: ${change.from} -> ${change.to}`);
21
+ if (options.dryRun) {
22
+ io.out("dry run: nothing applied");
23
+ return 0;
24
+ }
25
+ const question = `apply ${changes.length} change(s) to ${slug}?`;
26
+ const confirmed = options.yes || (io.confirm ? await io.confirm(question) : false);
27
+ if (!confirmed) {
28
+ io.out("nothing applied; pass --yes to apply");
29
+ return 1;
30
+ }
31
+ for (const change of changes) {
32
+ await change.apply(api);
33
+ io.out(`applied ${change.setting}`);
34
+ }
35
+ return 0;
36
+ }
@@ -59,6 +59,7 @@ function withDefaults(data) {
59
59
  release: modules.release ?? true,
60
60
  deps: modules.deps ?? true,
61
61
  gitignore: modules.gitignore ?? true,
62
+ drift: modules.drift ?? false,
62
63
  health: modules.health,
63
64
  },
64
65
  owned: data.owned ?? [],
@@ -20,6 +20,7 @@ export const configSchema = {
20
20
  release: flag,
21
21
  deps: flag,
22
22
  gitignore: flag,
23
+ drift: flag,
23
24
  health: {
24
25
  anyOf: [
25
26
  { const: false },
@@ -40,6 +41,39 @@ export const configSchema = {
40
41
  },
41
42
  owned: { type: "array", items: { type: "string", minLength: 1 } },
42
43
  stack_options: { type: "object" },
43
- github: { type: "object" },
44
+ github: {
45
+ type: "object",
46
+ additionalProperties: false,
47
+ properties: {
48
+ default_branch: { type: "string", minLength: 1 },
49
+ description: { type: "string" },
50
+ topics: { type: "array", uniqueItems: true, items: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,49}$" } },
51
+ merge: {
52
+ type: "object",
53
+ additionalProperties: false,
54
+ properties: { squash: flag, merge_commit: flag, rebase: flag, delete_branch_on_merge: flag },
55
+ },
56
+ security: {
57
+ type: "object",
58
+ additionalProperties: false,
59
+ properties: { dependabot_alerts: flag, dependabot_security_updates: flag },
60
+ },
61
+ protect: {
62
+ anyOf: [
63
+ { const: false },
64
+ {
65
+ type: "object",
66
+ additionalProperties: false,
67
+ properties: {
68
+ require_pull_request: flag,
69
+ required_approvals: { type: "integer", minimum: 0, maximum: 10 },
70
+ required_checks: { type: "array", uniqueItems: true, items: { type: "string", minLength: 1 } },
71
+ allow_force_push: flag,
72
+ },
73
+ },
74
+ ],
75
+ },
76
+ },
77
+ },
44
78
  },
45
79
  };
@@ -1,4 +1,16 @@
1
- export const STACK_IDS = ["node", "python", "dart", "script", "java", "dotnet"];
1
+ export const STACK_IDS = [
2
+ "node",
3
+ "python",
4
+ "dart",
5
+ "script",
6
+ "java",
7
+ "dotnet",
8
+ "go",
9
+ "rust",
10
+ "kotlin",
11
+ "php",
12
+ "ruby",
13
+ ];
2
14
  /** The branch workflows run on and contributors branch from: `github.default_branch`, or `main`. */
3
15
  export function defaultBranch(config) {
4
16
  const branch = config.github?.default_branch;
@@ -18,6 +30,7 @@ export function defaultConfig(input) {
18
30
  release: true,
19
31
  deps: true,
20
32
  gitignore: true,
33
+ drift: false,
21
34
  health: { license: "MIT", copyright: input.copyright, contact: input.contact, codeowners: input.codeowners },
22
35
  },
23
36
  owned: [],
package/dist/errors.js CHANGED
@@ -18,6 +18,12 @@ export class UsageError extends RepokeeperError {
18
18
  super(message, 2);
19
19
  }
20
20
  }
21
+ /** A GitHub API call failed; nothing on disk was touched. */
22
+ export class ApiError extends RepokeeperError {
23
+ constructor(message) {
24
+ super(message, 3);
25
+ }
26
+ }
21
27
  /** `.repokeeper/lock.json` is unreadable; the repository state is unknown, which counts as drift. */
22
28
  export class LockError extends RepokeeperError {
23
29
  constructor(message) {
@@ -0,0 +1,49 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { ApiError } from "../errors.js";
4
+ const execFileAsync = promisify(execFile);
5
+ /** `GITHUB_TOKEN`, or the token of the GitHub CLI. */
6
+ export async function resolveToken(env = process.env) {
7
+ if (env.GITHUB_TOKEN)
8
+ return env.GITHUB_TOKEN;
9
+ try {
10
+ const { stdout } = await execFileAsync("gh", ["auth", "token"], { encoding: "utf8" });
11
+ if (stdout.trim())
12
+ return stdout.trim();
13
+ }
14
+ catch {
15
+ // fall through to the error below
16
+ }
17
+ throw new ApiError("no GitHub token: set GITHUB_TOKEN or sign in with `gh auth login`");
18
+ }
19
+ export function restApi(token, base = "https://api.github.com") {
20
+ return {
21
+ async request(method, path, body) {
22
+ let response;
23
+ try {
24
+ response = await fetch(`${base}${path}`, {
25
+ method,
26
+ headers: {
27
+ accept: "application/vnd.github+json",
28
+ authorization: `Bearer ${token}`,
29
+ "x-github-api-version": "2022-11-28",
30
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
31
+ },
32
+ body: body === undefined ? undefined : JSON.stringify(body),
33
+ });
34
+ }
35
+ catch (error) {
36
+ throw new ApiError(`GitHub API ${method} ${path} failed: ${error.message}`);
37
+ }
38
+ const text = await response.text();
39
+ let data = null;
40
+ try {
41
+ data = text ? JSON.parse(text) : null;
42
+ }
43
+ catch {
44
+ data = text;
45
+ }
46
+ return { status: response.status, data };
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,184 @@
1
+ import { ApiError } from "../errors.js";
2
+ export const RULESET_NAME = "repokeeper";
3
+ function expect2xx(response, what, repo) {
4
+ if (response.status < 400)
5
+ return response;
6
+ const message = response.data?.message ?? `HTTP ${response.status}`;
7
+ const access = response.status === 403 || response.status === 404
8
+ ? `; this needs admin access to ${repo} (a token with the repo scope, or Administration: write)`
9
+ : "";
10
+ throw new ApiError(`${what} failed: ${message}${access}`);
11
+ }
12
+ const show = (value) => value === undefined || value === null || value === ""
13
+ ? "(none)"
14
+ : Array.isArray(value)
15
+ ? value.join(", ") || "(none)"
16
+ : String(value);
17
+ const MERGE_FIELDS = {
18
+ squash: "allow_squash_merge",
19
+ merge_commit: "allow_merge_commit",
20
+ rebase: "allow_rebase_merge",
21
+ delete_branch_on_merge: "delete_branch_on_merge",
22
+ };
23
+ function summarize(rules) {
24
+ const pr = rules.find((r) => r.type === "pull_request");
25
+ const checks = rules.find((r) => r.type === "required_status_checks");
26
+ return {
27
+ pullRequest: pr !== undefined,
28
+ approvals: pr?.parameters?.required_approving_review_count ?? 0,
29
+ checks: (checks?.parameters?.required_status_checks ?? []).map((c) => c.context).sort(),
30
+ forcePush: !rules.some((r) => r.type === "non_fast_forward"),
31
+ };
32
+ }
33
+ function describeRules(summary) {
34
+ if (summary === null)
35
+ return "(none)";
36
+ const parts = [
37
+ summary.pullRequest ? `pull request required (${summary.approvals} approvals)` : "no pull request required",
38
+ summary.checks.length > 0 ? `checks: ${summary.checks.join(", ")}` : "no required checks",
39
+ summary.forcePush ? "force push allowed" : "no force push",
40
+ ];
41
+ return parts.join("; ");
42
+ }
43
+ function desiredRules(protect) {
44
+ const rules = [];
45
+ if (protect.require_pull_request ?? true) {
46
+ rules.push({
47
+ type: "pull_request",
48
+ parameters: {
49
+ required_approving_review_count: protect.required_approvals ?? 0,
50
+ dismiss_stale_reviews_on_push: false,
51
+ require_code_owner_review: false,
52
+ require_last_push_approval: false,
53
+ required_review_thread_resolution: false,
54
+ },
55
+ });
56
+ }
57
+ const checks = protect.required_checks ?? [];
58
+ if (checks.length > 0) {
59
+ rules.push({
60
+ type: "required_status_checks",
61
+ parameters: {
62
+ strict_required_status_checks_policy: false,
63
+ required_status_checks: checks.map((context) => ({ context })),
64
+ },
65
+ });
66
+ }
67
+ if (!(protect.allow_force_push ?? false))
68
+ rules.push({ type: "non_fast_forward" });
69
+ return rules;
70
+ }
71
+ const sameSummary = (a, b) => JSON.stringify(a) === JSON.stringify(b);
72
+ /** Reads the repository's current settings and lists what differs from `settings`. Reads only. */
73
+ export async function planGithub(api, owner, name, settings) {
74
+ const repo = `${owner}/${name}`;
75
+ const base = `/repos/${owner}/${name}`;
76
+ const get = async (path, what, missingOk = false) => {
77
+ const response = await api.request("GET", `${base}${path}`);
78
+ return missingOk && response.status === 404 ? response : expect2xx(response, what, repo);
79
+ };
80
+ const send = async (method, path, what, body) => {
81
+ expect2xx(await api.request(method, `${base}${path}`, body), what, repo);
82
+ };
83
+ const changes = [];
84
+ const current = (await get("", `reading ${repo}`)).data;
85
+ for (const [key, field] of Object.entries(MERGE_FIELDS)) {
86
+ const want = settings.merge?.[key];
87
+ if (want === undefined || current[field] === want)
88
+ continue;
89
+ changes.push({
90
+ setting: `merge.${key}`,
91
+ from: show(current[field]),
92
+ to: show(want),
93
+ apply: () => send("PATCH", "", `setting merge.${key}`, { [field]: want }),
94
+ });
95
+ }
96
+ if (settings.description !== undefined && (current.description ?? "") !== settings.description) {
97
+ const description = settings.description;
98
+ changes.push({
99
+ setting: "description",
100
+ from: show(current.description),
101
+ to: show(description),
102
+ apply: () => send("PATCH", "", "setting the description", { description }),
103
+ });
104
+ }
105
+ if (settings.topics !== undefined) {
106
+ const have = ((await get("/topics", "reading topics")).data.names ?? []).slice().sort();
107
+ const want = [...settings.topics].sort();
108
+ if (JSON.stringify(have) !== JSON.stringify(want)) {
109
+ changes.push({
110
+ setting: "topics",
111
+ from: show(have),
112
+ to: show(want),
113
+ apply: () => send("PUT", "/topics", "setting topics", { names: want }),
114
+ });
115
+ }
116
+ }
117
+ const alerts = settings.security?.dependabot_alerts;
118
+ if (alerts !== undefined) {
119
+ const have = (await get("/vulnerability-alerts", "reading Dependabot alerts", true)).status === 204;
120
+ if (have !== alerts) {
121
+ changes.push({
122
+ setting: "security.dependabot_alerts",
123
+ from: show(have),
124
+ to: show(alerts),
125
+ apply: () => send(alerts ? "PUT" : "DELETE", "/vulnerability-alerts", "setting Dependabot alerts"),
126
+ });
127
+ }
128
+ }
129
+ const fixes = settings.security?.dependabot_security_updates;
130
+ if (fixes !== undefined) {
131
+ const response = await get("/automated-security-fixes", "reading Dependabot security updates", true);
132
+ const have = response.status !== 404 && response.data?.enabled === true;
133
+ if (have !== fixes) {
134
+ changes.push({
135
+ setting: "security.dependabot_security_updates",
136
+ from: show(have),
137
+ to: show(fixes),
138
+ apply: () => send(fixes ? "PUT" : "DELETE", "/automated-security-fixes", "setting Dependabot security updates"),
139
+ });
140
+ }
141
+ }
142
+ if (settings.protect !== undefined) {
143
+ const list = (await get("/rulesets?includes_parents=false", "reading rulesets")).data;
144
+ const existing = list.find((r) => r.name === RULESET_NAME);
145
+ const detail = existing
146
+ ? (await get(`/rulesets/${existing.id}`, "reading the repokeeper ruleset")).data
147
+ : null;
148
+ const have = detail ? summarize(detail.rules ?? []) : null;
149
+ if (settings.protect === false) {
150
+ if (existing) {
151
+ changes.push({
152
+ setting: "protect",
153
+ from: describeRules(have),
154
+ to: "(none)",
155
+ apply: () => send("DELETE", `/rulesets/${existing.id}`, "removing the repokeeper ruleset"),
156
+ });
157
+ }
158
+ }
159
+ else {
160
+ const rules = desiredRules(settings.protect);
161
+ const want = summarize(rules);
162
+ const targetsDefault = detail?.conditions?.ref_name?.include?.includes("~DEFAULT_BRANCH") ?? false;
163
+ const upToDate = have !== null && sameSummary(have, want) && detail?.enforcement === "active" && targetsDefault;
164
+ if (!upToDate) {
165
+ const body = {
166
+ name: RULESET_NAME,
167
+ target: "branch",
168
+ enforcement: "active",
169
+ conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
170
+ rules,
171
+ };
172
+ changes.push({
173
+ setting: "protect",
174
+ from: describeRules(have),
175
+ to: describeRules(want),
176
+ apply: () => existing
177
+ ? send("PUT", `/rulesets/${existing.id}`, "updating the repokeeper ruleset", body)
178
+ : send("POST", "/rulesets", "creating the repokeeper ruleset", body),
179
+ });
180
+ }
181
+ }
182
+ }
183
+ return changes;
184
+ }
@@ -1,7 +1,7 @@
1
1
  import { stringify } from "yaml";
2
2
  import { defaultBranch } from "../config/types.js";
3
3
  import { MANAGED_HEADER } from "../model.js";
4
- import { REUSABLE_REPO, WORKFLOW_REF } from "../version.js";
4
+ import { PACKAGE_VERSION, REUSABLE_REPO, WORKFLOW_REF } from "../version.js";
5
5
  const yamlFile = (module, path, data) => ({
6
6
  kind: "file",
7
7
  module,
@@ -120,12 +120,25 @@ export const githubPlatform = {
120
120
  if (ctx.config.modules.commits) {
121
121
  jobs.push(workflowKey("ci", path, ["jobs", "commits"], { uses: workflowRef(ctx, "commitlint.yml") }));
122
122
  }
123
+ if (ctx.config.modules.drift) {
124
+ jobs.push(workflowKey("ci", path, ["jobs", "repokeeper"], {
125
+ uses: workflowRef(ctx, "repokeeper-check.yml"),
126
+ with: { version: PACKAGE_VERSION },
127
+ }));
128
+ }
123
129
  if (jobs.length === 0)
124
130
  return [];
125
131
  return [
126
132
  workflowKey("ci", path, ["name"], "ci"),
127
133
  workflowKey("ci", path, ["on"], { pull_request: null, push: { branches: [defaultBranch(ctx.config)] } }),
128
134
  workflowKey("ci", path, ["permissions"], { contents: "read" }),
135
+ // a new push to a pull request makes its earlier run pointless; runs on the default branch always finish
136
+ workflowKey("ci", path, ["concurrency"], {
137
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions
138
+ group: "${{ github.workflow }}-${{ github.ref }}",
139
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions
140
+ "cancel-in-progress": "${{ github.event_name == 'pull_request' }}",
141
+ }),
129
142
  ...jobs,
130
143
  ];
131
144
  },
@@ -145,6 +158,7 @@ export const githubPlatform = {
145
158
  "bump-minor-pre-major": true,
146
159
  "include-component-in-tag": false,
147
160
  ...(release.extraFiles && release.extraFiles.length > 0 ? { "extra-files": release.extraFiles } : {}),
161
+ ...(release.versionFile ? { "version-file": release.versionFile } : {}),
148
162
  },
149
163
  },
150
164
  }),
@@ -0,0 +1,30 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { checkKeys, stringList } from "./support.js";
4
+ const OPTION_KEYS = ["versions", "os"];
5
+ const GOFMT = 'test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; }';
6
+ export const goStack = {
7
+ id: "go",
8
+ detect: (root) => existsSync(join(root, "go.mod")),
9
+ async resolve(_root, options = {}) {
10
+ checkKeys("go", options, OPTION_KEYS);
11
+ return {
12
+ id: "go",
13
+ staged: [{ name: "go:gofmt", glob: "*.go", run: "gofmt -w {staged_files}" }],
14
+ test: "go test ./...",
15
+ install: "go mod download",
16
+ gitignore: ["Go"],
17
+ dependabot: ["gomod"],
18
+ ci: {
19
+ workflow: "stack-go.yml",
20
+ with: {
21
+ "go-versions": JSON.stringify(stringList("go", options, "versions") ?? ["stable"]),
22
+ os: JSON.stringify(stringList("go", options, "os") ?? ["ubuntu-latest"]),
23
+ commands: JSON.stringify([GOFMT, "go vet ./...", "go test ./..."]),
24
+ },
25
+ },
26
+ // Go modules are versioned by their tags; the manifest starts from the latest one
27
+ release: { type: "go", version: null },
28
+ };
29
+ },
30
+ };
@@ -0,0 +1,16 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { readText } from "./support.js";
4
+ export const GRADLE_FILES = ["build.gradle", "build.gradle.kts"];
5
+ /** The build command (wrapper when present) and release info of a Gradle project. */
6
+ export async function gradleBuild(root) {
7
+ const wrapper = existsSync(join(root, "gradlew"));
8
+ const properties = await readText(root, "gradle.properties");
9
+ const version = properties ? (/^version\s*=\s*(\S+)/m.exec(properties)?.[1] ?? null) : null;
10
+ return {
11
+ command: wrapper ? "./gradlew check" : "gradle check",
12
+ wrapper,
13
+ // release-please's generic updater changes gradle.properties once it carries x-release-please markers
14
+ release: { type: "simple", version, ...(properties !== null ? { extraFiles: ["gradle.properties"] } : {}) },
15
+ };
16
+ }
@@ -1,9 +1,14 @@
1
1
  import { STACK_IDS } from "../config/types.js";
2
2
  import { dartStack } from "./dart.js";
3
3
  import { dotnetStack } from "./dotnet.js";
4
+ import { goStack } from "./go.js";
4
5
  import { javaStack } from "./java.js";
6
+ import { kotlinStack } from "./kotlin.js";
5
7
  import { nodeStack } from "./node.js";
8
+ import { phpStack } from "./php.js";
6
9
  import { pythonStack } from "./python.js";
10
+ import { rubyStack } from "./ruby.js";
11
+ import { rustStack } from "./rust.js";
7
12
  import { scriptStack } from "./script.js";
8
13
  const PACKS = {
9
14
  node: nodeStack,
@@ -12,12 +17,20 @@ const PACKS = {
12
17
  script: scriptStack,
13
18
  java: javaStack,
14
19
  dotnet: dotnetStack,
20
+ go: goStack,
21
+ rust: rustStack,
22
+ kotlin: kotlinStack,
23
+ php: phpStack,
24
+ ruby: rubyStack,
15
25
  };
16
26
  export function getStackPack(id) {
17
27
  return PACKS[id];
18
28
  }
19
29
  export async function detectStacks(root) {
20
- const found = STACK_IDS.filter((id) => PACKS[id].detect(root));
30
+ let found = STACK_IDS.filter((id) => PACKS[id].detect(root));
31
+ // a Kotlin build is also a Gradle build; one CI job runs it
32
+ if (found.includes("kotlin"))
33
+ found = found.filter((id) => id !== "java");
21
34
  // scripts beside another stack belong to that stack; the script pack is for script-only repositories
22
35
  return found.length > 1 ? found.filter((id) => id !== "script") : found;
23
36
  }
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { gradleBuild } from "./gradle.js";
3
4
  import { checkKeys, readText, stringList } from "./support.js";
4
5
  const OPTION_KEYS = ["versions", "os"];
5
6
  /** The project's own <version>: parent, dependency and plugin versions are removed first. */
@@ -22,11 +23,7 @@ export const javaStack = {
22
23
  release = { type: "maven", version: mavenVersion((await readText(root, "pom.xml")) ?? "") };
23
24
  }
24
25
  else {
25
- command = has("gradlew") ? "./gradlew check" : "gradle check";
26
- const properties = await readText(root, "gradle.properties");
27
- const version = properties ? (/^version\s*=\s*(\S+)/m.exec(properties)?.[1] ?? null) : null;
28
- // release-please's generic updater changes gradle.properties once it carries x-release-please markers
29
- release = { type: "simple", version, ...(properties !== null ? { extraFiles: ["gradle.properties"] } : {}) };
26
+ ({ command, release } = await gradleBuild(root));
30
27
  }
31
28
  return {
32
29
  id: "java",
@@ -0,0 +1,54 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { UsageError } from "../errors.js";
4
+ import { GRADLE_FILES, gradleBuild } from "./gradle.js";
5
+ import { checkKeys, stringList } from "./support.js";
6
+ const OPTION_KEYS = ["versions", "os"];
7
+ // plugin ids, the kotlin() shorthand and version-catalog aliases; org.jetbrains.kotlinx is a library, not the plugin
8
+ const KOTLIN = /\bkotlin\(\s*"|org\.jetbrains\.kotlin[."']|libs\.plugins\.kotlin\b/;
9
+ const ANDROID = /com\.android\.(application|library)|libs\.plugins\.android\b/;
10
+ /** The root Gradle build files, with `//` comments removed. */
11
+ function buildFiles(root) {
12
+ return GRADLE_FILES.flatMap((file) => {
13
+ try {
14
+ return [{ file, text: readFileSync(join(root, file), "utf8").replace(/\/\/.*$/gm, "") }];
15
+ }
16
+ catch {
17
+ return [];
18
+ }
19
+ });
20
+ }
21
+ /** Whether a Gradle build at the root applies a Kotlin plugin. */
22
+ export const usesKotlin = (root) => buildFiles(root).some(({ text }) => KOTLIN.test(text));
23
+ export const kotlinStack = {
24
+ id: "kotlin",
25
+ detect: usesKotlin,
26
+ async resolve(root, options = {}) {
27
+ checkKeys("kotlin", options, OPTION_KEYS);
28
+ const android = buildFiles(root).find(({ text }) => ANDROID.test(text));
29
+ if (android) {
30
+ throw new UsageError(`${android.file} is an Android project, which needs the Android SDK the CI runners don't have; leave kotlin out of stacks`);
31
+ }
32
+ const { command, release, wrapper } = await gradleBuild(root);
33
+ return {
34
+ id: "kotlin",
35
+ // ktlint or Spotless run through the build, like Spotless for java
36
+ staged: [],
37
+ test: command,
38
+ install: null,
39
+ gitignore: ["Kotlin", "Gradle"],
40
+ dependabot: ["gradle"],
41
+ ci: {
42
+ workflow: "stack-java.yml",
43
+ with: {
44
+ "java-versions": JSON.stringify(stringList("kotlin", options, "versions") ?? ["17", "21"]),
45
+ os: JSON.stringify(stringList("kotlin", options, "os") ?? ["ubuntu-latest"]),
46
+ "build-tool": "gradle",
47
+ "gradle-version": wrapper ? "" : "current",
48
+ commands: JSON.stringify([command]),
49
+ },
50
+ },
51
+ release,
52
+ };
53
+ },
54
+ };
@@ -0,0 +1,53 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { UsageError } from "../errors.js";
4
+ import { checkKeys, readText, stringList } from "./support.js";
5
+ const OPTION_KEYS = ["versions", "os"];
6
+ export const phpStack = {
7
+ id: "php",
8
+ detect: (root) => existsSync(join(root, "composer.json")),
9
+ async resolve(root, options = {}) {
10
+ checkKeys("php", options, OPTION_KEYS);
11
+ const has = (path) => existsSync(join(root, path));
12
+ let composer;
13
+ try {
14
+ composer = JSON.parse((await readText(root, "composer.json")) ?? "{}");
15
+ }
16
+ catch (error) {
17
+ throw new UsageError(`composer.json could not be read: ${error.message}`);
18
+ }
19
+ const csFixer = has(".php-cs-fixer.dist.php") || has(".php-cs-fixer.php");
20
+ const phpstan = has("phpstan.neon") || has("phpstan.neon.dist") || has("phpstan.dist.neon");
21
+ const test = composer.scripts?.test
22
+ ? "composer test"
23
+ : has("phpunit.xml") || has("phpunit.xml.dist") || has("phpunit.dist.xml")
24
+ ? "vendor/bin/phpunit"
25
+ : null;
26
+ return {
27
+ id: "php",
28
+ staged: csFixer
29
+ ? [{ name: "php:cs-fixer", glob: "*.php", run: "vendor/bin/php-cs-fixer fix {staged_files}" }]
30
+ : [],
31
+ test,
32
+ install: "composer install",
33
+ gitignore: ["Composer"],
34
+ dependabot: ["composer"],
35
+ ci: {
36
+ workflow: "stack-php.yml",
37
+ with: {
38
+ "php-versions": JSON.stringify(stringList("php", options, "versions") ?? ["8.3", "8.4"]),
39
+ os: JSON.stringify(stringList("php", options, "os") ?? ["ubuntu-latest"]),
40
+ "install-command": "composer install --no-interaction --no-progress",
41
+ commands: JSON.stringify([
42
+ // --strict would fail on warnings such as a missing license or the version field release-please writes
43
+ "composer validate --no-check-publish",
44
+ ...(csFixer ? ["vendor/bin/php-cs-fixer fix --dry-run --diff"] : []),
45
+ ...(phpstan ? ["vendor/bin/phpstan analyse --no-progress"] : []),
46
+ ...(test ? [test] : []),
47
+ ]),
48
+ },
49
+ },
50
+ release: { type: "php", version: composer.version ?? null },
51
+ };
52
+ },
53
+ };
@@ -1,14 +1,10 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { checkKeys, filesMatching, optionalString, readText, stringList } from "./support.js";
3
+ import { checkKeys, filesMatching, optionalString, readText, stringList, tomlSection } from "./support.js";
4
4
  const OPTION_KEYS = ["versions", "os", "test"];
5
5
  /** The `version = "…"` of the `[project]` table, if any. */
6
6
  function projectVersion(pyproject) {
7
- return /^version\s*=\s*"([^"]+)"/m.exec(section(pyproject, "project"))?.[1] ?? null;
8
- }
9
- /** The body of the `[name]` table of a TOML file, or "". */
10
- function section(toml, name) {
11
- return toml.split(/^\[/m).find((part) => part.startsWith(`${name}]`)) ?? "";
7
+ return /^version\s*=\s*"([^"]+)"/m.exec(tomlSection(pyproject, "project"))?.[1] ?? null;
12
8
  }
13
9
  export const pythonStack = {
14
10
  id: "python",
@@ -21,7 +17,7 @@ export const pythonStack = {
21
17
  const mypyIni = (await readText(root, "mypy.ini")) ?? "";
22
18
  const mypy = has("mypy.ini") || pyproject.includes("[tool.mypy]");
23
19
  // a config that names its files decides what mypy checks; otherwise check the whole tree
24
- const mypyFiles = /^files\s*=/m.test(mypyIni) || /^files\s*=/m.test(section(pyproject, "tool.mypy"));
20
+ const mypyFiles = /^files\s*=/m.test(mypyIni) || /^files\s*=/m.test(tomlSection(pyproject, "tool.mypy"));
25
21
  const hasTests = has("tests") || has("test") || filesMatching(root, ".", /^test_.*\.py$/).length > 0;
26
22
  const customTest = optionalString("python", options, "test");
27
23
  const pytest = customTest === undefined && hasTests;
@@ -0,0 +1,53 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { checkKeys, filesMatching, readText, stringList } from "./support.js";
4
+ const OPTION_KEYS = ["versions", "os"];
5
+ // only the gem's own version: not MINIMUM_RUBY_VERSION, required_ruby_version or a computed value
6
+ const VERSION_CONSTANT = /^\s*VERSION\s*=\s*["']([^"']+)["']/m;
7
+ const GEMSPEC_VERSION = /\.version\s*=\s*["']([^"']+)["']/;
8
+ /** A `VERSION = "x.y.z"` in lib/<name>/version.rb, else the gemspec's `s.version = "x.y.z"`. */
9
+ async function rubyRelease(root) {
10
+ for (const dir of filesMatching(root, "lib", /^[^.]+$/)) {
11
+ const path = `lib/${dir}/version.rb`;
12
+ const version = VERSION_CONSTANT.exec((await readText(root, path)) ?? "")?.[1];
13
+ if (version)
14
+ return { type: "ruby", version, versionFile: path };
15
+ }
16
+ for (const spec of filesMatching(root, ".", /\.gemspec$/)) {
17
+ const version = GEMSPEC_VERSION.exec((await readText(root, spec)) ?? "")?.[1];
18
+ // release-please's ruby strategy only bumps version.rb; the generic updater bumps the gemspec
19
+ if (version)
20
+ return { type: "ruby", version, extraFiles: [spec] };
21
+ }
22
+ return { type: "ruby", version: null };
23
+ }
24
+ export const rubyStack = {
25
+ id: "ruby",
26
+ detect: (root) => existsSync(join(root, "Gemfile")),
27
+ async resolve(root, options = {}) {
28
+ checkKeys("ruby", options, OPTION_KEYS);
29
+ const has = (path) => existsSync(join(root, path));
30
+ const rubocop = has(".rubocop.yml");
31
+ const test = has("spec") ? "bundle exec rspec" : has("Rakefile") && has("test") ? "bundle exec rake test" : null;
32
+ return {
33
+ id: "ruby",
34
+ staged: rubocop
35
+ ? [{ name: "ruby:rubocop", glob: "*.rb", run: "bundle exec rubocop --autocorrect {staged_files}" }]
36
+ : [],
37
+ test,
38
+ install: "bundle install",
39
+ gitignore: ["Ruby"],
40
+ dependabot: ["bundler"],
41
+ ci: {
42
+ workflow: "stack-ruby.yml",
43
+ with: {
44
+ "ruby-versions": JSON.stringify(stringList("ruby", options, "versions") ?? ["3.3", "3.4"]),
45
+ os: JSON.stringify(stringList("ruby", options, "os") ?? ["ubuntu-latest"]),
46
+ "install-command": "bundle install",
47
+ commands: JSON.stringify([...(rubocop ? ["bundle exec rubocop"] : []), ...(test ? [test] : [])]),
48
+ },
49
+ },
50
+ release: await rubyRelease(root),
51
+ };
52
+ },
53
+ };
@@ -0,0 +1,39 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { checkKeys, readText, stringList, tomlSection } from "./support.js";
4
+ const OPTION_KEYS = ["versions", "os"];
5
+ export const rustStack = {
6
+ id: "rust",
7
+ detect: (root) => existsSync(join(root, "Cargo.toml")),
8
+ async resolve(root, options = {}) {
9
+ checkKeys("rust", options, OPTION_KEYS);
10
+ const cargo = (await readText(root, "Cargo.toml")) ?? "";
11
+ const pkg = tomlSection(cargo, "package");
12
+ const version = /^version\s*=\s*"([^"]+)"/m.exec(pkg)?.[1] ?? null;
13
+ // a workspace root without its own package has nothing for release-please's rust updater to bump
14
+ const release = pkg ? { type: "rust", version } : { type: "simple", version: null };
15
+ return {
16
+ id: "rust",
17
+ staged: [{ name: "rust:fmt", glob: "*.rs", run: "cargo fmt --all" }],
18
+ test: "cargo test",
19
+ install: "cargo fetch",
20
+ gitignore: ["Rust"],
21
+ dependabot: ["cargo"],
22
+ ci: {
23
+ workflow: "stack-rust.yml",
24
+ with: {
25
+ "rust-versions": JSON.stringify(stringList("rust", options, "versions") ?? ["stable"]),
26
+ os: JSON.stringify(stringList("rust", options, "os") ?? ["ubuntu-latest"]),
27
+ commands: JSON.stringify([
28
+ "cargo fmt --all --check",
29
+ "cargo clippy --workspace --all-targets -- -D warnings",
30
+ "cargo test --workspace --all-targets",
31
+ // --all-targets leaves doctests out, and --doc fails when there is no library to document
32
+ ...(existsSync(join(root, "src/lib.rs")) ? ["cargo test --workspace --doc"] : []),
33
+ ]),
34
+ },
35
+ },
36
+ release,
37
+ };
38
+ },
39
+ };
@@ -48,3 +48,7 @@ export async function readText(root, file) {
48
48
  return null;
49
49
  }
50
50
  }
51
+ /** The body of the `[name]` table of a TOML file, or "". */
52
+ export function tomlSection(toml, name) {
53
+ return toml.split(/^\[/m).find((part) => part.startsWith(`${name}]`)) ?? "";
54
+ }
package/dist/version.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  export const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
3
3
  /** The standard this build of repokeeper applies. Bump it whenever generated output changes. */
4
- export const STANDARD_VERSION = "1.2.2";
4
+ export const STANDARD_VERSION = "1.3.0";
5
5
  export const TOOL_VERSIONS = {
6
6
  lefthook: "2.1.14",
7
7
  commitlintCli: "21.2.3",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repokeeper",
3
- "version": "0.2.3",
3
+ "version": "0.4.0",
4
4
  "description": "Keep every repository on one maintained standard: commits, git hooks, CI, releases, dependency updates and repo settings.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,6 @@
1
+ composer.phar
2
+ /vendor/
3
+
4
+ # Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
5
+ # You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
6
+ # composer.lock
@@ -0,0 +1,32 @@
1
+ # If you prefer the allow list template instead of the deny list, see community template:
2
+ # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
3
+ #
4
+ # Binaries for programs and plugins
5
+ *.exe
6
+ *.exe~
7
+ *.dll
8
+ *.so
9
+ *.dylib
10
+
11
+ # Test binary, built with `go test -c`
12
+ *.test
13
+
14
+ # Code coverage profiles and other test artifacts
15
+ *.out
16
+ coverage.*
17
+ *.coverprofile
18
+ profile.cov
19
+
20
+ # Dependency directories (remove the comment below to include it)
21
+ # vendor/
22
+
23
+ # Go workspace file
24
+ go.work
25
+ go.work.sum
26
+
27
+ # env file
28
+ .env
29
+
30
+ # Editor/IDE
31
+ # .idea/
32
+ # .vscode/
@@ -0,0 +1,27 @@
1
+ # Compiled class file
2
+ *.class
3
+
4
+ # Log file
5
+ *.log
6
+
7
+ # BlueJ files
8
+ *.ctxt
9
+
10
+ # Mobile Tools for Java (J2ME)
11
+ .mtj.tmp/
12
+
13
+ # Package Files #
14
+ *.jar
15
+ *.war
16
+ *.nar
17
+ *.ear
18
+ *.zip
19
+ *.tar.gz
20
+ *.rar
21
+
22
+ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
23
+ hs_err_pid*
24
+ replay_pid*
25
+
26
+ # Kotlin Gradle plugin data, see https://kotlinlang.org/docs/whatsnew20.html#new-directory-for-kotlin-data-in-gradle-projects
27
+ .kotlin/
@@ -0,0 +1,56 @@
1
+ *.gem
2
+ *.rbc
3
+ /.config
4
+ /coverage/
5
+ /InstalledFiles
6
+ /pkg/
7
+ /spec/reports/
8
+ /spec/examples.txt
9
+ /test/tmp/
10
+ /test/version_tmp/
11
+ /tmp/
12
+
13
+ # Used by dotenv library to load environment variables.
14
+ # .env
15
+
16
+ # Ignore Byebug command history file.
17
+ .byebug_history
18
+
19
+ ## Specific to RubyMotion:
20
+ .dat*
21
+ .repl_history
22
+ build/
23
+ *.bridgesupport
24
+ build-iPhoneOS/
25
+ build-iPhoneSimulator/
26
+
27
+ ## Specific to RubyMotion (use of CocoaPods):
28
+ #
29
+ # We recommend against adding the Pods directory to your .gitignore. However
30
+ # you should judge for yourself, the pros and cons are mentioned at:
31
+ # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
32
+ #
33
+ # vendor/Pods/
34
+
35
+ ## Documentation cache and generated files:
36
+ /.yardoc/
37
+ /_yardoc/
38
+ /doc/
39
+ /rdoc/
40
+
41
+ ## Environment normalization:
42
+ /.bundle/
43
+ /vendor/bundle
44
+ /lib/bundler/man/
45
+
46
+ # for a library or gem, you might want to ignore these files since the code is
47
+ # intended to run in multiple environments; otherwise, check them in:
48
+ # Gemfile.lock
49
+ # .ruby-version
50
+ # .ruby-gemset
51
+
52
+ # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
53
+ .rvmrc
54
+
55
+ # Used by RuboCop. Remote config files pulled in from inherit_from directive.
56
+ # .rubocop-https?--*
@@ -0,0 +1,24 @@
1
+ # Generated by Cargo
2
+ # will have compiled files and executables
3
+ debug
4
+ target
5
+
6
+ # These are backup files generated by rustfmt
7
+ **/*.rs.bk
8
+
9
+ # MSVC Windows builds of rustc generate these, which store debugging information
10
+ *.pdb
11
+
12
+ # Generated by cargo mutants
13
+ # Contains mutation testing data
14
+ **/mutants.out*/
15
+
16
+ # rustc will dump stack traces when hitting an internal compiler error to PWD
17
+ rustc-ice-*.txt
18
+
19
+ # RustRover
20
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
21
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
22
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
23
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
24
+ #.idea/