repokeeper 0.4.7 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) 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 -63
  15. package/dist/cli.js +56 -8
  16. package/dist/commands/context.js +26 -3
  17. package/dist/commands/eject.js +74 -0
  18. package/dist/commands/github.js +2 -0
  19. package/dist/commands/gitlab.js +46 -0
  20. package/dist/commands/init.js +110 -13
  21. package/dist/commands/presets.js +114 -0
  22. package/dist/commands/report.js +23 -3
  23. package/dist/commands/update.js +3 -2
  24. package/dist/config/load.js +8 -1
  25. package/dist/config/schema.js +45 -1
  26. package/dist/config/types.js +3 -3
  27. package/dist/duplicates.js +2 -2
  28. package/dist/errors.js +1 -1
  29. package/dist/git.js +33 -4
  30. package/dist/gitlab/api.js +50 -0
  31. package/dist/gitlab/settings.js +181 -0
  32. package/dist/model.js +8 -0
  33. package/dist/modules/commits.js +3 -2
  34. package/dist/modules/deps.js +1 -1
  35. package/dist/modules/health.js +5 -8
  36. package/dist/modules/hooks.js +7 -5
  37. package/dist/modules/release.js +18 -5
  38. package/dist/platforms/github.js +77 -13
  39. package/dist/platforms/gitlab.js +233 -0
  40. package/dist/platforms/index.js +5 -0
  41. package/dist/stacks/index.js +56 -0
  42. package/dist/stacks/support.js +19 -0
  43. package/dist/templates.js +13 -1
  44. package/dist/version.js +9 -0
  45. package/package.json +6 -2
@@ -0,0 +1,233 @@
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", "go", "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
+ /**
52
+ * The go stack's CI inputs as a GitLab job. No module cache: it would have to live inside the project
53
+ * folder, where `gofmt -l .` would read it as the project's own code.
54
+ */
55
+ function goJob(input) {
56
+ // setup-go's "stable" is the image's `latest` tag
57
+ const versions = JSON.parse(input["go-versions"] ?? '["stable"]').map((version) => version === "stable" ? "latest" : version);
58
+ return {
59
+ stage: "test",
60
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: a GitLab CI variable
61
+ image: "golang:${GO_VERSION}",
62
+ parallel: { matrix: [{ GO_VERSION: versions }] },
63
+ rules: [{ if: FOR_CHANGES }],
64
+ script: JSON.parse(input.commands ?? "[]"),
65
+ };
66
+ }
67
+ /** commitlint from a scratch directory, so the project needs no commitlint of its own. */
68
+ const COMMITLINT = `dir="$(mktemp -d)"
69
+ (cd "$dir" && echo '{ "private": true }' > package.json && npm install --no-audit --no-fund @commitlint/cli@${TOOL_VERSIONS.commitlintCli} @commitlint/config-conventional@${TOOL_VERSIONS.commitlintConventional})
70
+ cat > "$dir/commitlint.config.mjs" <<'EOF'
71
+ export default { extends: ["@commitlint/config-conventional"], rules: { "header-max-length": [2, "always", 100] } };
72
+ EOF
73
+ lint() { "$dir/node_modules/.bin/commitlint" --config "$dir/commitlint.config.mjs" --verbose "$@"; }
74
+ # a merged results pipeline runs on a merge commit; the commits to lint end at the source branch
75
+ head="$CI_MERGE_REQUEST_SOURCE_BRANCH_SHA"
76
+ [ -n "$head" ] || head="$CI_COMMIT_SHA"
77
+ if [ -n "$CI_MERGE_REQUEST_DIFF_BASE_SHA" ]; then
78
+ lint --from "$CI_MERGE_REQUEST_DIFF_BASE_SHA" --to "$head"
79
+ else
80
+ lint --last
81
+ fi
82
+ `;
83
+ const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
84
+ /**
85
+ * semantic-release and the plugins it does not bundle, from a scratch directory. It resolves plugins next to
86
+ * itself before it looks in the project, so the project needs none of them installed.
87
+ */
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})
90
+ "$dir/node_modules/.bin/semantic-release"
91
+ `;
92
+ export const gitlabPlatform = {
93
+ id: "gitlab",
94
+ stacks: ["node", "go"],
95
+ changeRequest: "merge request",
96
+ profileUrl: (repo) => (repo.owner ? `${baseUrl(repo)}/${repo.owner}` : null),
97
+ securityReport: (repo) => repo.owner
98
+ ? `by opening a [confidential issue](${baseUrl(repo)}/${repo.owner}/${repo.name}/-/issues/new?issue%5Bconfidential%5D=true)`
99
+ : "by opening a confidential issue in the repository",
100
+ communityFiles(ctx) {
101
+ const outputs = [
102
+ 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"),
103
+ md(".gitlab/issue_templates/Feature.md", "## What problem would this solve?\n\n## What do you propose?\n\n/label ~enhancement\n"),
104
+ md(".gitlab/merge_request_templates/Default.md", "## Summary\n\n## Testing\n\n- [ ] Tests added or updated\n- [ ] Commit messages follow Conventional Commits\n"),
105
+ ];
106
+ const health = ctx.config.modules.health;
107
+ if (health && health.codeowners.length > 0) {
108
+ outputs.push({
109
+ kind: "file",
110
+ module: "health",
111
+ path: ".gitlab/CODEOWNERS",
112
+ content: `# ${MANAGED_HEADER}\n* ${health.codeowners.join(" ")}\n`,
113
+ });
114
+ }
115
+ return outputs;
116
+ },
117
+ // Renovate finds the package managers itself, so the Dependabot ecosystem names are not needed
118
+ dependencyUpdates() {
119
+ return [
120
+ {
121
+ kind: "file",
122
+ module: "deps",
123
+ path: "renovate.json",
124
+ content: json({
125
+ $schema: "https://docs.renovatebot.com/renovate-schema.json",
126
+ // chore commits pass commitlint, as the Dependabot prefix does on GitHub. No schedule preset: the
127
+ // pipeline schedule sets the cadence, and a Renovate schedule would skip runs outside its own window
128
+ extends: ["config:recommended", ":semanticCommits", ":semanticCommitTypeAll(chore)"],
129
+ packageRules: [
130
+ { matchUpdateTypes: ["minor", "patch"], groupName: "minor and patch updates" },
131
+ // @types/node majors track the Node.js line a project runs on, which the project chooses
132
+ { matchPackageNames: ["@types/node"], matchUpdateTypes: ["major"], enabled: false },
133
+ ],
134
+ }),
135
+ },
136
+ // runs from a pipeline schedule of the repository; without the token or a schedule it never starts
137
+ ciKey("deps", "renovate", {
138
+ stage: "test",
139
+ image: `renovate/renovate:${TOOL_VERSIONS.renovate}`,
140
+ rules: [{ if: '$CI_PIPELINE_SOURCE == "schedule" && $RENOVATE_TOKEN' }],
141
+ variables: {
142
+ RENOVATE_PLATFORM: "gitlab",
143
+ RENOVATE_ENDPOINT: "$CI_API_V4_URL",
144
+ RENOVATE_AUTODISCOVER: "false",
145
+ RENOVATE_ONBOARDING: "false",
146
+ },
147
+ script: ['renovate "$CI_PROJECT_PATH"'],
148
+ }),
149
+ ];
150
+ },
151
+ ciWorkflow(ctx) {
152
+ const jobs = [];
153
+ const node = ctx.stacks.find((stack) => stack.id === "node")?.ci;
154
+ if (node)
155
+ jobs.push(ciKey("ci", "node", nodeJob(node.with)));
156
+ const go = ctx.stacks.find((stack) => stack.id === "go")?.ci;
157
+ if (go)
158
+ jobs.push(ciKey("ci", "go", goJob(go.with)));
159
+ if (ctx.config.modules.commits) {
160
+ jobs.push(ciKey("ci", "commits", {
161
+ stage: "test",
162
+ image: TOOL_IMAGE,
163
+ rules: [{ if: FOR_CHANGES }],
164
+ variables: { GIT_DEPTH: "0" },
165
+ script: [COMMITLINT],
166
+ }));
167
+ }
168
+ if (ctx.config.modules.drift) {
169
+ jobs.push(ciKey("ci", "repokeeper", {
170
+ stage: "test",
171
+ image: TOOL_IMAGE,
172
+ rules: [{ if: FOR_CHANGES }],
173
+ script: [`npx --yes repokeeper@${PACKAGE_VERSION} check`],
174
+ }));
175
+ }
176
+ if (jobs.length === 0)
177
+ return [];
178
+ return [
179
+ // one pipeline per merge request, per push to the default branch and per schedule; none for other branches.
180
+ // Tags are let through for jobs of the user's own, such as publishing: no managed job runs on one
181
+ ciKey("ci", "workflow", {
182
+ rules: [
183
+ { if: '$CI_PIPELINE_SOURCE == "merge_request_event"' },
184
+ { if: '$CI_PIPELINE_SOURCE == "schedule"' },
185
+ { if: `$CI_COMMIT_BRANCH == "${defaultBranch(ctx.config)}"` },
186
+ { if: "$CI_COMMIT_TAG" },
187
+ ],
188
+ }),
189
+ ...jobs,
190
+ ];
191
+ },
192
+ releaseAutomation(ctx) {
193
+ const branch = defaultBranch(ctx.config);
194
+ // a Go module's version is its tag: there is no file to bump, so the release is the changelog and the tag
195
+ const npm = ctx.stacks.some((stack) => stack.release.type === "node");
196
+ return [
197
+ {
198
+ kind: "file",
199
+ module: "release",
200
+ path: ".releaserc.json",
201
+ content: json({
202
+ branches: [branch],
203
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: a semantic-release template
204
+ tagFormat: "v${version}",
205
+ plugins: [
206
+ ["@semantic-release/commit-analyzer", { preset: "conventionalcommits" }],
207
+ ["@semantic-release/release-notes-generator", { preset: "conventionalcommits" }],
208
+ ["@semantic-release/changelog", { changelogFile: "CHANGELOG.md" }],
209
+ // bumps package.json and the npm lock file; publishing stays the project's own business
210
+ ...(npm ? [["@semantic-release/npm", { npmPublish: false }]] : []),
211
+ [
212
+ "@semantic-release/git",
213
+ {
214
+ assets: ["CHANGELOG.md", ...(npm ? ["package.json", "package-lock.json", "npm-shrinkwrap.json"] : [])],
215
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: a semantic-release template
216
+ message: "chore(release): ${nextRelease.version} [skip ci]",
217
+ },
218
+ ],
219
+ "@semantic-release/gitlab",
220
+ ],
221
+ }),
222
+ },
223
+ // semantic-release pushes a commit and a tag, which the job token cannot do; without GITLAB_TOKEN the job is absent
224
+ ciKey("release", "release", {
225
+ stage: "deploy",
226
+ image: TOOL_IMAGE,
227
+ rules: [{ if: `${NOT_SCHEDULED} && $CI_COMMIT_BRANCH == "${branch}" && $GITLAB_TOKEN` }],
228
+ variables: { GIT_DEPTH: "0" },
229
+ script: [SEMANTIC_RELEASE],
230
+ }),
231
+ ];
232
+ },
233
+ };
@@ -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
+ }
@@ -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
@@ -6,11 +6,20 @@ 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";
12
19
  /** Moving tag callers pin to: the major version of this repokeeper (v0 during 0.x). */
13
20
  export const WORKFLOW_REF = `v${PACKAGE_VERSION.split(".")[0]}`;
21
+ /** The tag of this release, for callers that pin to it instead of following the major tag. */
22
+ export const EXACT_WORKFLOW_REF = `v${PACKAGE_VERSION}`;
14
23
  export function compareVersions(a, b) {
15
24
  const pa = a.split(".").map(Number);
16
25
  const pb = b.split(".").map(Number);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repokeeper",
3
- "version": "0.4.7",
3
+ "version": "0.6.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"