repokeeper 0.6.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.
package/README.md CHANGED
@@ -6,8 +6,8 @@ the standard evolves.
6
6
 
7
7
  > Status: early development. Supported stacks: Node.js (including NestJS), Python, Dart and
8
8
  > Flutter, shell and PowerShell scripts, Java (Maven and Gradle), Kotlin, .NET, Go, Rust, PHP and
9
- > Ruby, each with CI and releases, plus GitHub settings through `repokeeper github apply`. GitLab is
10
- > supported for Node.js and Go projects, with project settings through `repokeeper gitlab apply`. See the
9
+ > Ruby, each with CI and releases, on GitHub and on GitLab, plus repository settings through
10
+ > `repokeeper github apply` and `repokeeper gitlab apply`. See the
11
11
  > [design](docs/superpowers/specs/2026-09-25-repokeeper-design.md).
12
12
 
13
13
  ## How it works
@@ -106,7 +106,7 @@ Requires Node.js 22.12 or newer.
106
106
  | [CI and releases](docs/ci-and-releases.md) | The workflows repokeeper writes, pinning or mirroring the reusable workflows, `repokeeper eject`, script linting, release-please, the drift check |
107
107
  | [Monorepos](docs/monorepos.md) | Stacks in folders: `stack_options.<stack>.directory`, what runs where, the limits |
108
108
  | [GitHub settings](docs/github-settings.md) | `repokeeper github apply`: description, topics, merge settings, security, branch protection |
109
- | [GitLab](docs/gitlab.md) | What differs on GitLab, the two jobs that need a token, and `repokeeper gitlab apply`: merge settings, branch protection, the Renovate schedule |
109
+ | [GitLab](docs/gitlab.md) | What differs on GitLab, the image each stack's job runs in, how releases write the version, the two jobs that need a token, and `repokeeper gitlab apply`: merge settings, branch protection, the Renovate schedule |
110
110
 
111
111
  ## Pilots
112
112
 
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import { realpathSync } from "node:fs";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { parseArgs } from "node:util";
6
+ import { bumpCommand } from "./commands/bump.js";
6
7
  import { checkCommand } from "./commands/check.js";
7
8
  import { ejectCommand } from "./commands/eject.js";
8
9
  import { githubApplyCommand } from "./commands/github.js";
@@ -23,6 +24,7 @@ export const USAGE = [
23
24
  " github apply diff the GitHub settings against .repokeeper.yml and apply them",
24
25
  " gitlab apply the same for a GitLab project: settings, branch protection, the Renovate schedule",
25
26
  " eject copy the reusable CI workflows into this repository and call them from there",
27
+ " bump <version> write a release's version into the stack's version files (the GitLab release job runs it)",
26
28
  "",
27
29
  "options:",
28
30
  " --dry-run show what would change without writing",
@@ -125,6 +127,8 @@ export async function run(argv, io) {
125
127
  return await updateCommand(io.cwd, options, io);
126
128
  if (command === "eject")
127
129
  return await ejectCommand(io.cwd, options, io);
130
+ if (command === "bump")
131
+ return await bumpCommand(io.cwd, positionals[1], options, io);
128
132
  if (command === "github") {
129
133
  if (positionals[1] !== "apply")
130
134
  throw new UsageError("usage: repokeeper github apply [--dry-run] [--yes]");
@@ -0,0 +1,30 @@
1
+ import { join } from "node:path";
2
+ import { loadConfig } from "../config/load.js";
3
+ import { UsageError } from "../errors.js";
4
+ import { pickReleaseStack } from "../modules/release.js";
5
+ import { bumpVersion } from "../release/bump.js";
6
+ import { buildContext } from "./context.js";
7
+ const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
8
+ /**
9
+ * `repokeeper bump <version>`: writes a release's version into the version files of the stack the
10
+ * repository releases. The GitLab release job runs it, where no release tool knows the stack's files.
11
+ */
12
+ export async function bumpCommand(root, version, options, io) {
13
+ if (version === undefined || !VERSION.test(version)) {
14
+ throw new UsageError("usage: repokeeper bump <version> [--dry-run], with a version such as 1.4.0");
15
+ }
16
+ const ctx = await buildContext(root, await loadConfig(root));
17
+ const stack = pickReleaseStack(ctx.stacks);
18
+ if (stack === undefined)
19
+ throw new UsageError("no stack to release");
20
+ const folder = stack.directory ? join(root, stack.directory) : root;
21
+ const changed = await bumpVersion(folder, stack.release, version, !options.dryRun);
22
+ for (const path of changed)
23
+ io.out(`bumped ${stack.directory ? `${stack.directory}/${path}` : path}`);
24
+ if (changed.length === 0) {
25
+ io.out(`no file of the ${stack.id} stack holds a version to change; the release is its tag`);
26
+ }
27
+ if (options.dryRun)
28
+ io.out("dry run: nothing written");
29
+ return 0;
30
+ }
@@ -68,9 +68,11 @@ export async function printWarnings(root, ctx, io) {
68
68
  }
69
69
  }
70
70
  if (ctx.platform.id === "gitlab") {
71
- const os = ctx.stacks.find((stack) => stack.id === "node")?.ci?.with.os;
72
- if (os !== undefined && os !== '["ubuntu-latest"]') {
73
- io.out("note: node.os is ignored on gitlab (Linux runners only)");
71
+ for (const stack of ctx.stacks) {
72
+ const os = stack.ci?.with.os;
73
+ if (os !== undefined && os !== '["ubuntu-latest"]') {
74
+ io.out(`note: ${stack.id}.os is ignored on gitlab (Linux runners only)`);
75
+ }
74
76
  }
75
77
  // repokeeper's jobs use GitLab's default stages; a stages list of the user's own replaces those
76
78
  let ci = null;
@@ -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
+ ];
@@ -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", "go", "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. */
@@ -84,14 +83,16 @@ const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
84
83
  /**
85
84
  * semantic-release and the plugins it does not bundle, from a scratch directory. It resolves plugins next to
86
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).
87
88
  */
88
- const SEMANTIC_RELEASE = `dir="$(mktemp -d)"
89
- (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})
90
91
  "$dir/node_modules/.bin/semantic-release"
91
92
  `;
92
93
  export const gitlabPlatform = {
93
94
  id: "gitlab",
94
- stacks: ["node", "go"],
95
+ stacks: STACK_IDS,
95
96
  changeRequest: "merge request",
96
97
  profileUrl: (repo) => (repo.owner ? `${baseUrl(repo)}/${repo.owner}` : null),
97
98
  securityReport: (repo) => repo.owner
@@ -156,6 +157,10 @@ export const gitlabPlatform = {
156
157
  const go = ctx.stacks.find((stack) => stack.id === "go")?.ci;
157
158
  if (go)
158
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
+ }
159
164
  if (ctx.config.modules.commits) {
160
165
  jobs.push(ciKey("ci", "commits", {
161
166
  stage: "test",
@@ -189,10 +194,11 @@ export const gitlabPlatform = {
189
194
  ...jobs,
190
195
  ];
191
196
  },
192
- releaseAutomation(ctx) {
197
+ releaseAutomation(ctx, release) {
193
198
  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");
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);
196
202
  return [
197
203
  {
198
204
  kind: "file",
@@ -208,10 +214,23 @@ export const gitlabPlatform = {
208
214
  ["@semantic-release/changelog", { changelogFile: "CHANGELOG.md" }],
209
215
  // bumps package.json and the npm lock file; publishing stays the project's own business
210
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
+ : []),
211
226
  [
212
227
  "@semantic-release/git",
213
228
  {
214
- assets: ["CHANGELOG.md", ...(npm ? ["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
+ ],
215
234
  // biome-ignore lint/suspicious/noTemplateCurlyInString: a semantic-release template
216
235
  message: "chore(release): ${nextRelease.version} [skip ci]",
217
236
  },
@@ -226,7 +245,7 @@ export const gitlabPlatform = {
226
245
  image: TOOL_IMAGE,
227
246
  rules: [{ if: `${NOT_SCHEDULED} && $CI_COMMIT_BRANCH == "${branch}" && $GITLAB_TOKEN` }],
228
247
  variables: { GIT_DEPTH: "0" },
229
- script: [SEMANTIC_RELEASE],
248
+ script: [semanticRelease(versionFiles.length > 0)],
230
249
  }),
231
250
  ];
232
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
+ }
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repokeeper",
3
- "version": "0.6.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": {