repokeeper 0.5.0 → 0.6.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/.github/workflows/commitlint.yml +49 -0
- package/.github/workflows/release-please.yml +59 -0
- package/.github/workflows/repokeeper-check.yml +30 -0
- package/.github/workflows/stack-dart.yml +72 -0
- package/.github/workflows/stack-dotnet.yml +68 -0
- package/.github/workflows/stack-go.yml +59 -0
- package/.github/workflows/stack-java.yml +70 -0
- package/.github/workflows/stack-node.yml +85 -0
- package/.github/workflows/stack-php.yml +66 -0
- package/.github/workflows/stack-python.yml +70 -0
- package/.github/workflows/stack-ruby.yml +65 -0
- package/.github/workflows/stack-rust.yml +60 -0
- package/.github/workflows/stack-script.yml +87 -0
- package/README.md +59 -105
- package/dist/cli.js +48 -9
- package/dist/commands/context.js +19 -2
- package/dist/commands/eject.js +74 -0
- package/dist/commands/gitlab.js +46 -0
- package/dist/commands/init.js +104 -10
- package/dist/commands/presets.js +114 -0
- package/dist/commands/report.js +2 -2
- package/dist/commands/update.js +3 -2
- package/dist/config/schema.js +40 -1
- package/dist/duplicates.js +2 -2
- package/dist/errors.js +1 -1
- package/dist/gitlab/api.js +50 -0
- package/dist/gitlab/settings.js +181 -0
- package/dist/model.js +8 -0
- package/dist/modules/commits.js +3 -2
- package/dist/modules/deps.js +1 -1
- package/dist/modules/health.js +3 -3
- package/dist/modules/hooks.js +7 -5
- package/dist/modules/release.js +18 -5
- package/dist/platforms/github.js +70 -12
- package/dist/platforms/gitlab.js +25 -4
- package/dist/stacks/index.js +56 -0
- package/dist/stacks/support.js +19 -0
- package/dist/templates.js +13 -1
- package/dist/version.js +2 -0
- package/package.json +6 -2
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { ApiError } from "../errors.js";
|
|
2
|
+
export const ACCESS_LEVELS = { none: 0, developers: 30, maintainers: 40 };
|
|
3
|
+
/** Description of the pipeline schedule repokeeper owns; a schedule under another name is never touched. */
|
|
4
|
+
export const RENOVATE_SCHEDULE = "repokeeper: renovate";
|
|
5
|
+
const MERGE_FIELDS = {
|
|
6
|
+
method: "merge_method",
|
|
7
|
+
squash: "squash_option",
|
|
8
|
+
delete_source_branch: "remove_source_branch_after_merge",
|
|
9
|
+
pipeline_must_succeed: "only_allow_merge_if_pipeline_succeeds",
|
|
10
|
+
discussions_must_be_resolved: "only_allow_merge_if_all_discussions_are_resolved",
|
|
11
|
+
};
|
|
12
|
+
function expect2xx(response, what, project) {
|
|
13
|
+
if (response.status < 400)
|
|
14
|
+
return response;
|
|
15
|
+
const data = response.data;
|
|
16
|
+
const said = data?.message ?? data?.error;
|
|
17
|
+
const message = typeof said === "string" ? said : said ? JSON.stringify(said) : `HTTP ${response.status}`;
|
|
18
|
+
const access = response.status === 401 || response.status === 403 || response.status === 404
|
|
19
|
+
? `; this needs the Maintainer role on ${project} and a token with the api scope`
|
|
20
|
+
: "";
|
|
21
|
+
throw new ApiError(`${what} failed: ${message}${access}`);
|
|
22
|
+
}
|
|
23
|
+
const show = (value) => value === undefined || value === null || value === ""
|
|
24
|
+
? "(none)"
|
|
25
|
+
: Array.isArray(value)
|
|
26
|
+
? value.join(", ") || "(none)"
|
|
27
|
+
: String(value);
|
|
28
|
+
const levels = (list) => (list ?? []).map((entry) => entry.access_level ?? -1).sort((a, b) => a - b);
|
|
29
|
+
const levelName = (level) => Object.entries(ACCESS_LEVELS).find(([, value]) => value === level)?.[0] ??
|
|
30
|
+
// a user, a group or a deploy key allowed by name (paid tiers), or a level repokeeper has no word for
|
|
31
|
+
(level === -1 ? "named members" : `level ${level}`);
|
|
32
|
+
const describeLevels = (list) => (list.length === 0 ? "none" : list.map(levelName).join(" + "));
|
|
33
|
+
function describeProtection(protection) {
|
|
34
|
+
if (protection === null)
|
|
35
|
+
return "(not protected)";
|
|
36
|
+
return [
|
|
37
|
+
`push: ${describeLevels(protection.push)}`,
|
|
38
|
+
`merge: ${describeLevels(protection.merge)}`,
|
|
39
|
+
protection.forcePush ? "force push allowed" : "no force push",
|
|
40
|
+
].join("; ");
|
|
41
|
+
}
|
|
42
|
+
const branchOf = (ref) => ref.replace(/^refs\/heads\//, "");
|
|
43
|
+
const describeSchedule = (schedule) => schedule === undefined
|
|
44
|
+
? "(none)"
|
|
45
|
+
: `${schedule.cron} on ${branchOf(schedule.ref)}${schedule.active ? "" : " (inactive)"}`;
|
|
46
|
+
/**
|
|
47
|
+
* Reads the project's current settings and lists what differs from `settings`. Reads only.
|
|
48
|
+
*
|
|
49
|
+
* @param secrets CI/CD variables the generated jobs wait for; a missing one is reported, never created.
|
|
50
|
+
*/
|
|
51
|
+
export async function planGitlab(api, path, settings, secrets = []) {
|
|
52
|
+
const base = `/projects/${encodeURIComponent(path)}`;
|
|
53
|
+
const get = async (suffix, what, missingOk = false) => {
|
|
54
|
+
const response = await api.request("GET", `${base}${suffix}`);
|
|
55
|
+
return missingOk && response.status === 404 ? response : expect2xx(response, what, path);
|
|
56
|
+
};
|
|
57
|
+
const send = async (method, suffix, what, body) => {
|
|
58
|
+
expect2xx(await api.request(method, `${base}${suffix}`, body), what, path);
|
|
59
|
+
};
|
|
60
|
+
const changes = [];
|
|
61
|
+
const current = (await get("", `reading ${path}`)).data;
|
|
62
|
+
for (const [key, field] of Object.entries(MERGE_FIELDS)) {
|
|
63
|
+
const want = settings.merge?.[key];
|
|
64
|
+
if (want === undefined || current[field] === want)
|
|
65
|
+
continue;
|
|
66
|
+
changes.push({
|
|
67
|
+
setting: `merge.${key}`,
|
|
68
|
+
from: show(current[field]),
|
|
69
|
+
to: show(want),
|
|
70
|
+
apply: () => send("PUT", "", `setting merge.${key}`, { [field]: want }),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (settings.description !== undefined && (current.description ?? "") !== settings.description) {
|
|
74
|
+
const description = settings.description;
|
|
75
|
+
changes.push({
|
|
76
|
+
setting: "description",
|
|
77
|
+
from: show(current.description),
|
|
78
|
+
to: show(description),
|
|
79
|
+
apply: () => send("PUT", "", "setting the description", { description }),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (settings.topics !== undefined) {
|
|
83
|
+
const have = (current.topics ?? []).slice().sort();
|
|
84
|
+
const want = [...settings.topics].sort();
|
|
85
|
+
if (JSON.stringify(have) !== JSON.stringify(want)) {
|
|
86
|
+
changes.push({
|
|
87
|
+
setting: "topics",
|
|
88
|
+
from: show(have),
|
|
89
|
+
to: show(want),
|
|
90
|
+
apply: () => send("PUT", "", "setting topics", { topics: want }),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const branch = settings.default_branch ?? current.default_branch ?? "main";
|
|
95
|
+
if (settings.protect !== undefined) {
|
|
96
|
+
const rule = `/protected_branches/${encodeURIComponent(branch)}`;
|
|
97
|
+
const response = await get(rule, `reading the protection of ${branch}`, true);
|
|
98
|
+
const existing = response.status === 404 ? null : response.data;
|
|
99
|
+
const have = existing && {
|
|
100
|
+
push: levels(existing.push_access_levels),
|
|
101
|
+
merge: levels(existing.merge_access_levels),
|
|
102
|
+
forcePush: existing.allow_force_push === true,
|
|
103
|
+
};
|
|
104
|
+
if (settings.protect === false) {
|
|
105
|
+
if (have) {
|
|
106
|
+
changes.push({
|
|
107
|
+
setting: `protect ${branch}`,
|
|
108
|
+
from: describeProtection(have),
|
|
109
|
+
to: describeProtection(null),
|
|
110
|
+
apply: () => send("DELETE", rule, `removing the protection of ${branch}`),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
const want = {
|
|
116
|
+
push: [ACCESS_LEVELS[settings.protect.push ?? "maintainers"]],
|
|
117
|
+
merge: [ACCESS_LEVELS[settings.protect.merge ?? "maintainers"]],
|
|
118
|
+
forcePush: settings.protect.allow_force_push ?? false,
|
|
119
|
+
};
|
|
120
|
+
if (JSON.stringify(have) !== JSON.stringify(want)) {
|
|
121
|
+
changes.push({
|
|
122
|
+
setting: `protect ${branch}`,
|
|
123
|
+
from: describeProtection(have),
|
|
124
|
+
to: describeProtection(want),
|
|
125
|
+
// a protection is replaced as a whole: changing its access levels in place needs the id of each one
|
|
126
|
+
apply: async () => {
|
|
127
|
+
if (have)
|
|
128
|
+
await send("DELETE", rule, `removing the protection of ${branch}`);
|
|
129
|
+
await send("POST", "/protected_branches", `protecting ${branch}`, {
|
|
130
|
+
name: branch,
|
|
131
|
+
push_access_level: want.push[0],
|
|
132
|
+
merge_access_level: want.merge[0],
|
|
133
|
+
allow_force_push: want.forcePush,
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (settings.renovate_schedule !== undefined) {
|
|
141
|
+
const list = (await get("/pipeline_schedules", "reading the pipeline schedules")).data;
|
|
142
|
+
const existing = list.find((schedule) => schedule.description === RENOVATE_SCHEDULE);
|
|
143
|
+
const cron = settings.renovate_schedule;
|
|
144
|
+
if (cron === false) {
|
|
145
|
+
if (existing) {
|
|
146
|
+
changes.push({
|
|
147
|
+
setting: "renovate_schedule",
|
|
148
|
+
from: describeSchedule(existing),
|
|
149
|
+
to: describeSchedule(undefined),
|
|
150
|
+
apply: () => send("DELETE", `/pipeline_schedules/${existing.id}`, "removing the renovate schedule"),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
else if (!existing || existing.cron !== cron || branchOf(existing.ref) !== branch || !existing.active) {
|
|
155
|
+
const want = { cron, ref: branch, active: true };
|
|
156
|
+
changes.push({
|
|
157
|
+
setting: "renovate_schedule",
|
|
158
|
+
from: describeSchedule(existing),
|
|
159
|
+
to: describeSchedule(want),
|
|
160
|
+
apply: () => existing
|
|
161
|
+
? send("PUT", `/pipeline_schedules/${existing.id}`, "updating the renovate schedule", want)
|
|
162
|
+
: send("POST", "/pipeline_schedules", "creating the renovate schedule", {
|
|
163
|
+
description: RENOVATE_SCHEDULE,
|
|
164
|
+
cron_timezone: "UTC",
|
|
165
|
+
...want,
|
|
166
|
+
}),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const notes = [];
|
|
171
|
+
for (const { key, job } of secrets) {
|
|
172
|
+
const response = await api.request("GET", `${base}/variables/${key}`);
|
|
173
|
+
if (response.status === 404) {
|
|
174
|
+
notes.push(`no CI/CD variable ${key} in ${path}: the ${job} job stays inactive until one is set, here or in a group above`);
|
|
175
|
+
}
|
|
176
|
+
else if (response.status >= 400) {
|
|
177
|
+
notes.push(`could not read the CI/CD variables of ${path} (HTTP ${response.status}); ${key} was not checked`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { changes, notes };
|
|
181
|
+
}
|
package/dist/model.js
CHANGED
|
@@ -23,3 +23,11 @@ export function describeOutput(output) {
|
|
|
23
23
|
return `${output.path} (block ${output.id})`;
|
|
24
24
|
return `${output.path} (${output.keyPath.join(".")})`;
|
|
25
25
|
}
|
|
26
|
+
/** `command`, run from the stack's folder when it has one. */
|
|
27
|
+
export function inDirectory(stack, command) {
|
|
28
|
+
return stack.directory ? `cd ${stack.directory} && ${command}` : command;
|
|
29
|
+
}
|
|
30
|
+
/** Whether the Node.js project is the repository itself, so its package.json can carry repokeeper's tools. */
|
|
31
|
+
export function nodeAtRoot(stacks) {
|
|
32
|
+
return stacks.some((stack) => stack.id === "node" && !stack.directory);
|
|
33
|
+
}
|
package/dist/modules/commits.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MANAGED_HEADER } from "../model.js";
|
|
1
|
+
import { MANAGED_HEADER, nodeAtRoot } from "../model.js";
|
|
2
2
|
import { TOOL_VERSIONS } from "../version.js";
|
|
3
3
|
const COMMITLINT_CONFIG = `// ${MANAGED_HEADER}
|
|
4
4
|
export default {
|
|
@@ -15,7 +15,8 @@ export const commitsModule = {
|
|
|
15
15
|
const outputs = [
|
|
16
16
|
{ kind: "file", module: "commits", path: "commitlint.config.mjs", content: COMMITLINT_CONFIG },
|
|
17
17
|
];
|
|
18
|
-
|
|
18
|
+
// a package.json in a folder of its own is the project's, not the place for the repository's tools
|
|
19
|
+
if (nodeAtRoot(ctx.stacks)) {
|
|
19
20
|
outputs.push({
|
|
20
21
|
kind: "json",
|
|
21
22
|
module: "commits",
|
package/dist/modules/deps.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const depsModule = {
|
|
2
2
|
id: "deps",
|
|
3
3
|
enabled: (config) => config.modules.deps,
|
|
4
|
-
outputs: (ctx) => ctx.platform.dependencyUpdates(ctx.stacks.flatMap((
|
|
4
|
+
outputs: (ctx) => ctx.platform.dependencyUpdates(ctx.stacks.flatMap((stack) => stack.dependabot.map((ecosystem) => ({ ecosystem, directory: stack.directory ? `/${stack.directory}` : "/" }))), ctx),
|
|
5
5
|
};
|
package/dist/modules/health.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defaultBranch } from "../config/types.js";
|
|
2
2
|
import { UsageError } from "../errors.js";
|
|
3
|
-
import { MANAGED_HEADER } from "../model.js";
|
|
3
|
+
import { inDirectory, MANAGED_HEADER, nodeAtRoot } from "../model.js";
|
|
4
4
|
import { readTemplate } from "../templates.js";
|
|
5
5
|
import { TOOL_VERSIONS } from "../version.js";
|
|
6
6
|
const MIT = (holder) => `MIT License
|
|
@@ -42,9 +42,9 @@ export const healthModule = {
|
|
|
42
42
|
}
|
|
43
43
|
const report = ctx.platform.securityReport(ctx.repo);
|
|
44
44
|
outputs.push(md("SECURITY.md", `# Security Policy\n\n<!-- ${MANAGED_HEADER} -->\n\n## Supported versions\n\nSecurity fixes are released for the latest published version.\n\n## Reporting a vulnerability\n\nPlease do not open a public issue. Report the vulnerability privately ${report}.\nThe maintainers will acknowledge it as soon as possible and keep you informed until it is resolved.\n`));
|
|
45
|
-
const install = ctx.stacks.
|
|
45
|
+
const install = ctx.stacks.flatMap((s) => (s.install === null ? [] : [inDirectory(s, s.install)]));
|
|
46
46
|
const run = install.length ? `Run ${install.map((c) => `\`${c}\``).join(" and ")}.` : "";
|
|
47
|
-
const hooks = ctx.stacks
|
|
47
|
+
const hooks = nodeAtRoot(ctx.stacks)
|
|
48
48
|
? "Installing the dependencies also installs the git hooks (lefthook)."
|
|
49
49
|
: `${run ? "Then run" : "Run"} \`npx --yes lefthook@${TOOL_VERSIONS.lefthook} install\` once to enable the git hooks (they need Node.js 22 or newer).`;
|
|
50
50
|
const setup = [run, hooks].filter(Boolean).join(" ");
|
package/dist/modules/hooks.js
CHANGED
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
import { stringify } from "yaml";
|
|
2
|
-
import { MANAGED_HEADER } from "../model.js";
|
|
2
|
+
import { MANAGED_HEADER, nodeAtRoot } from "../model.js";
|
|
3
3
|
import { TOOL_VERSIONS } from "../version.js";
|
|
4
|
+
/** lefthook runs a job from `root` and hands it only the staged files under it. */
|
|
5
|
+
const rootOf = (stack) => (stack.directory ? { root: `${stack.directory}/` } : {});
|
|
4
6
|
export const hooksModule = {
|
|
5
7
|
id: "hooks",
|
|
6
8
|
enabled: (config) => config.modules.hooks,
|
|
7
9
|
outputs(ctx) {
|
|
8
10
|
const config = {};
|
|
9
|
-
const preCommit = ctx.stacks.flatMap((s) => s.staged
|
|
11
|
+
const preCommit = ctx.stacks.flatMap((s) => s.staged.map((job) => ({ ...job, ...rootOf(s), stage_fixed: true })));
|
|
10
12
|
if (preCommit.length > 0)
|
|
11
13
|
config["pre-commit"] = { parallel: true, jobs: preCommit };
|
|
12
14
|
if (ctx.config.modules.commits) {
|
|
13
|
-
const node = ctx.stacks
|
|
15
|
+
const node = nodeAtRoot(ctx.stacks);
|
|
14
16
|
const commitlint = node
|
|
15
17
|
? "npx --no-install commitlint --edit {1}"
|
|
16
18
|
: `npx --yes --package @commitlint/cli@${TOOL_VERSIONS.commitlintCli} --package @commitlint/config-conventional@${TOOL_VERSIONS.commitlintConventional} -- commitlint --edit {1}`;
|
|
17
19
|
config["commit-msg"] = { jobs: [{ name: "commitlint", run: commitlint }] };
|
|
18
20
|
}
|
|
19
|
-
const prePush = ctx.stacks.flatMap((s) => (s.test ? [{ name: `${s.id}:test`, run: s.test }] : []));
|
|
21
|
+
const prePush = ctx.stacks.flatMap((s) => (s.test ? [{ name: `${s.id}:test`, run: s.test, ...rootOf(s) }] : []));
|
|
20
22
|
if (prePush.length > 0)
|
|
21
23
|
config["pre-push"] = { jobs: prePush };
|
|
22
24
|
const outputs = [
|
|
@@ -27,7 +29,7 @@ export const hooksModule = {
|
|
|
27
29
|
content: `# ${MANAGED_HEADER}\n${stringify(config, { lineWidth: 0 })}`,
|
|
28
30
|
},
|
|
29
31
|
];
|
|
30
|
-
if (ctx.stacks
|
|
32
|
+
if (nodeAtRoot(ctx.stacks)) {
|
|
31
33
|
outputs.push({
|
|
32
34
|
kind: "json",
|
|
33
35
|
module: "hooks",
|
package/dist/modules/release.js
CHANGED
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
/** One release per repository: the first stack with a language release type wins, otherwise the first stack's `simple` release (which may name extra files). */
|
|
2
|
+
export function pickReleaseStack(stacks) {
|
|
3
|
+
return stacks.find((stack) => stack.release.type !== "simple") ?? stacks[0];
|
|
4
|
+
}
|
|
2
5
|
export function pickRelease(stacks) {
|
|
3
|
-
|
|
4
|
-
return language?.release ?? stacks[0]?.release ?? { type: "simple", version: null };
|
|
6
|
+
return pickReleaseStack(stacks)?.release ?? { type: "simple", version: null };
|
|
5
7
|
}
|
|
6
8
|
export const releaseModule = {
|
|
7
9
|
id: "release",
|
|
8
10
|
enabled: (config) => config.modules.release,
|
|
9
11
|
outputs: (ctx) => {
|
|
10
|
-
const
|
|
12
|
+
const stack = pickReleaseStack(ctx.stacks);
|
|
13
|
+
const release = stack?.release ?? { type: "simple", version: null };
|
|
14
|
+
const directory = stack?.directory;
|
|
15
|
+
// the markers go into a file of the repository, so its path starts at the root
|
|
16
|
+
const markerPath = (path) => (directory ? `${directory}/${path}` : path);
|
|
11
17
|
const markers = release.versionLine
|
|
12
|
-
? [
|
|
18
|
+
? [
|
|
19
|
+
{
|
|
20
|
+
kind: "marker",
|
|
21
|
+
module: "release",
|
|
22
|
+
path: markerPath(release.versionLine.path),
|
|
23
|
+
line: release.versionLine.line,
|
|
24
|
+
},
|
|
25
|
+
]
|
|
13
26
|
: [];
|
|
14
|
-
return [...ctx.platform.releaseAutomation(ctx, release), ...markers];
|
|
27
|
+
return [...ctx.platform.releaseAutomation(ctx, release, directory), ...markers];
|
|
15
28
|
},
|
|
16
29
|
};
|
package/dist/platforms/github.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { stringify } from "yaml";
|
|
2
|
+
import { CONFIG_FILE } from "../config/load.js";
|
|
2
3
|
import { defaultBranch, STACK_IDS } from "../config/types.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
4
|
+
import { ConfigError } from "../errors.js";
|
|
5
|
+
import { MANAGED_HEADER, } from "../model.js";
|
|
6
|
+
import { readWorkflow } from "../templates.js";
|
|
7
|
+
import { EXACT_WORKFLOW_REF, PACKAGE_VERSION, REUSABLE_REPO, WORKFLOW_REF } from "../version.js";
|
|
5
8
|
const yamlFile = (module, path, data) => ({
|
|
6
9
|
kind: "file",
|
|
7
10
|
module,
|
|
@@ -9,10 +12,47 @@ const yamlFile = (module, path, data) => ({
|
|
|
9
12
|
content: `# ${MANAGED_HEADER}\n${stringify(data)}`,
|
|
10
13
|
});
|
|
11
14
|
const WORKFLOW_KEYS = ["name", "on", "permissions", "concurrency", "env", "defaults", "jobs"];
|
|
12
|
-
|
|
15
|
+
function workflowOrigin(ctx) {
|
|
16
|
+
// the repository that hosts the workflows calls its own files
|
|
17
|
+
if (ctx.repo.owner !== null && `${ctx.repo.owner}/${ctx.repo.name}` === REUSABLE_REPO)
|
|
18
|
+
return { local: true, self: true };
|
|
19
|
+
const { source, ref } = ctx.config.github?.workflows ?? {};
|
|
20
|
+
if (source === "local")
|
|
21
|
+
return { local: true, self: false };
|
|
22
|
+
const repo = source ?? REUSABLE_REPO;
|
|
23
|
+
if (ref === "exact") {
|
|
24
|
+
if (repo !== REUSABLE_REPO) {
|
|
25
|
+
throw new ConfigError(`${CONFIG_FILE}: github.workflows.ref: exact follows repokeeper's own releases; for ${repo}, name a tag, a branch or a commit`);
|
|
26
|
+
}
|
|
27
|
+
return { local: false, repo, ref: EXACT_WORKFLOW_REF, pinned: true };
|
|
28
|
+
}
|
|
29
|
+
// a copy in a repository of your own has no release tags of repokeeper's to follow
|
|
30
|
+
return {
|
|
31
|
+
local: false,
|
|
32
|
+
repo,
|
|
33
|
+
ref: ref ?? (repo === REUSABLE_REPO ? WORKFLOW_REF : "main"),
|
|
34
|
+
pinned: ref !== undefined,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** Reference to a reusable workflow, as the `uses` of a caller job. */
|
|
13
38
|
function workflowRef(ctx, file) {
|
|
14
|
-
const
|
|
15
|
-
return
|
|
39
|
+
const origin = workflowOrigin(ctx);
|
|
40
|
+
return origin.local ? `./.github/workflows/${file}` : `${origin.repo}/.github/workflows/${file}@${origin.ref}`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The reusable workflows themselves, for a repository that keeps its own copies. Seeds: written
|
|
44
|
+
* once and from then on the repository's, so its owners decide when a copy changes.
|
|
45
|
+
*/
|
|
46
|
+
function localWorkflows(ctx, module, files) {
|
|
47
|
+
const origin = workflowOrigin(ctx);
|
|
48
|
+
if (!origin.local || origin.self)
|
|
49
|
+
return [];
|
|
50
|
+
return [...new Set(files)].map((file) => ({
|
|
51
|
+
kind: "seed",
|
|
52
|
+
module,
|
|
53
|
+
path: `.github/workflows/${file}`,
|
|
54
|
+
content: `# Copied from repokeeper ${PACKAGE_VERSION} (https://github.com/${REUSABLE_REPO}). This copy is yours: repokeeper does not update it.\n${readWorkflow(file)}`,
|
|
55
|
+
}));
|
|
16
56
|
}
|
|
17
57
|
const workflowKey = (module, path, keyPath, value) => ({
|
|
18
58
|
kind: "yaml",
|
|
@@ -122,10 +162,18 @@ export const githubPlatform = {
|
|
|
122
162
|
}
|
|
123
163
|
return outputs;
|
|
124
164
|
},
|
|
125
|
-
dependencyUpdates(
|
|
126
|
-
|
|
165
|
+
dependencyUpdates(requested, ctx) {
|
|
166
|
+
// a pinned reference is moved by `repokeeper update` or by its owner in .repokeeper.yml; a
|
|
167
|
+
// Dependabot bump of it would edit a key repokeeper manages and show up as drift every time
|
|
168
|
+
const origin = workflowOrigin(ctx);
|
|
169
|
+
const pinnedWorkflows = !origin.local && origin.pinned ? [{ "dependency-name": `${origin.repo}*` }] : [];
|
|
170
|
+
// workflows always live at the root, whatever folders the stacks are in
|
|
171
|
+
const all = [...requested, { ecosystem: "github-actions", directory: "/" }];
|
|
172
|
+
const unique = all.filter((update, index) => all.findIndex((other) => other.ecosystem === update.ecosystem && other.directory === update.directory) ===
|
|
173
|
+
index);
|
|
174
|
+
const updates = unique.map(({ ecosystem, directory }) => ({
|
|
127
175
|
"package-ecosystem": ecosystem,
|
|
128
|
-
directory
|
|
176
|
+
directory,
|
|
129
177
|
schedule: { interval: "weekly" },
|
|
130
178
|
groups: { [`${ecosystem}-minor-and-patch`]: { "update-types": ["minor", "patch"] } },
|
|
131
179
|
// Dependabot infers a Conventional Commits prefix only from history; young repositories fail commitlint
|
|
@@ -134,6 +182,7 @@ export const githubPlatform = {
|
|
|
134
182
|
...(ecosystem === "npm"
|
|
135
183
|
? { ignore: [{ "dependency-name": "@types/node", "update-types": ["version-update:semver-major"] }] }
|
|
136
184
|
: {}),
|
|
185
|
+
...(ecosystem === "github-actions" && pinnedWorkflows.length > 0 ? { ignore: pinnedWorkflows } : {}),
|
|
137
186
|
}));
|
|
138
187
|
return [yamlFile("deps", ".github/dependabot.yml", { version: 2, updates })];
|
|
139
188
|
},
|
|
@@ -144,7 +193,7 @@ export const githubPlatform = {
|
|
|
144
193
|
if (stack.ci) {
|
|
145
194
|
jobs.push(workflowKey("ci", path, ["jobs", stack.id], {
|
|
146
195
|
uses: workflowRef(ctx, stack.ci.workflow),
|
|
147
|
-
with: stack.ci.with,
|
|
196
|
+
with: stack.directory ? { ...stack.ci.with, "working-directory": stack.directory } : stack.ci.with,
|
|
148
197
|
}));
|
|
149
198
|
}
|
|
150
199
|
}
|
|
@@ -159,7 +208,13 @@ export const githubPlatform = {
|
|
|
159
208
|
}
|
|
160
209
|
if (jobs.length === 0)
|
|
161
210
|
return [];
|
|
211
|
+
const called = [
|
|
212
|
+
...ctx.stacks.flatMap((stack) => (stack.ci ? [stack.ci.workflow] : [])),
|
|
213
|
+
...(ctx.config.modules.commits ? ["commitlint.yml"] : []),
|
|
214
|
+
...(ctx.config.modules.drift ? ["repokeeper-check.yml"] : []),
|
|
215
|
+
];
|
|
162
216
|
return [
|
|
217
|
+
...localWorkflows(ctx, "ci", called),
|
|
163
218
|
workflowKey("ci", path, ["name"], "ci"),
|
|
164
219
|
workflowKey("ci", path, ["on"], { pull_request: null, push: { branches: [defaultBranch(ctx.config)] } }),
|
|
165
220
|
workflowKey("ci", path, ["permissions"], { contents: "read" }),
|
|
@@ -173,9 +228,11 @@ export const githubPlatform = {
|
|
|
173
228
|
...jobs,
|
|
174
229
|
];
|
|
175
230
|
},
|
|
176
|
-
releaseAutomation(ctx, release) {
|
|
231
|
+
releaseAutomation(ctx, release, directory) {
|
|
177
232
|
const path = ".github/workflows/release.yml";
|
|
178
233
|
const seed = release.version ?? ctx.repo.releasedVersion ?? "0.0.0";
|
|
234
|
+
// release-please names a package by its folder; its changelog and extra files are relative to it
|
|
235
|
+
const packagePath = directory ?? ".";
|
|
179
236
|
return [
|
|
180
237
|
{
|
|
181
238
|
kind: "file",
|
|
@@ -184,7 +241,7 @@ export const githubPlatform = {
|
|
|
184
241
|
content: json({
|
|
185
242
|
$schema: RELEASE_PLEASE_SCHEMA,
|
|
186
243
|
packages: {
|
|
187
|
-
|
|
244
|
+
[packagePath]: {
|
|
188
245
|
"release-type": release.type,
|
|
189
246
|
"changelog-path": "CHANGELOG.md",
|
|
190
247
|
"bump-minor-pre-major": true,
|
|
@@ -206,8 +263,9 @@ export const githubPlatform = {
|
|
|
206
263
|
kind: "seed",
|
|
207
264
|
module: "release",
|
|
208
265
|
path: ".release-please-manifest.json",
|
|
209
|
-
content: json({
|
|
266
|
+
content: json({ [packagePath]: seed }),
|
|
210
267
|
},
|
|
268
|
+
...localWorkflows(ctx, "release", ["release-please.yml"]),
|
|
211
269
|
workflowKey("release", path, ["name"], "release"),
|
|
212
270
|
workflowKey("release", path, ["on"], { push: { branches: [defaultBranch(ctx.config)] } }),
|
|
213
271
|
workflowKey("release", path, ["permissions"], { contents: "read" }),
|
package/dist/platforms/gitlab.js
CHANGED
|
@@ -10,7 +10,7 @@ const md = (path, body) => ({
|
|
|
10
10
|
/** Web address of the GitLab instance the repository lives on. */
|
|
11
11
|
const baseUrl = (repo) => `https://${repo.host ?? "gitlab.com"}`;
|
|
12
12
|
const CI_FILE = ".gitlab-ci.yml";
|
|
13
|
-
const CI_KEYS = ["workflow", "node", "commits", "repokeeper", "renovate", "release"];
|
|
13
|
+
const CI_KEYS = ["workflow", "node", "go", "commits", "repokeeper", "renovate", "release"];
|
|
14
14
|
/** One managed top-level key of .gitlab-ci.yml; keys the user adds are left alone. */
|
|
15
15
|
const ciKey = (module, key, value) => ({
|
|
16
16
|
kind: "yaml",
|
|
@@ -48,6 +48,22 @@ function nodeJob(input) {
|
|
|
48
48
|
],
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* The go stack's CI inputs as a GitLab job. No module cache: it would have to live inside the project
|
|
53
|
+
* folder, where `gofmt -l .` would read it as the project's own code.
|
|
54
|
+
*/
|
|
55
|
+
function goJob(input) {
|
|
56
|
+
// setup-go's "stable" is the image's `latest` tag
|
|
57
|
+
const versions = JSON.parse(input["go-versions"] ?? '["stable"]').map((version) => version === "stable" ? "latest" : version);
|
|
58
|
+
return {
|
|
59
|
+
stage: "test",
|
|
60
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: a GitLab CI variable
|
|
61
|
+
image: "golang:${GO_VERSION}",
|
|
62
|
+
parallel: { matrix: [{ GO_VERSION: versions }] },
|
|
63
|
+
rules: [{ if: FOR_CHANGES }],
|
|
64
|
+
script: JSON.parse(input.commands ?? "[]"),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
51
67
|
/** commitlint from a scratch directory, so the project needs no commitlint of its own. */
|
|
52
68
|
const COMMITLINT = `dir="$(mktemp -d)"
|
|
53
69
|
(cd "$dir" && echo '{ "private": true }' > package.json && npm install --no-audit --no-fund @commitlint/cli@${TOOL_VERSIONS.commitlintCli} @commitlint/config-conventional@${TOOL_VERSIONS.commitlintConventional})
|
|
@@ -75,7 +91,7 @@ const SEMANTIC_RELEASE = `dir="$(mktemp -d)"
|
|
|
75
91
|
`;
|
|
76
92
|
export const gitlabPlatform = {
|
|
77
93
|
id: "gitlab",
|
|
78
|
-
stacks: ["node"],
|
|
94
|
+
stacks: ["node", "go"],
|
|
79
95
|
changeRequest: "merge request",
|
|
80
96
|
profileUrl: (repo) => (repo.owner ? `${baseUrl(repo)}/${repo.owner}` : null),
|
|
81
97
|
securityReport: (repo) => repo.owner
|
|
@@ -137,6 +153,9 @@ export const gitlabPlatform = {
|
|
|
137
153
|
const node = ctx.stacks.find((stack) => stack.id === "node")?.ci;
|
|
138
154
|
if (node)
|
|
139
155
|
jobs.push(ciKey("ci", "node", nodeJob(node.with)));
|
|
156
|
+
const go = ctx.stacks.find((stack) => stack.id === "go")?.ci;
|
|
157
|
+
if (go)
|
|
158
|
+
jobs.push(ciKey("ci", "go", goJob(go.with)));
|
|
140
159
|
if (ctx.config.modules.commits) {
|
|
141
160
|
jobs.push(ciKey("ci", "commits", {
|
|
142
161
|
stage: "test",
|
|
@@ -172,6 +191,8 @@ export const gitlabPlatform = {
|
|
|
172
191
|
},
|
|
173
192
|
releaseAutomation(ctx) {
|
|
174
193
|
const branch = defaultBranch(ctx.config);
|
|
194
|
+
// a Go module's version is its tag: there is no file to bump, so the release is the changelog and the tag
|
|
195
|
+
const npm = ctx.stacks.some((stack) => stack.release.type === "node");
|
|
175
196
|
return [
|
|
176
197
|
{
|
|
177
198
|
kind: "file",
|
|
@@ -186,11 +207,11 @@ export const gitlabPlatform = {
|
|
|
186
207
|
["@semantic-release/release-notes-generator", { preset: "conventionalcommits" }],
|
|
187
208
|
["@semantic-release/changelog", { changelogFile: "CHANGELOG.md" }],
|
|
188
209
|
// bumps package.json and the npm lock file; publishing stays the project's own business
|
|
189
|
-
["@semantic-release/npm", { npmPublish: false }],
|
|
210
|
+
...(npm ? [["@semantic-release/npm", { npmPublish: false }]] : []),
|
|
190
211
|
[
|
|
191
212
|
"@semantic-release/git",
|
|
192
213
|
{
|
|
193
|
-
assets: ["CHANGELOG.md", "package.json", "package-lock.json", "npm-shrinkwrap.json"],
|
|
214
|
+
assets: ["CHANGELOG.md", ...(npm ? ["package.json", "package-lock.json", "npm-shrinkwrap.json"] : [])],
|
|
194
215
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: a semantic-release template
|
|
195
216
|
message: "chore(release): ${nextRelease.version} [skip ci]",
|
|
196
217
|
},
|
package/dist/stacks/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
1
3
|
import { STACK_IDS } from "../config/types.js";
|
|
2
4
|
import { dartStack } from "./dart.js";
|
|
3
5
|
import { dotnetStack } from "./dotnet.js";
|
|
@@ -34,3 +36,57 @@ export async function detectStacks(root) {
|
|
|
34
36
|
// scripts beside another stack belong to that stack; the script pack is for script-only repositories
|
|
35
37
|
return found.length > 1 ? found.filter((id) => id !== "script") : found;
|
|
36
38
|
}
|
|
39
|
+
/** Folders that hold a project's by-products or samples rather than a project of the repository. */
|
|
40
|
+
const NOT_A_PROJECT = new Set([
|
|
41
|
+
"node_modules",
|
|
42
|
+
"vendor",
|
|
43
|
+
"dist",
|
|
44
|
+
"build",
|
|
45
|
+
"target",
|
|
46
|
+
"out",
|
|
47
|
+
"bin",
|
|
48
|
+
"obj",
|
|
49
|
+
"coverage",
|
|
50
|
+
"example",
|
|
51
|
+
"examples",
|
|
52
|
+
"test",
|
|
53
|
+
"tests",
|
|
54
|
+
"fixtures",
|
|
55
|
+
"doc",
|
|
56
|
+
"docs",
|
|
57
|
+
]);
|
|
58
|
+
/**
|
|
59
|
+
* The stacks of a repository and where they are: at the root or, when the root holds no project
|
|
60
|
+
* (a monorepo such as frontend/ and backend/), in its immediate folders.
|
|
61
|
+
*/
|
|
62
|
+
export async function detectStackLayout(root) {
|
|
63
|
+
const atRoot = await detectStacks(root);
|
|
64
|
+
const rooted = { stacks: atRoot, directories: {}, skipped: [] };
|
|
65
|
+
// loose scripts at the root do not make it a project: a monorepo often keeps some beside its folders
|
|
66
|
+
if (atRoot.some((id) => id !== "script"))
|
|
67
|
+
return rooted;
|
|
68
|
+
const directories = {};
|
|
69
|
+
const skipped = [];
|
|
70
|
+
const folders = readdirSync(root, { withFileTypes: true })
|
|
71
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && !NOT_A_PROJECT.has(entry.name))
|
|
72
|
+
.map((entry) => entry.name)
|
|
73
|
+
.sort();
|
|
74
|
+
for (const folder of folders) {
|
|
75
|
+
for (const id of await detectStacks(join(root, folder))) {
|
|
76
|
+
if (directories[id] === undefined)
|
|
77
|
+
directories[id] = folder;
|
|
78
|
+
else
|
|
79
|
+
skipped.push({ stack: id, directory: folder });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
let stacks = STACK_IDS.filter((id) => directories[id] !== undefined);
|
|
83
|
+
// nothing but scripts in the folders either: the root's own answer stands
|
|
84
|
+
if (atRoot.length > 0 && stacks.every((id) => id === "script"))
|
|
85
|
+
return rooted;
|
|
86
|
+
// as at the root: a folder of scripts beside real projects is tooling, not a stack
|
|
87
|
+
if (stacks.length > 1 && directories.script !== undefined) {
|
|
88
|
+
stacks = stacks.filter((id) => id !== "script");
|
|
89
|
+
delete directories.script;
|
|
90
|
+
}
|
|
91
|
+
return { stacks, directories, skipped: skipped.filter((entry) => stacks.includes(entry.stack)) };
|
|
92
|
+
}
|
package/dist/stacks/support.js
CHANGED
|
@@ -10,6 +10,25 @@ export function checkKeys(stack, options, keys) {
|
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* The `directory` option every stack takes: the folder of a monorepo the stack lives in.
|
|
15
|
+
* Returns it with forward slashes and no trailing slash, or undefined for the repository root.
|
|
16
|
+
*/
|
|
17
|
+
export function stackDirectory(stack, options) {
|
|
18
|
+
const value = options.directory;
|
|
19
|
+
if (value === undefined)
|
|
20
|
+
return undefined;
|
|
21
|
+
const key = `${CONFIG_FILE}: stack_options.${stack}.directory`;
|
|
22
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
23
|
+
throw new ConfigError(`${key} must be a folder name`);
|
|
24
|
+
const directory = value.trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
25
|
+
if (directory === "" || directory === ".")
|
|
26
|
+
return undefined;
|
|
27
|
+
if (directory.startsWith("/") || /^[A-Za-z]:/.test(directory) || directory.split("/").includes("..")) {
|
|
28
|
+
throw new ConfigError(`${key} must be a folder inside the repository, such as backend or apps/api`);
|
|
29
|
+
}
|
|
30
|
+
return directory;
|
|
31
|
+
}
|
|
13
32
|
/** A list option; YAML numbers such as `[22, 24]` count as strings. */
|
|
14
33
|
export function stringList(stack, options, key) {
|
|
15
34
|
const value = options[key];
|
package/dist/templates.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { normalizeEol } from "./sync/hash.js";
|
|
3
3
|
/** Reads a bundled template with LF endings; works from src/ (tests) and dist/ (package). */
|
|
4
4
|
export function readTemplate(relativePath) {
|
|
5
5
|
return normalizeEol(readFileSync(new URL(`../templates/${relativePath}`, import.meta.url), "utf8"));
|
|
6
6
|
}
|
|
7
|
+
/** File names of the reusable workflows generated CI and release files can call. */
|
|
8
|
+
export const REUSABLE_WORKFLOW = /^(stack-[a-z]+|commitlint|release-please|repokeeper-check)\.yml$/;
|
|
9
|
+
/** Reads one of the reusable workflows shipped with the package, with LF endings. */
|
|
10
|
+
export function readWorkflow(file) {
|
|
11
|
+
return normalizeEol(readFileSync(new URL(`../.github/workflows/${file}`, import.meta.url), "utf8"));
|
|
12
|
+
}
|
|
13
|
+
/** Every reusable workflow shipped with the package, by file name. */
|
|
14
|
+
export function listWorkflows() {
|
|
15
|
+
return readdirSync(new URL("../.github/workflows/", import.meta.url))
|
|
16
|
+
.filter((name) => REUSABLE_WORKFLOW.test(name))
|
|
17
|
+
.sort();
|
|
18
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -18,6 +18,8 @@ export const TOOL_VERSIONS = {
|
|
|
18
18
|
export const REUSABLE_REPO = "vannt-dev/repokeeper";
|
|
19
19
|
/** Moving tag callers pin to: the major version of this repokeeper (v0 during 0.x). */
|
|
20
20
|
export const WORKFLOW_REF = `v${PACKAGE_VERSION.split(".")[0]}`;
|
|
21
|
+
/** The tag of this release, for callers that pin to it instead of following the major tag. */
|
|
22
|
+
export const EXACT_WORKFLOW_REF = `v${PACKAGE_VERSION}`;
|
|
21
23
|
export function compareVersions(a, b) {
|
|
22
24
|
const pa = a.split(".").map(Number);
|
|
23
25
|
const pb = b.split(".").map(Number);
|