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
@@ -1,6 +1,8 @@
1
- import { defaultBranch } from "../config/types.js";
2
- import { MANAGED_HEADER } from "../model.js";
1
+ import { defaultBranch, STACK_IDS } from "../config/types.js";
2
+ import { MANAGED_HEADER, } from "../model.js";
3
+ import { releaseFiles } from "../release/bump.js";
3
4
  import { PACKAGE_VERSION, TOOL_VERSIONS } from "../version.js";
5
+ import { FOR_CHANGES, NOT_SCHEDULED, STACK_JOB_KEYS, stackJobs } from "./gitlab-jobs.js";
4
6
  const md = (path, body) => ({
5
7
  kind: "file",
6
8
  module: "health",
@@ -10,7 +12,7 @@ const md = (path, body) => ({
10
12
  /** Web address of the GitLab instance the repository lives on. */
11
13
  const baseUrl = (repo) => `https://${repo.host ?? "gitlab.com"}`;
12
14
  const CI_FILE = ".gitlab-ci.yml";
13
- const CI_KEYS = ["workflow", "node", "commits", "repokeeper", "renovate", "release"];
15
+ const CI_KEYS = ["workflow", "node", "go", ...STACK_JOB_KEYS, "commits", "repokeeper", "renovate", "release"];
14
16
  /** One managed top-level key of .gitlab-ci.yml; keys the user adds are left alone. */
15
17
  const ciKey = (module, key, value) => ({
16
18
  kind: "yaml",
@@ -20,9 +22,6 @@ const ciKey = (module, key, value) => ({
20
22
  value,
21
23
  order: CI_KEYS,
22
24
  });
23
- const NOT_SCHEDULED = '$CI_PIPELINE_SOURCE != "schedule"';
24
- /** The checks of a change: merge requests and the default branch, not schedules and not tag pipelines. */
25
- const FOR_CHANGES = `${NOT_SCHEDULED} && $CI_COMMIT_TAG == null`;
26
25
  /** Image of the jobs that only run tools, whatever Node.js versions the project tests on. */
27
26
  const TOOL_IMAGE = "node:24";
28
27
  /** The node stack's CI inputs, as the GitHub reusable workflow receives them, as a GitLab job. */
@@ -48,6 +47,22 @@ function nodeJob(input) {
48
47
  ],
49
48
  };
50
49
  }
50
+ /**
51
+ * The go stack's CI inputs as a GitLab job. No module cache: it would have to live inside the project
52
+ * folder, where `gofmt -l .` would read it as the project's own code.
53
+ */
54
+ function goJob(input) {
55
+ // setup-go's "stable" is the image's `latest` tag
56
+ const versions = JSON.parse(input["go-versions"] ?? '["stable"]').map((version) => version === "stable" ? "latest" : version);
57
+ return {
58
+ stage: "test",
59
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitLab CI variable
60
+ image: "golang:${GO_VERSION}",
61
+ parallel: { matrix: [{ GO_VERSION: versions }] },
62
+ rules: [{ if: FOR_CHANGES }],
63
+ script: JSON.parse(input.commands ?? "[]"),
64
+ };
65
+ }
51
66
  /** commitlint from a scratch directory, so the project needs no commitlint of its own. */
52
67
  const COMMITLINT = `dir="$(mktemp -d)"
53
68
  (cd "$dir" && echo '{ "private": true }' > package.json && npm install --no-audit --no-fund @commitlint/cli@${TOOL_VERSIONS.commitlintCli} @commitlint/config-conventional@${TOOL_VERSIONS.commitlintConventional})
@@ -68,14 +83,16 @@ const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
68
83
  /**
69
84
  * semantic-release and the plugins it does not bundle, from a scratch directory. It resolves plugins next to
70
85
  * itself before it looks in the project, so the project needs none of them installed.
86
+ *
87
+ * @param exec Whether the release runs a command of its own (the version bump of a stack npm does not know).
71
88
  */
72
- const SEMANTIC_RELEASE = `dir="$(mktemp -d)"
73
- (cd "$dir" && echo '{ "private": true }' > package.json && npm install --no-audit --no-fund semantic-release@${TOOL_VERSIONS.semanticRelease} @semantic-release/changelog@${TOOL_VERSIONS.semanticReleaseChangelog} @semantic-release/git@${TOOL_VERSIONS.semanticReleaseGit} @semantic-release/gitlab@${TOOL_VERSIONS.semanticReleaseGitlab} conventional-changelog-conventionalcommits@${TOOL_VERSIONS.conventionalCommitsPreset})
89
+ const semanticRelease = (exec) => `dir="$(mktemp -d)"
90
+ (cd "$dir" && echo '{ "private": true }' > package.json && npm install --no-audit --no-fund semantic-release@${TOOL_VERSIONS.semanticRelease} @semantic-release/changelog@${TOOL_VERSIONS.semanticReleaseChangelog} @semantic-release/git@${TOOL_VERSIONS.semanticReleaseGit} @semantic-release/gitlab@${TOOL_VERSIONS.semanticReleaseGitlab}${exec ? ` @semantic-release/exec@${TOOL_VERSIONS.semanticReleaseExec}` : ""} conventional-changelog-conventionalcommits@${TOOL_VERSIONS.conventionalCommitsPreset})
74
91
  "$dir/node_modules/.bin/semantic-release"
75
92
  `;
76
93
  export const gitlabPlatform = {
77
94
  id: "gitlab",
78
- stacks: ["node"],
95
+ stacks: STACK_IDS,
79
96
  changeRequest: "merge request",
80
97
  profileUrl: (repo) => (repo.owner ? `${baseUrl(repo)}/${repo.owner}` : null),
81
98
  securityReport: (repo) => repo.owner
@@ -137,6 +154,13 @@ export const gitlabPlatform = {
137
154
  const node = ctx.stacks.find((stack) => stack.id === "node")?.ci;
138
155
  if (node)
139
156
  jobs.push(ciKey("ci", "node", nodeJob(node.with)));
157
+ const go = ctx.stacks.find((stack) => stack.id === "go")?.ci;
158
+ if (go)
159
+ jobs.push(ciKey("ci", "go", goJob(go.with)));
160
+ for (const stack of ctx.stacks) {
161
+ for (const { key, job } of stackJobs(stack))
162
+ jobs.push(ciKey("ci", key, job));
163
+ }
140
164
  if (ctx.config.modules.commits) {
141
165
  jobs.push(ciKey("ci", "commits", {
142
166
  stage: "test",
@@ -170,8 +194,11 @@ export const gitlabPlatform = {
170
194
  ...jobs,
171
195
  ];
172
196
  },
173
- releaseAutomation(ctx) {
197
+ releaseAutomation(ctx, release) {
174
198
  const branch = defaultBranch(ctx.config);
199
+ const npm = release.type === "node";
200
+ // the files of the other stacks that hold a version; none for a Go module, whose version is its tag
201
+ const versionFiles = npm ? [] : releaseFiles(release);
175
202
  return [
176
203
  {
177
204
  kind: "file",
@@ -186,11 +213,24 @@ export const gitlabPlatform = {
186
213
  ["@semantic-release/release-notes-generator", { preset: "conventionalcommits" }],
187
214
  ["@semantic-release/changelog", { changelogFile: "CHANGELOG.md" }],
188
215
  // bumps package.json and the npm lock file; publishing stays the project's own business
189
- ["@semantic-release/npm", { npmPublish: false }],
216
+ ...(npm ? [["@semantic-release/npm", { npmPublish: false }]] : []),
217
+ // semantic-release knows no other stack's version file; repokeeper writes the version into it
218
+ ...(versionFiles.length > 0
219
+ ? [
220
+ [
221
+ "@semantic-release/exec",
222
+ { prepareCmd: `npx --yes repokeeper@${PACKAGE_VERSION} bump \${nextRelease.version}` },
223
+ ],
224
+ ]
225
+ : []),
190
226
  [
191
227
  "@semantic-release/git",
192
228
  {
193
- assets: ["CHANGELOG.md", "package.json", "package-lock.json", "npm-shrinkwrap.json"],
229
+ assets: [
230
+ "CHANGELOG.md",
231
+ ...(npm ? ["package.json", "package-lock.json", "npm-shrinkwrap.json"] : []),
232
+ ...versionFiles,
233
+ ],
194
234
  // biome-ignore lint/suspicious/noTemplateCurlyInString: a semantic-release template
195
235
  message: "chore(release): ${nextRelease.version} [skip ci]",
196
236
  },
@@ -205,7 +245,7 @@ export const gitlabPlatform = {
205
245
  image: TOOL_IMAGE,
206
246
  rules: [{ if: `${NOT_SCHEDULED} && $CI_COMMIT_BRANCH == "${branch}" && $GITLAB_TOKEN` }],
207
247
  variables: { GIT_DEPTH: "0" },
208
- script: [SEMANTIC_RELEASE],
248
+ script: [semanticRelease(versionFiles.length > 0)],
209
249
  }),
210
250
  ];
211
251
  },
@@ -0,0 +1,223 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ /**
4
+ * Writing a release's version into the files of a stack, for a release tool that has no updater per
5
+ * language: on GitHub release-please does this, on GitLab the release job runs `repokeeper bump`.
6
+ * Every function here takes the text of a file and returns it with the new version, or null when
7
+ * the file holds no version of the kind it looks for.
8
+ */
9
+ const SEMVER = /\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/;
10
+ /** Replaces the first match of `pattern` in the body of the `[name]` table; group 1 is what precedes the version. */
11
+ function inTomlTable(toml, name, pattern, version) {
12
+ const header = new RegExp(`^\\[${name.replace(/\./g, "\\.")}\\][^\\n]*$`, "m").exec(toml);
13
+ if (!header)
14
+ return null;
15
+ const start = header.index + header[0].length;
16
+ const next = /^\[/m.exec(toml.slice(start));
17
+ const end = next ? start + next.index : toml.length;
18
+ const body = toml.slice(start, end);
19
+ if (!pattern.test(body))
20
+ return null;
21
+ return toml.slice(0, start) + body.replace(pattern, `$1${version}$2`) + toml.slice(end);
22
+ }
23
+ const TOML_VERSION = /^(version\s*=\s*")[^"]*(")/m;
24
+ /** `version` of `[project]`, or of `[tool.poetry]` for a Poetry project without one. */
25
+ export function bumpPyproject(text, version) {
26
+ return inTomlTable(text, "project", TOML_VERSION, version) ?? inTomlTable(text, "tool.poetry", TOML_VERSION, version);
27
+ }
28
+ /** `version = x` of `[metadata]` in setup.cfg. */
29
+ export function bumpSetupCfg(text, version) {
30
+ const header = /^\[metadata\][^\n]*$/m.exec(text);
31
+ if (!header)
32
+ return null;
33
+ const start = header.index + header[0].length;
34
+ const next = /^\[/m.exec(text.slice(start));
35
+ const end = next ? start + next.index : text.length;
36
+ const pattern = /^(version\s*=\s*)\S+/m;
37
+ const body = text.slice(start, end);
38
+ // `attr:` and `file:` read the version from elsewhere; there is nothing to write here
39
+ if (!pattern.test(body) || /^version\s*=\s*(attr|file):/m.test(body))
40
+ return null;
41
+ return text.slice(0, start) + body.replace(pattern, `$1${version}`) + text.slice(end);
42
+ }
43
+ /** The `version="x"` argument of `setup()` in setup.py. */
44
+ export function bumpSetupPy(text, version) {
45
+ const pattern = /(\bversion\s*=\s*["'])[^"']*(["'])/;
46
+ return pattern.test(text) ? text.replace(pattern, `$1${version}$2`) : null;
47
+ }
48
+ /** `version:` of pubspec.yaml; a build number (`+4`) is kept and counted up, as stores require. */
49
+ export function bumpPubspec(text, version) {
50
+ const match = /^(version:\s*)(\S+)/m.exec(text);
51
+ if (!match)
52
+ return null;
53
+ const build = /\+(\d+)$/.exec(match[2]);
54
+ const next = build ? `${version}+${Number(build[1]) + 1}` : version;
55
+ return text.replace(/^(version:\s*)\S+/m, `$1${next}`);
56
+ }
57
+ /** `version` of `[package]` in Cargo.toml, or of `[workspace.package]` when the package inherits it. */
58
+ export function bumpCargoToml(text, version) {
59
+ return (inTomlTable(text, "package", TOML_VERSION, version) ?? inTomlTable(text, "workspace.package", TOML_VERSION, version));
60
+ }
61
+ /** The name of the crate at the root, for its entry in Cargo.lock. */
62
+ export function cargoPackageName(cargoToml) {
63
+ const table = cargoToml.split(/^\[/m).find((part) => part.startsWith("package]")) ?? "";
64
+ return /^name\s*=\s*"([^"]+)"/m.exec(table)?.[1] ?? null;
65
+ }
66
+ /** The crate's own entry in Cargo.lock, which `cargo build --locked` compares with Cargo.toml. */
67
+ export function bumpCargoLock(text, name, version) {
68
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
69
+ const pattern = new RegExp(`(\\[\\[package\\]\\]\\r?\\nname = "${escaped}"\\r?\\nversion = ")[^"]*(")`);
70
+ return pattern.test(text) ? text.replace(pattern, `$1${version}$2`) : null;
71
+ }
72
+ /** The top-level `version` of composer.json, when the project keeps one; Composer itself reads tags. */
73
+ export function bumpComposer(text, version) {
74
+ let current;
75
+ try {
76
+ current = JSON.parse(text).version;
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ if (typeof current !== "string")
82
+ return null;
83
+ const pattern = new RegExp(`("version"\\s*:\\s*")${current.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(")`);
84
+ return pattern.test(text) ? text.replace(pattern, `$1${version}$2`) : null;
85
+ }
86
+ /** `VERSION = "x"` of a gem's version.rb. */
87
+ export function bumpRubyVersion(text, version) {
88
+ const pattern = /^(\s*VERSION\s*=\s*["'])[^"']*(["'])/m;
89
+ return pattern.test(text) ? text.replace(pattern, `$1${version}$2`) : null;
90
+ }
91
+ /** The gem's own entry in Gemfile.lock: the spec of the `PATH` source that is the repository itself. */
92
+ export function bumpGemfileLock(text, version) {
93
+ const pattern = /(^PATH\r?\n {2}remote: \.\r?\n {2}specs:\r?\n {4}\S+ \()[^)]*(\))/m;
94
+ return pattern.test(text) ? text.replace(pattern, `$1${version}$2`) : null;
95
+ }
96
+ /** The elements of an XML document in order, without reading comments, CDATA or processing instructions as tags. */
97
+ function* elements(xml) {
98
+ const token = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<\?[\s\S]*?\?>|<!\w[^>]*>|<(\/?)([\w.:-]+)([^>]*?)(\/?)>/g;
99
+ const open = [];
100
+ for (let match = token.exec(xml); match !== null; match = token.exec(xml)) {
101
+ const [, closing, name, , selfClosing] = match;
102
+ if (name === undefined || selfClosing === "/")
103
+ continue;
104
+ if (closing === "/") {
105
+ const element = open.pop();
106
+ if (element === undefined)
107
+ return;
108
+ yield { path: [...open.map((e) => e.name), element.name], start: element.start, end: match.index };
109
+ }
110
+ else {
111
+ open.push({ name, start: match.index + match[0].length });
112
+ }
113
+ }
114
+ }
115
+ /** Sets the text of the first element whose path ends with (or, when `anchored`, is exactly) `path`. */
116
+ function setElement(xml, path, version, anchored) {
117
+ for (const element of elements(xml)) {
118
+ const tail = anchored ? element.path : element.path.slice(-path.length);
119
+ if (tail.length !== path.length || tail.some((name, index) => name !== path[index]))
120
+ continue;
121
+ return xml.slice(0, element.start) + version + xml.slice(element.end);
122
+ }
123
+ return null;
124
+ }
125
+ /** `<version>` of the project itself in pom.xml: not the parent's, a dependency's or a plugin's. */
126
+ export function bumpPom(text, version) {
127
+ return setElement(text, ["project", "version"], version, true);
128
+ }
129
+ /** An element named by a path such as `//Project/PropertyGroup/Version` (the form release-please takes). */
130
+ export function bumpXml(text, xpath, version) {
131
+ const anchored = !xpath.startsWith("//");
132
+ const path = xpath.replace(/^\/+/, "").split("/");
133
+ return path.every((name) => /^[\w.:-]+$/.test(name)) ? setElement(text, path, version, anchored) : null;
134
+ }
135
+ /**
136
+ * release-please's generic updater: the version on a line marked `x-release-please-version`, and on
137
+ * every line between `x-release-please-start-version` and `x-release-please-end`.
138
+ */
139
+ export function bumpMarked(text, version) {
140
+ let inside = false;
141
+ let changed = false;
142
+ const lines = text.split("\n").map((line) => {
143
+ if (line.includes("x-release-please-start-version")) {
144
+ inside = true;
145
+ return line;
146
+ }
147
+ if (line.includes("x-release-please-end")) {
148
+ inside = false;
149
+ return line;
150
+ }
151
+ if ((inside || line.includes("x-release-please-version")) && SEMVER.test(line)) {
152
+ changed = true;
153
+ return line.replace(SEMVER, version);
154
+ }
155
+ return line;
156
+ });
157
+ return changed ? lines.join("\n") : null;
158
+ }
159
+ /** The files that can hold the version of a release, with how each is rewritten. Paths are relative to the stack. */
160
+ function plan(release, version) {
161
+ const own = [];
162
+ if (release.type === "python") {
163
+ own.push({ path: "pyproject.toml", edit: (text) => bumpPyproject(text, version) }, { path: "setup.cfg", edit: (text) => bumpSetupCfg(text, version) }, { path: "setup.py", edit: (text) => bumpSetupPy(text, version) });
164
+ }
165
+ else if (release.type === "dart") {
166
+ own.push({ path: "pubspec.yaml", edit: (text) => bumpPubspec(text, version) });
167
+ }
168
+ else if (release.type === "maven") {
169
+ own.push({ path: "pom.xml", edit: (text) => bumpPom(text, version) });
170
+ }
171
+ else if (release.type === "rust") {
172
+ own.push({ path: "Cargo.toml", edit: (text) => bumpCargoToml(text, version) }, {
173
+ path: "Cargo.lock",
174
+ edit: async (text, read) => {
175
+ const name = cargoPackageName((await read("Cargo.toml")) ?? "");
176
+ return name ? bumpCargoLock(text, name, version) : null;
177
+ },
178
+ });
179
+ }
180
+ else if (release.type === "php") {
181
+ own.push({ path: "composer.json", edit: (text) => bumpComposer(text, version) });
182
+ }
183
+ else if (release.type === "ruby") {
184
+ if (release.versionFile)
185
+ own.push({ path: release.versionFile, edit: (text) => bumpRubyVersion(text, version) });
186
+ own.push({ path: "Gemfile.lock", edit: (text) => bumpGemfileLock(text, version) });
187
+ }
188
+ const extra = (release.extraFiles ?? []).map((file) => typeof file === "string"
189
+ ? { path: file, edit: (text) => bumpMarked(text, version) }
190
+ : { path: file.path, edit: (text) => bumpXml(text, file.xpath, version) });
191
+ return [...own, ...extra];
192
+ }
193
+ /** Paths, relative to the stack, a release of this kind can change; for the release tool to commit. */
194
+ export function releaseFiles(release) {
195
+ return [...new Set(plan(release, "0.0.0").map((file) => file.path))];
196
+ }
197
+ /**
198
+ * Writes `version` into the stack's version files under `root` (the stack's own folder).
199
+ * Returns the paths it changed (would change, when `write` is off); a file without a version is left as it is.
200
+ */
201
+ export async function bumpVersion(root, release, version, write = true) {
202
+ const read = async (path) => {
203
+ try {
204
+ return await readFile(join(root, path), "utf8");
205
+ }
206
+ catch {
207
+ return null;
208
+ }
209
+ };
210
+ const changed = [];
211
+ for (const { path, edit } of plan(release, version)) {
212
+ const text = await read(path);
213
+ if (text === null)
214
+ continue;
215
+ const next = await edit(text, read);
216
+ if (next === null || next === text)
217
+ continue;
218
+ if (write)
219
+ await writeFile(join(root, path), next);
220
+ changed.push(path);
221
+ }
222
+ return changed;
223
+ }
@@ -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
+ }
@@ -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
@@ -9,6 +9,7 @@ export const TOOL_VERSIONS = {
9
9
  semanticRelease: "25.0.9",
10
10
  semanticReleaseChangelog: "7.0.0",
11
11
  semanticReleaseGit: "11.0.1",
12
+ semanticReleaseExec: "7.1.0",
12
13
  semanticReleaseGitlab: "13.3.3",
13
14
  // 10.x needs conventional-changelog-writer 9, and semantic-release 25's notes generator bundles 8
14
15
  conventionalCommitsPreset: "9.3.1",
@@ -18,6 +19,8 @@ export const TOOL_VERSIONS = {
18
19
  export const REUSABLE_REPO = "vannt-dev/repokeeper";
19
20
  /** Moving tag callers pin to: the major version of this repokeeper (v0 during 0.x). */
20
21
  export const WORKFLOW_REF = `v${PACKAGE_VERSION.split(".")[0]}`;
22
+ /** The tag of this release, for callers that pin to it instead of following the major tag. */
23
+ export const EXACT_WORKFLOW_REF = `v${PACKAGE_VERSION}`;
21
24
  export function compareVersions(a, b) {
22
25
  const pa = a.split(".").map(Number);
23
26
  const pb = b.split(".").map(Number);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repokeeper",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Keep every repository on one maintained standard: commits, git hooks, CI, releases, dependency updates and repo settings.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,11 @@
8
8
  },
9
9
  "files": [
10
10
  "dist/",
11
- "templates/"
11
+ "templates/",
12
+ ".github/workflows/stack-*.yml",
13
+ ".github/workflows/commitlint.yml",
14
+ ".github/workflows/release-please.yml",
15
+ ".github/workflows/repokeeper-check.yml"
12
16
  ],
13
17
  "engines": {
14
18
  "node": ">=22.12.0"