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 +37 -5
- package/dist/cli.js +21 -0
- package/dist/commands/github.js +36 -0
- package/dist/config/load.js +1 -0
- package/dist/config/schema.js +35 -1
- package/dist/config/types.js +14 -1
- package/dist/errors.js +6 -0
- package/dist/github/api.js +49 -0
- package/dist/github/settings.js +184 -0
- package/dist/platforms/github.js +15 -1
- package/dist/stacks/go.js +30 -0
- package/dist/stacks/gradle.js +16 -0
- package/dist/stacks/index.js +14 -1
- package/dist/stacks/java.js +2 -5
- package/dist/stacks/kotlin.js +54 -0
- package/dist/stacks/php.js +53 -0
- package/dist/stacks/python.js +3 -7
- package/dist/stacks/ruby.js +53 -0
- package/dist/stacks/rust.js +39 -0
- package/dist/stacks/support.js +4 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/templates/gitignore/Composer.gitignore +6 -0
- package/templates/gitignore/Go.gitignore +32 -0
- package/templates/gitignore/Kotlin.gitignore +27 -0
- package/templates/gitignore/Ruby.gitignore +56 -0
- package/templates/gitignore/Rust.gitignore +24 -0
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)
|
|
9
|
-
> GitHub settings
|
|
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 `
|
|
55
|
-
adds; jobs you add yourself are left alone, and so is the formatting of the rest of the
|
|
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
|
|
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
|
+
}
|
package/dist/config/load.js
CHANGED
package/dist/config/schema.js
CHANGED
|
@@ -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: {
|
|
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
|
};
|
package/dist/config/types.js
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
export const STACK_IDS = [
|
|
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
|
+
}
|
package/dist/platforms/github.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/stacks/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/stacks/java.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|
package/dist/stacks/python.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
+
};
|
package/dist/stacks/support.js
CHANGED
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.
|
|
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
|
@@ -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/
|