repokeeper 0.5.0 → 0.7.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.
Files changed (43) hide show
  1. package/.github/workflows/commitlint.yml +49 -0
  2. package/.github/workflows/release-please.yml +59 -0
  3. package/.github/workflows/repokeeper-check.yml +30 -0
  4. package/.github/workflows/stack-dart.yml +72 -0
  5. package/.github/workflows/stack-dotnet.yml +68 -0
  6. package/.github/workflows/stack-go.yml +59 -0
  7. package/.github/workflows/stack-java.yml +70 -0
  8. package/.github/workflows/stack-node.yml +85 -0
  9. package/.github/workflows/stack-php.yml +66 -0
  10. package/.github/workflows/stack-python.yml +70 -0
  11. package/.github/workflows/stack-ruby.yml +65 -0
  12. package/.github/workflows/stack-rust.yml +60 -0
  13. package/.github/workflows/stack-script.yml +87 -0
  14. package/README.md +60 -106
  15. package/dist/cli.js +52 -9
  16. package/dist/commands/bump.js +30 -0
  17. package/dist/commands/context.js +19 -2
  18. package/dist/commands/eject.js +74 -0
  19. package/dist/commands/gitlab.js +46 -0
  20. package/dist/commands/init.js +104 -10
  21. package/dist/commands/presets.js +114 -0
  22. package/dist/commands/report.js +7 -5
  23. package/dist/commands/update.js +3 -2
  24. package/dist/config/schema.js +40 -1
  25. package/dist/duplicates.js +2 -2
  26. package/dist/errors.js +1 -1
  27. package/dist/gitlab/api.js +50 -0
  28. package/dist/gitlab/settings.js +181 -0
  29. package/dist/model.js +8 -0
  30. package/dist/modules/commits.js +3 -2
  31. package/dist/modules/deps.js +1 -1
  32. package/dist/modules/health.js +3 -3
  33. package/dist/modules/hooks.js +7 -5
  34. package/dist/modules/release.js +18 -5
  35. package/dist/platforms/github.js +70 -12
  36. package/dist/platforms/gitlab-jobs.js +191 -0
  37. package/dist/platforms/gitlab.js +53 -13
  38. package/dist/release/bump.js +223 -0
  39. package/dist/stacks/index.js +56 -0
  40. package/dist/stacks/support.js +19 -0
  41. package/dist/templates.js +13 -1
  42. package/dist/version.js +3 -0
  43. 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
+ }
@@ -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
- if (ctx.stacks.some((s) => s.id === "node")) {
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",
@@ -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((s) => s.dependabot)),
4
+ outputs: (ctx) => ctx.platform.dependencyUpdates(ctx.stacks.flatMap((stack) => stack.dependabot.map((ecosystem) => ({ ecosystem, directory: stack.directory ? `/${stack.directory}` : "/" }))), ctx),
5
5
  };
@@ -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.map((s) => s.install).filter((c) => c !== null);
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.some((s) => s.id === "node")
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(" ");
@@ -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).map((job) => ({ ...job, stage_fixed: true }));
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.some((s) => s.id === "node");
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.some((s) => s.id === "node")) {
32
+ if (nodeAtRoot(ctx.stacks)) {
31
33
  outputs.push({
32
34
  kind: "json",
33
35
  module: "hooks",
@@ -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
- const language = stacks.find((stack) => stack.release.type !== "simple");
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 release = pickRelease(ctx.stacks);
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
- ? [{ kind: "marker", module: "release", path: release.versionLine.path, line: release.versionLine.line }]
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
  };
@@ -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 { MANAGED_HEADER } from "../model.js";
4
- import { PACKAGE_VERSION, REUSABLE_REPO, WORKFLOW_REF } from "../version.js";
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
- /** Reference to a reusable workflow; local inside the repository that hosts them. */
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 self = ctx.repo.owner !== null && `${ctx.repo.owner}/${ctx.repo.name}` === REUSABLE_REPO;
15
- return self ? `./.github/workflows/${file}` : `${REUSABLE_REPO}/.github/workflows/${file}@${WORKFLOW_REF}`;
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(ecosystems) {
126
- const updates = [...new Set([...ecosystems, "github-actions"])].map((ecosystem) => ({
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({ ".": seed }),
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" }),
@@ -0,0 +1,191 @@
1
+ export const NOT_SCHEDULED = '$CI_PIPELINE_SOURCE != "schedule"';
2
+ /** The checks of a change: merge requests and the default branch, not schedules and not tag pipelines. */
3
+ export const FOR_CHANGES = `${NOT_SCHEDULED} && $CI_COMMIT_TAG == null`;
4
+ const list = (input, key) => JSON.parse(input[key] ?? "[]");
5
+ /** A job per version: the image is named with the matrix variable, so each run pulls its own. */
6
+ function perVersion(image, variable, versions, script, more = {}) {
7
+ return {
8
+ stage: "test",
9
+ image,
10
+ parallel: { matrix: [{ [variable]: versions }] },
11
+ rules: [{ if: FOR_CHANGES }],
12
+ ...more,
13
+ script,
14
+ };
15
+ }
16
+ /** A job that runs once. */
17
+ const once = (image, script, more = {}) => ({
18
+ stage: "test",
19
+ image,
20
+ rules: [{ if: FOR_CHANGES }],
21
+ ...more,
22
+ script,
23
+ });
24
+ /** The version of shfmt the script stack checks with; the same as in .github/workflows/stack-script.yml. */
25
+ export const SHFMT_VERSION = "3.14.1";
26
+ /** The version of PSScriptAnalyzer the script stack checks with; the same as in stack-script.yml. */
27
+ export const SCRIPT_ANALYZER_VERSION = "1.25.0";
28
+ const SHFMT = `wget -q -O /usr/local/bin/shfmt https://github.com/mvdan/sh/releases/download/v${SHFMT_VERSION}/shfmt_v${SHFMT_VERSION}_linux_amd64
29
+ chmod +x /usr/local/bin/shfmt
30
+ git ls-files -z '*.sh' | xargs -0 -r shfmt -d
31
+ `;
32
+ // the settings file is the repository's own choice of rules; without one, errors and warnings fail the job.
33
+ // Stop on the first error: an analyzer that fails to load would otherwise report no issues and pass
34
+ const SCRIPT_ANALYZER = `pwsh -NoProfile -Command '
35
+ $ErrorActionPreference = "Stop"
36
+ Set-PSRepository PSGallery -InstallationPolicy Trusted
37
+ Install-Module PSScriptAnalyzer -RequiredVersion ${SCRIPT_ANALYZER_VERSION} -Force -Scope CurrentUser
38
+ $issues = if (Test-Path PSScriptAnalyzerSettings.psd1) {
39
+ Invoke-ScriptAnalyzer -Path . -Recurse -Settings PSScriptAnalyzerSettings.psd1
40
+ } else {
41
+ Invoke-ScriptAnalyzer -Path . -Recurse -Severity Error, Warning
42
+ }
43
+ $issues | Format-Table -AutoSize
44
+ if ($issues) { exit 1 }
45
+ '
46
+ `;
47
+ /** Composer is not in the php image; neither are the git and unzip it fetches packages with. */
48
+ const COMPOSER = `apt-get update -qq && apt-get install -y -qq git unzip > /dev/null
49
+ php -r "copy('https://getcomposer.org/installer', '/tmp/composer-setup.php');"
50
+ php /tmp/composer-setup.php --quiet --install-dir=/usr/local/bin --filename=composer
51
+ `;
52
+ /**
53
+ * PowerShell for Linux. Not `mcr.microsoft.com/powershell`: its `latest` stopped at 7.4.2, older than
54
+ * PSScriptAnalyzer loads on. The .NET SDK image carries a PowerShell that is kept current.
55
+ */
56
+ const POWERSHELL_IMAGE = "mcr.microsoft.com/dotnet/sdk:10.0";
57
+ /** Newest first, so the image is the newest SDK and the older ones are installed beside it. */
58
+ const newestFirst = (versions) => [...versions].sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
59
+ /**
60
+ * The CI jobs of a stack on GitLab: the inputs its GitHub reusable workflow receives, run in a
61
+ * container image instead of on a runner that a setup action prepares. node and go are in gitlab.ts.
62
+ */
63
+ export function stackJobs(stack) {
64
+ const input = stack.ci?.with;
65
+ if (input === undefined)
66
+ return [];
67
+ const commands = list(input, "commands");
68
+ const install = input["install-command"] ? [input["install-command"]] : [];
69
+ if (stack.id === "python") {
70
+ const uv = input.manager === "uv";
71
+ return [
72
+ {
73
+ key: "python",
74
+ job: perVersion(
75
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitLab CI variable
76
+ "python:${PYTHON_VERSION}", "PYTHON_VERSION", list(input, "python-versions"), [...(uv ? ["python -m pip install uv"] : []), ...install, ...commands],
77
+ // uv then runs the image's interpreter instead of fetching one of its own choice
78
+ { variables: { PIP_ROOT_USER_ACTION: "ignore", ...(uv ? { UV_PYTHON: "$PYTHON_VERSION" } : {}) } }),
79
+ },
80
+ ];
81
+ }
82
+ if (stack.id === "rust") {
83
+ return [
84
+ {
85
+ key: "rust",
86
+ // the toolchain is installed by name, as on GitHub, so "stable", "beta" and "1.80" all work
87
+ job: perVersion("rust:latest", "RUST_VERSION", list(input, "rust-versions"), [
88
+ 'rustup toolchain install "$RUST_VERSION" --profile minimal --component rustfmt,clippy',
89
+ 'rustup default "$RUST_VERSION"',
90
+ ...commands,
91
+ ]),
92
+ },
93
+ ];
94
+ }
95
+ if (stack.id === "ruby") {
96
+ return [
97
+ {
98
+ key: "ruby",
99
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitLab CI variable
100
+ job: perVersion("ruby:${RUBY_VERSION}", "RUBY_VERSION", list(input, "ruby-versions"), [
101
+ ...install,
102
+ ...commands,
103
+ ]),
104
+ },
105
+ ];
106
+ }
107
+ if (stack.id === "php") {
108
+ return [
109
+ {
110
+ key: "php",
111
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitLab CI variable
112
+ job: perVersion("php:${PHP_VERSION}-cli", "PHP_VERSION", list(input, "php-versions"), [
113
+ COMPOSER,
114
+ ...install,
115
+ ...commands,
116
+ ]),
117
+ },
118
+ ];
119
+ }
120
+ if (stack.id === "dart") {
121
+ const flutter = input.flutter === "true";
122
+ return [
123
+ {
124
+ key: "dart",
125
+ job: perVersion(
126
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitLab CI variable
127
+ flutter ? "ghcr.io/cirruslabs/flutter:${SDK_VERSION}" : "dart:${SDK_VERSION}", "SDK_VERSION", list(input, "sdk-versions"), [...install, ...commands]),
128
+ },
129
+ ];
130
+ }
131
+ if (stack.id === "java" || stack.id === "kotlin") {
132
+ const image = input["build-tool"] === "maven"
133
+ ? // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitLab CI variable
134
+ "maven:3-eclipse-temurin-${JAVA_VERSION}"
135
+ : input["gradle-version"] === ""
136
+ ? // the project's own wrapper fetches its Gradle
137
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitLab CI variable
138
+ "eclipse-temurin:${JAVA_VERSION}-jdk"
139
+ : // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitLab CI variable
140
+ "gradle:jdk${JAVA_VERSION}";
141
+ return [{ key: stack.id, job: perVersion(image, "JAVA_VERSION", list(input, "java-versions"), commands) }];
142
+ }
143
+ if (stack.id === "dotnet") {
144
+ const [newest = "9.0", ...older] = newestFirst(list(input, "dotnet-versions"));
145
+ return [
146
+ {
147
+ key: "dotnet",
148
+ // one job with every SDK side by side, as on GitHub: a project may target several of them
149
+ job: once(`mcr.microsoft.com/dotnet/sdk:${newest}`, [
150
+ ...older.map((version) => `curl -fsSL https://dot.net/v1/dotnet-install.sh | bash -s -- --channel ${version} --install-dir /usr/share/dotnet`),
151
+ ...commands,
152
+ ], { variables: { DOTNET_CLI_TELEMETRY_OPTOUT: "1", DOTNET_NOLOGO: "1" } }),
153
+ },
154
+ ];
155
+ }
156
+ if (stack.id === "script") {
157
+ const jobs = [];
158
+ if (input["shell-scripts"] === "true") {
159
+ jobs.push({
160
+ key: "shell",
161
+ job: once("koalaman/shellcheck-alpine:stable", [
162
+ "apk add --no-cache git > /dev/null",
163
+ "git ls-files -z '*.sh' | xargs -0 -r shellcheck",
164
+ SHFMT,
165
+ ]),
166
+ });
167
+ }
168
+ if (input["powershell-scripts"] === "true") {
169
+ jobs.push({ key: "powershell", job: once(POWERSHELL_IMAGE, [SCRIPT_ANALYZER]) });
170
+ }
171
+ // the image has both bash and pwsh, whichever the test command is written for
172
+ if (input["test-command"])
173
+ jobs.push({ key: "script-test", job: once(POWERSHELL_IMAGE, [input["test-command"]]) });
174
+ return jobs;
175
+ }
176
+ return [];
177
+ }
178
+ /** Every key `stackJobs` can write, in the order they appear in .gitlab-ci.yml. */
179
+ export const STACK_JOB_KEYS = [
180
+ "python",
181
+ "dart",
182
+ "shell",
183
+ "powershell",
184
+ "script-test",
185
+ "java",
186
+ "dotnet",
187
+ "rust",
188
+ "kotlin",
189
+ "php",
190
+ "ruby",
191
+ ];