repokeeper 0.4.7 → 0.5.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,7 +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`. See the
9
+ > Ruby, each with CI and releases, plus GitHub settings through `repokeeper github apply`. GitLab is
10
+ > supported for Node.js projects. See the
10
11
  > [design](docs/superpowers/specs/2026-09-25-repokeeper-design.md).
11
12
 
12
13
  ## Usage
@@ -111,6 +112,48 @@ It prints every change first and applies them with `--yes`, or after you confirm
111
112
  access to the repository. Legacy branch protection, visibility, secrets and collaborators are never
112
113
  touched.
113
114
 
115
+ ## GitLab
116
+
117
+ `repokeeper init` selects GitLab when the `origin` remote's host contains `gitlab`; pass
118
+ `--platform gitlab` otherwise (a self-hosted instance under another name, or no remote yet). Only
119
+ the node stack is supported on GitLab for now.
120
+
121
+ What differs from GitHub:
122
+
123
+ | | GitHub | GitLab |
124
+ | --- | --- | --- |
125
+ | Templates, CODEOWNERS | `.github/` | `.gitlab/` |
126
+ | CI | caller workflows of reusable workflows | every job generated into `.gitlab-ci.yml` |
127
+ | Dependency updates | Dependabot | Renovate (`renovate.json`) |
128
+ | Releases | release-please, through a release pull request | semantic-release, on every push to the default branch |
129
+
130
+ Jobs you add to `.gitlab-ci.yml` are kept; repokeeper manages only its own top-level keys
131
+ (`workflow`, `node`, `commits`, `repokeeper`, `renovate`, `release`). `stack_options.node.os` has
132
+ no effect: GitLab jobs run on Linux.
133
+
134
+ Two things to know when you add jobs of your own:
135
+
136
+ - The managed `workflow` runs pipelines for merge requests, the default branch, schedules and
137
+ tags. repokeeper's own jobs skip tags, so a tag pipeline holds only your jobs (publishing, for
138
+ example). A job meant for other branches never starts. To write the `workflow` rules yourself,
139
+ list the key under `owned` in `.repokeeper.yml`: `owned: [".gitlab-ci.yml#workflow"]`.
140
+ - repokeeper's jobs use GitLab's default stages `test` and `deploy`. If you declare `stages`,
141
+ include both; repokeeper warns when one is missing.
142
+
143
+ Two jobs stay inactive until you set them up in the project's CI/CD settings:
144
+
145
+ - **`release`** needs a CI/CD variable `GITLAB_TOKEN`: a project access token with the `api` and
146
+ `write_repository` scopes and a role that may push to the default branch. Every push to the
147
+ default branch with a `feat`, `fix` or breaking change then releases at once: version bump,
148
+ `CHANGELOG.md`, tag and GitLab release. There is no release merge request. A repository without
149
+ a `vX.Y.Z` tag starts at `1.0.0`; tag the current version first to continue from it. A variable
150
+ marked Protected is only visible on protected branches, so protect the default branch or leave
151
+ the variable unprotected; otherwise the job silently stays away.
152
+ - **`renovate`** needs a CI/CD variable `RENOVATE_TOKEN` (same scopes) and a pipeline schedule,
153
+ for example weekly. The schedule alone decides how often Renovate runs.
154
+
155
+ `repokeeper github apply` has no GitLab counterpart yet.
156
+
114
157
  ## Pilots
115
158
 
116
159
  Each stack is tried on a real or demo repository before a release. The demo repositories carry the
package/dist/cli.js CHANGED
@@ -25,6 +25,7 @@ export const USAGE = [
25
25
  " --adopt <path> let repokeeper manage an existing file (repeatable); --adopt-all for every file",
26
26
  " --accept <path> take repokeeper's version of a locally edited file (update, repeatable)",
27
27
  " --stack <id> stack to use instead of detection (init, repeatable)",
28
+ " --platform <id> github or gitlab, instead of detection from the origin remote (init)",
28
29
  " --relock rebuild .repokeeper/lock.json from the current files (init)",
29
30
  " --json machine-readable output (check)",
30
31
  " -y, --yes apply without asking (github apply)",
@@ -44,6 +45,7 @@ export async function run(argv, io) {
44
45
  "adopt-all": { type: "boolean", default: false },
45
46
  accept: { type: "string", multiple: true, default: [] },
46
47
  stack: { type: "string", multiple: true, default: [] },
48
+ platform: { type: "string" },
47
49
  relock: { type: "boolean", default: false },
48
50
  json: { type: "boolean", default: false },
49
51
  yes: { type: "boolean", short: "y", default: false },
@@ -66,6 +68,12 @@ export async function run(argv, io) {
66
68
  throw new UsageError(`unknown stack ${stack}; expected one of ${STACK_IDS.join(", ")}`);
67
69
  }
68
70
  }
71
+ const platform = values.platform;
72
+ if (platform !== undefined && platform !== "github" && platform !== "gitlab") {
73
+ throw new UsageError(`unknown platform ${platform}; expected github or gitlab`);
74
+ }
75
+ if (platform !== undefined && command !== "init")
76
+ throw new UsageError("--platform is only for init");
69
77
  const options = {
70
78
  dryRun: values["dry-run"],
71
79
  force: values.force,
@@ -76,6 +84,7 @@ export async function run(argv, io) {
76
84
  relock: values.relock,
77
85
  json: values.json,
78
86
  yes: values.yes,
87
+ ...(platform !== undefined ? { platform } : {}),
79
88
  };
80
89
  if (command === "init")
81
90
  return await initCommand(io.cwd, options, io);
@@ -1,7 +1,13 @@
1
+ import { UsageError } from "../errors.js";
1
2
  import { repoInfo } from "../git.js";
2
- import { githubPlatform } from "../platforms/github.js";
3
+ import { platformFor } from "../platforms/index.js";
3
4
  import { getStackPack } from "../stacks/index.js";
4
5
  export async function buildContext(root, config, repo) {
6
+ const platform = platformFor(config.platform);
7
+ const unsupported = config.stacks.find((id) => !platform.stacks.includes(id));
8
+ if (unsupported) {
9
+ throw new UsageError(`stack "${unsupported}" is not supported on ${platform.id} yet (supported: ${platform.stacks.join(", ")})`);
10
+ }
5
11
  const stacks = await Promise.all(config.stacks.map((id) => getStackPack(id).resolve(root, config.stack_options[id] ?? {})));
6
- return { config, stacks, platform: githubPlatform, repo: repo ?? (await repoInfo(root)) };
12
+ return { config, stacks, platform, repo: repo ?? (await repoInfo(root, config.platform)) };
7
13
  }
@@ -6,6 +6,8 @@ import { planGithub } from "../github/settings.js";
6
6
  /** `repokeeper github apply`: diff the repository's GitHub settings against `github:` and apply on confirmation. */
7
7
  export async function githubApplyCommand(root, options, io) {
8
8
  const config = await loadConfig(root);
9
+ if (config.platform !== "github")
10
+ throw new UsageError(`this repository uses the ${config.platform} platform`);
9
11
  const repo = await repoInfo(root);
10
12
  if (!repo.owner)
11
13
  throw new UsageError("the origin remote is not a GitHub repository");
@@ -4,9 +4,10 @@ import { join } from "node:path";
4
4
  import { CONFIG_FILE, loadConfig, renderConfig } from "../config/load.js";
5
5
  import { defaultConfig } from "../config/types.js";
6
6
  import { UsageError } from "../errors.js";
7
- import { dirtyPaths, gitUserName, remoteDefaultBranch, repoInfo } from "../git.js";
7
+ import { dirtyPaths, gitUserName, remoteDefaultBranch, remoteHost, repoInfo } from "../git.js";
8
8
  import { outputId } from "../model.js";
9
9
  import { planOutputs } from "../plan.js";
10
+ import { platformFor } from "../platforms/index.js";
10
11
  import { detectStacks } from "../stacks/index.js";
11
12
  import { applySync } from "../sync/apply.js";
12
13
  import { hashText } from "../sync/hash.js";
@@ -53,18 +54,20 @@ export async function initCommand(root, options, io) {
53
54
  const stacks = options.stacks.length > 0 ? options.stacks : await detectStacks(root);
54
55
  if (stacks.length === 0)
55
56
  throw new UsageError("no supported stack detected; pass --stack node");
56
- const repo = await repoInfo(root);
57
+ const platform = options.platform ?? ((await remoteHost(root))?.includes("gitlab") ? "gitlab" : "github");
58
+ const repo = await repoInfo(root, platform);
57
59
  const holder = (await gitUserName(root)) ?? repo.owner ?? "the project authors";
58
60
  const config = defaultConfig({
59
61
  stacks,
60
62
  standard: STANDARD_VERSION,
61
63
  copyright: `${new Date().getFullYear()} ${holder}`,
62
- contact: repo.owner ? `https://github.com/${repo.owner}` : "the repository maintainers",
64
+ contact: platformFor(platform).profileUrl(repo) ?? "the repository maintainers",
63
65
  codeowners: repo.owner ? [`@${repo.owner}`] : [],
66
+ platform,
64
67
  });
65
68
  const branch = await remoteDefaultBranch(root);
66
69
  if (branch && branch !== "main")
67
- config.github = { default_branch: branch };
70
+ config[platform] = { default_branch: branch };
68
71
  const ctx = await buildContext(root, config, repo);
69
72
  const adopt = options.adoptAll ? "all" : new Set(options.adopt);
70
73
  const result = await computeSync(root, planOutputs(ctx), null, { adopt, accept: new Set() });
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
+ import { parse } from "yaml";
3
4
  import { duplicateTestRuns } from "../duplicates.js";
4
5
  import { crlfTrackedFiles } from "../git.js";
5
6
  import { describeOutput } from "../model.js";
@@ -61,11 +62,30 @@ export async function nextSteps(root, ctx, result) {
61
62
  }
62
63
  /** Problems repokeeper can't fix itself; printed, but not counted as drift. */
63
64
  export async function printWarnings(root, ctx, io) {
64
- if (ctx.config.modules.ci) {
65
+ if (ctx.config.modules.ci && ctx.platform.id === "github") {
65
66
  for (const { file, command } of await duplicateTestRuns(root, ctx.stacks)) {
66
67
  io.out(`note: ${file} also runs "${command}", which the repokeeper ci job now runs too; drop one of them to save CI time`);
67
68
  }
68
69
  }
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)");
74
+ }
75
+ // repokeeper's jobs use GitLab's default stages; a stages list of the user's own replaces those
76
+ let ci = null;
77
+ try {
78
+ ci = parse(await readFile(join(root, ".gitlab-ci.yml"), "utf8"));
79
+ }
80
+ catch { }
81
+ if (Array.isArray(ci?.stages)) {
82
+ const stages = ci.stages;
83
+ const missing = ["test", "deploy"].filter((stage) => !stages.includes(stage));
84
+ if (missing.length > 0) {
85
+ io.out(`warning: .gitlab-ci.yml: stages lacks ${missing.join(" and ")}, which repokeeper's jobs use; add it, or GitLab rejects the pipeline`);
86
+ }
87
+ }
88
+ }
69
89
  if (!ctx.config.modules.editorconfig)
70
90
  return;
71
91
  const text = await readFile(join(root, ".gitattributes"), "utf8").catch(() => "");
@@ -30,7 +30,12 @@ export function parseConfig(text) {
30
30
  const path = pathOf(error);
31
31
  throw new ConfigError(`${CONFIG_FILE}:${lineOf(doc, lineCounter, path)}: ${describe(error, path)}`);
32
32
  }
33
- return withDefaults(data);
33
+ const typed = data;
34
+ const other = typed.platform === "gitlab" ? "github" : "gitlab";
35
+ if (typed[other] !== undefined) {
36
+ throw new ConfigError(`${CONFIG_FILE}:${lineOf(doc, lineCounter, [other])}: ${other} is not used on the ${typed.platform} platform`);
37
+ }
38
+ return withDefaults(typed);
34
39
  }
35
40
  export function renderConfig(config) {
36
41
  return `# repokeeper configuration: https://github.com/vannt-dev/repokeeper\n${stringify(config)}`;
@@ -67,6 +72,8 @@ function withDefaults(data) {
67
72
  };
68
73
  if (data.github !== undefined)
69
74
  config.github = data.github;
75
+ if (data.gitlab !== undefined)
76
+ config.gitlab = data.gitlab;
70
77
  return config;
71
78
  }
72
79
  function pathOf(error) {
@@ -7,7 +7,7 @@ export const configSchema = {
7
7
  properties: {
8
8
  schema: { const: 1 },
9
9
  standard: { type: "string", pattern: "^\\d+\\.\\d+\\.\\d+$" },
10
- platform: { enum: ["github"] },
10
+ platform: { enum: ["github", "gitlab"] },
11
11
  stacks: { type: "array", minItems: 1, uniqueItems: true, items: { enum: [...STACK_IDS] } },
12
12
  modules: {
13
13
  type: "object",
@@ -75,5 +75,10 @@ export const configSchema = {
75
75
  },
76
76
  },
77
77
  },
78
+ gitlab: {
79
+ type: "object",
80
+ additionalProperties: false,
81
+ properties: { default_branch: { type: "string", minLength: 1 } },
82
+ },
78
83
  },
79
84
  };
@@ -11,16 +11,16 @@ export const STACK_IDS = [
11
11
  "php",
12
12
  "ruby",
13
13
  ];
14
- /** The branch workflows run on and contributors branch from: `github.default_branch`, or `main`. */
14
+ /** The branch CI runs on and contributors branch from: `default_branch` of the configured platform, or `main`. */
15
15
  export function defaultBranch(config) {
16
- const branch = config.github?.default_branch;
16
+ const branch = (config.platform === "gitlab" ? config.gitlab : config.github)?.default_branch;
17
17
  return typeof branch === "string" && branch.length > 0 ? branch : "main";
18
18
  }
19
19
  export function defaultConfig(input) {
20
20
  return {
21
21
  schema: 1,
22
22
  standard: input.standard,
23
- platform: "github",
23
+ platform: input.platform ?? "github",
24
24
  stacks: input.stacks,
25
25
  modules: {
26
26
  editorconfig: true,
package/dist/git.js CHANGED
@@ -18,10 +18,39 @@ export function parseRemoteUrl(url) {
18
18
  const match = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(url.trim());
19
19
  return match ? { owner: match[1], name: match[2] } : null;
20
20
  }
21
- export async function repoInfo(root) {
22
- const url = await git(root, ["remote", "get-url", "origin"]);
23
- const remote = (url ? parseRemoteUrl(url) : null) ?? { owner: null, name: basename(root) };
24
- return { ...remote, releasedVersion: await latestReleaseVersion(root) };
21
+ /** Any hosted remote, HTTPS or SSH. The owner is the whole namespace path, because GitLab groups nest. */
22
+ export function parseRemote(url) {
23
+ const text = url.trim();
24
+ // the web address shares the port of an http(s) remote; an SSH port says nothing about it
25
+ const match = /^https?:\/\/(?:[^@/]+@)?([^/:]+(?::\d+)?)\/(.+)$/i.exec(text) ??
26
+ /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/i.exec(text) ??
27
+ /^(?:[^@/]+@)?([^/:]+):(.+)$/.exec(text);
28
+ if (!match)
29
+ return null;
30
+ const host = match[1].toLowerCase();
31
+ const segments = match[2]
32
+ .replace(/\/+$/, "")
33
+ .replace(/\.git$/, "")
34
+ .split("/")
35
+ .filter(Boolean);
36
+ const name = segments.pop();
37
+ // a host has a dot, which rules out drive letters and file: URLs
38
+ if (!host.includes(".") || name === undefined || segments.length === 0)
39
+ return null;
40
+ return { host, owner: segments.join("/"), name };
41
+ }
42
+ async function originUrl(root) {
43
+ return git(root, ["remote", "get-url", "origin"]);
44
+ }
45
+ /** Host of the origin remote, or null without one. */
46
+ export async function remoteHost(root) {
47
+ const url = await originUrl(root);
48
+ return url ? (parseRemote(url)?.host ?? null) : null;
49
+ }
50
+ export async function repoInfo(root, platform = "github") {
51
+ const url = await originUrl(root);
52
+ const remote = url ? (platform === "gitlab" ? parseRemote(url) : parseRemoteUrl(url)) : null;
53
+ return { ...(remote ?? { owner: null, name: basename(root) }), releasedVersion: await latestReleaseVersion(root) };
25
54
  }
26
55
  /** The branch `origin/HEAD` points at, or null when the remote's default branch isn't known locally. */
27
56
  export async function remoteDefaultBranch(root) {
@@ -33,7 +33,6 @@ export const healthModule = {
33
33
  const health = ctx.config.modules.health;
34
34
  if (!health)
35
35
  return [];
36
- const { owner, name } = ctx.repo;
37
36
  const outputs = [];
38
37
  if (health.license !== false) {
39
38
  if (health.license !== "MIT") {
@@ -41,9 +40,7 @@ export const healthModule = {
41
40
  }
42
41
  outputs.push(md("LICENSE", MIT(health.copyright)));
43
42
  }
44
- const report = owner
45
- ? `through [GitHub security advisories](https://github.com/${owner}/${name}/security/advisories/new)`
46
- : "through the repository's Security tab (Report a vulnerability)";
43
+ const report = ctx.platform.securityReport(ctx.repo);
47
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`));
48
45
  const install = ctx.stacks.map((s) => s.install).filter((c) => c !== null);
49
46
  const run = install.length ? `Run ${install.map((c) => `\`${c}\``).join(" and ")}.` : "";
@@ -51,7 +48,7 @@ export const healthModule = {
51
48
  ? "Installing the dependencies also installs the git hooks (lefthook)."
52
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).`;
53
50
  const setup = [run, hooks].filter(Boolean).join(" ");
54
- outputs.push(md("CONTRIBUTING.md", `# Contributing\n\n<!-- ${MANAGED_HEADER} -->\n\nThanks for helping improve this project.\n\n## Local setup\n\n${setup}\n\n## Workflow\n\n1. Create a branch from \`${defaultBranch(ctx.config)}\`.\n2. Write commit messages in [Conventional Commits](https://www.conventionalcommits.org/) form, for example \`feat: add export button\` or \`fix(api): handle empty input\`. The \`commit-msg\` hook checks them.\n3. Open a pull request. It is merged once the checks pass and it has been reviewed.\n\nPlease follow the [Code of Conduct](CODE_OF_CONDUCT.md).\n`));
51
+ outputs.push(md("CONTRIBUTING.md", `# Contributing\n\n<!-- ${MANAGED_HEADER} -->\n\nThanks for helping improve this project.\n\n## Local setup\n\n${setup}\n\n## Workflow\n\n1. Create a branch from \`${defaultBranch(ctx.config)}\`.\n2. Write commit messages in [Conventional Commits](https://www.conventionalcommits.org/) form, for example \`feat: add export button\` or \`fix(api): handle empty input\`. The \`commit-msg\` hook checks them.\n3. Open a ${ctx.platform.changeRequest}. It is merged once the checks pass and it has been reviewed.\n\nPlease follow the [Code of Conduct](CODE_OF_CONDUCT.md).\n`));
55
52
  outputs.push(md("CODE_OF_CONDUCT.md", readTemplate("CODE_OF_CONDUCT.md").replace("[INSERT CONTACT METHOD]", health.contact)));
56
53
  outputs.push(...ctx.platform.communityFiles(ctx));
57
54
  return outputs;
@@ -1,5 +1,5 @@
1
1
  import { stringify } from "yaml";
2
- import { defaultBranch } from "../config/types.js";
2
+ import { defaultBranch, STACK_IDS } from "../config/types.js";
3
3
  import { MANAGED_HEADER } from "../model.js";
4
4
  import { PACKAGE_VERSION, REUSABLE_REPO, WORKFLOW_REF } from "../version.js";
5
5
  const yamlFile = (module, path, data) => ({
@@ -49,6 +49,12 @@ const PHP_CHANGELOG_SECTIONS = [
49
49
  const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
50
50
  export const githubPlatform = {
51
51
  id: "github",
52
+ stacks: STACK_IDS,
53
+ changeRequest: "pull request",
54
+ profileUrl: (repo) => (repo.owner ? `https://github.com/${repo.owner}` : null),
55
+ securityReport: (repo) => repo.owner
56
+ ? `through [GitHub security advisories](https://github.com/${repo.owner}/${repo.name}/security/advisories/new)`
57
+ : "through the repository's Security tab (Report a vulnerability)",
52
58
  communityFiles(ctx) {
53
59
  const { owner, name } = ctx.repo;
54
60
  const outputs = [
@@ -0,0 +1,212 @@
1
+ import { defaultBranch } from "../config/types.js";
2
+ import { MANAGED_HEADER } from "../model.js";
3
+ import { PACKAGE_VERSION, TOOL_VERSIONS } from "../version.js";
4
+ const md = (path, body) => ({
5
+ kind: "file",
6
+ module: "health",
7
+ path,
8
+ content: `<!-- ${MANAGED_HEADER} -->\n\n${body}`,
9
+ });
10
+ /** Web address of the GitLab instance the repository lives on. */
11
+ const baseUrl = (repo) => `https://${repo.host ?? "gitlab.com"}`;
12
+ const CI_FILE = ".gitlab-ci.yml";
13
+ const CI_KEYS = ["workflow", "node", "commits", "repokeeper", "renovate", "release"];
14
+ /** One managed top-level key of .gitlab-ci.yml; keys the user adds are left alone. */
15
+ const ciKey = (module, key, value) => ({
16
+ kind: "yaml",
17
+ module,
18
+ path: CI_FILE,
19
+ keyPath: [key],
20
+ value,
21
+ order: CI_KEYS,
22
+ });
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
+ /** Image of the jobs that only run tools, whatever Node.js versions the project tests on. */
27
+ const TOOL_IMAGE = "node:24";
28
+ /** The node stack's CI inputs, as the GitHub reusable workflow receives them, as a GitLab job. */
29
+ function nodeJob(input) {
30
+ const pm = input["package-manager"] ?? "npm";
31
+ const cached = input.cache === "npm";
32
+ const scripts = JSON.parse(input.scripts ?? "[]");
33
+ return {
34
+ stage: "test",
35
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitLab CI variable
36
+ image: "node:${NODE_VERSION}",
37
+ parallel: { matrix: [{ NODE_VERSION: JSON.parse(input["node-versions"] ?? '["22","24"]') }] },
38
+ rules: [{ if: FOR_CHANGES }],
39
+ variables: {
40
+ COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
41
+ ...(cached ? { npm_config_cache: "$CI_PROJECT_DIR/.npm" } : {}),
42
+ },
43
+ ...(cached ? { cache: { key: { files: ["package-lock.json"] }, paths: [".npm/"] } } : {}),
44
+ script: [
45
+ ...(pm === "npm" ? [] : ["corepack enable"]),
46
+ input["install-command"] ?? "npm ci",
47
+ ...scripts.map((script) => `${pm} run ${script}`),
48
+ ],
49
+ };
50
+ }
51
+ /** commitlint from a scratch directory, so the project needs no commitlint of its own. */
52
+ const COMMITLINT = `dir="$(mktemp -d)"
53
+ (cd "$dir" && echo '{ "private": true }' > package.json && npm install --no-audit --no-fund @commitlint/cli@${TOOL_VERSIONS.commitlintCli} @commitlint/config-conventional@${TOOL_VERSIONS.commitlintConventional})
54
+ cat > "$dir/commitlint.config.mjs" <<'EOF'
55
+ export default { extends: ["@commitlint/config-conventional"], rules: { "header-max-length": [2, "always", 100] } };
56
+ EOF
57
+ lint() { "$dir/node_modules/.bin/commitlint" --config "$dir/commitlint.config.mjs" --verbose "$@"; }
58
+ # a merged results pipeline runs on a merge commit; the commits to lint end at the source branch
59
+ head="$CI_MERGE_REQUEST_SOURCE_BRANCH_SHA"
60
+ [ -n "$head" ] || head="$CI_COMMIT_SHA"
61
+ if [ -n "$CI_MERGE_REQUEST_DIFF_BASE_SHA" ]; then
62
+ lint --from "$CI_MERGE_REQUEST_DIFF_BASE_SHA" --to "$head"
63
+ else
64
+ lint --last
65
+ fi
66
+ `;
67
+ const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
68
+ /**
69
+ * semantic-release and the plugins it does not bundle, from a scratch directory. It resolves plugins next to
70
+ * itself before it looks in the project, so the project needs none of them installed.
71
+ */
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})
74
+ "$dir/node_modules/.bin/semantic-release"
75
+ `;
76
+ export const gitlabPlatform = {
77
+ id: "gitlab",
78
+ stacks: ["node"],
79
+ changeRequest: "merge request",
80
+ profileUrl: (repo) => (repo.owner ? `${baseUrl(repo)}/${repo.owner}` : null),
81
+ securityReport: (repo) => repo.owner
82
+ ? `by opening a [confidential issue](${baseUrl(repo)}/${repo.owner}/${repo.name}/-/issues/new?issue%5Bconfidential%5D=true)`
83
+ : "by opening a confidential issue in the repository",
84
+ communityFiles(ctx) {
85
+ const outputs = [
86
+ md(".gitlab/issue_templates/Bug.md", "## What happened?\n\n<!-- Include the steps to reproduce it. -->\n\n## What did you expect?\n\n## Version\n\n/label ~bug\n"),
87
+ md(".gitlab/issue_templates/Feature.md", "## What problem would this solve?\n\n## What do you propose?\n\n/label ~enhancement\n"),
88
+ md(".gitlab/merge_request_templates/Default.md", "## Summary\n\n## Testing\n\n- [ ] Tests added or updated\n- [ ] Commit messages follow Conventional Commits\n"),
89
+ ];
90
+ const health = ctx.config.modules.health;
91
+ if (health && health.codeowners.length > 0) {
92
+ outputs.push({
93
+ kind: "file",
94
+ module: "health",
95
+ path: ".gitlab/CODEOWNERS",
96
+ content: `# ${MANAGED_HEADER}\n* ${health.codeowners.join(" ")}\n`,
97
+ });
98
+ }
99
+ return outputs;
100
+ },
101
+ // Renovate finds the package managers itself, so the Dependabot ecosystem names are not needed
102
+ dependencyUpdates() {
103
+ return [
104
+ {
105
+ kind: "file",
106
+ module: "deps",
107
+ path: "renovate.json",
108
+ content: json({
109
+ $schema: "https://docs.renovatebot.com/renovate-schema.json",
110
+ // chore commits pass commitlint, as the Dependabot prefix does on GitHub. No schedule preset: the
111
+ // pipeline schedule sets the cadence, and a Renovate schedule would skip runs outside its own window
112
+ extends: ["config:recommended", ":semanticCommits", ":semanticCommitTypeAll(chore)"],
113
+ packageRules: [
114
+ { matchUpdateTypes: ["minor", "patch"], groupName: "minor and patch updates" },
115
+ // @types/node majors track the Node.js line a project runs on, which the project chooses
116
+ { matchPackageNames: ["@types/node"], matchUpdateTypes: ["major"], enabled: false },
117
+ ],
118
+ }),
119
+ },
120
+ // runs from a pipeline schedule of the repository; without the token or a schedule it never starts
121
+ ciKey("deps", "renovate", {
122
+ stage: "test",
123
+ image: `renovate/renovate:${TOOL_VERSIONS.renovate}`,
124
+ rules: [{ if: '$CI_PIPELINE_SOURCE == "schedule" && $RENOVATE_TOKEN' }],
125
+ variables: {
126
+ RENOVATE_PLATFORM: "gitlab",
127
+ RENOVATE_ENDPOINT: "$CI_API_V4_URL",
128
+ RENOVATE_AUTODISCOVER: "false",
129
+ RENOVATE_ONBOARDING: "false",
130
+ },
131
+ script: ['renovate "$CI_PROJECT_PATH"'],
132
+ }),
133
+ ];
134
+ },
135
+ ciWorkflow(ctx) {
136
+ const jobs = [];
137
+ const node = ctx.stacks.find((stack) => stack.id === "node")?.ci;
138
+ if (node)
139
+ jobs.push(ciKey("ci", "node", nodeJob(node.with)));
140
+ if (ctx.config.modules.commits) {
141
+ jobs.push(ciKey("ci", "commits", {
142
+ stage: "test",
143
+ image: TOOL_IMAGE,
144
+ rules: [{ if: FOR_CHANGES }],
145
+ variables: { GIT_DEPTH: "0" },
146
+ script: [COMMITLINT],
147
+ }));
148
+ }
149
+ if (ctx.config.modules.drift) {
150
+ jobs.push(ciKey("ci", "repokeeper", {
151
+ stage: "test",
152
+ image: TOOL_IMAGE,
153
+ rules: [{ if: FOR_CHANGES }],
154
+ script: [`npx --yes repokeeper@${PACKAGE_VERSION} check`],
155
+ }));
156
+ }
157
+ if (jobs.length === 0)
158
+ return [];
159
+ return [
160
+ // one pipeline per merge request, per push to the default branch and per schedule; none for other branches.
161
+ // Tags are let through for jobs of the user's own, such as publishing: no managed job runs on one
162
+ ciKey("ci", "workflow", {
163
+ rules: [
164
+ { if: '$CI_PIPELINE_SOURCE == "merge_request_event"' },
165
+ { if: '$CI_PIPELINE_SOURCE == "schedule"' },
166
+ { if: `$CI_COMMIT_BRANCH == "${defaultBranch(ctx.config)}"` },
167
+ { if: "$CI_COMMIT_TAG" },
168
+ ],
169
+ }),
170
+ ...jobs,
171
+ ];
172
+ },
173
+ releaseAutomation(ctx) {
174
+ const branch = defaultBranch(ctx.config);
175
+ return [
176
+ {
177
+ kind: "file",
178
+ module: "release",
179
+ path: ".releaserc.json",
180
+ content: json({
181
+ branches: [branch],
182
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: a semantic-release template
183
+ tagFormat: "v${version}",
184
+ plugins: [
185
+ ["@semantic-release/commit-analyzer", { preset: "conventionalcommits" }],
186
+ ["@semantic-release/release-notes-generator", { preset: "conventionalcommits" }],
187
+ ["@semantic-release/changelog", { changelogFile: "CHANGELOG.md" }],
188
+ // bumps package.json and the npm lock file; publishing stays the project's own business
189
+ ["@semantic-release/npm", { npmPublish: false }],
190
+ [
191
+ "@semantic-release/git",
192
+ {
193
+ assets: ["CHANGELOG.md", "package.json", "package-lock.json", "npm-shrinkwrap.json"],
194
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: a semantic-release template
195
+ message: "chore(release): ${nextRelease.version} [skip ci]",
196
+ },
197
+ ],
198
+ "@semantic-release/gitlab",
199
+ ],
200
+ }),
201
+ },
202
+ // semantic-release pushes a commit and a tag, which the job token cannot do; without GITLAB_TOKEN the job is absent
203
+ ciKey("release", "release", {
204
+ stage: "deploy",
205
+ image: TOOL_IMAGE,
206
+ rules: [{ if: `${NOT_SCHEDULED} && $CI_COMMIT_BRANCH == "${branch}" && $GITLAB_TOKEN` }],
207
+ variables: { GIT_DEPTH: "0" },
208
+ script: [SEMANTIC_RELEASE],
209
+ }),
210
+ ];
211
+ },
212
+ };
@@ -0,0 +1,5 @@
1
+ import { githubPlatform } from "./github.js";
2
+ import { gitlabPlatform } from "./gitlab.js";
3
+ export function platformFor(id) {
4
+ return id === "gitlab" ? gitlabPlatform : githubPlatform;
5
+ }
package/dist/version.js CHANGED
@@ -6,6 +6,13 @@ export const TOOL_VERSIONS = {
6
6
  lefthook: "2.1.14",
7
7
  commitlintCli: "21.2.3",
8
8
  commitlintConventional: "21.2.3",
9
+ semanticRelease: "25.0.9",
10
+ semanticReleaseChangelog: "7.0.0",
11
+ semanticReleaseGit: "11.0.1",
12
+ semanticReleaseGitlab: "13.3.3",
13
+ // 10.x needs conventional-changelog-writer 9, and semantic-release 25's notes generator bundles 8
14
+ conventionalCommitsPreset: "9.3.1",
15
+ renovate: "44.128.1",
9
16
  };
10
17
  /** Repository hosting the reusable workflows that generated CI files call. */
11
18
  export const REUSABLE_REPO = "vannt-dev/repokeeper";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repokeeper",
3
- "version": "0.4.7",
3
+ "version": "0.5.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": {