repokeeper 0.5.0 → 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.
- package/.github/workflows/commitlint.yml +49 -0
- package/.github/workflows/release-please.yml +59 -0
- package/.github/workflows/repokeeper-check.yml +30 -0
- package/.github/workflows/stack-dart.yml +72 -0
- package/.github/workflows/stack-dotnet.yml +68 -0
- package/.github/workflows/stack-go.yml +59 -0
- package/.github/workflows/stack-java.yml +70 -0
- package/.github/workflows/stack-node.yml +85 -0
- package/.github/workflows/stack-php.yml +66 -0
- package/.github/workflows/stack-python.yml +70 -0
- package/.github/workflows/stack-ruby.yml +65 -0
- package/.github/workflows/stack-rust.yml +60 -0
- package/.github/workflows/stack-script.yml +87 -0
- package/README.md +59 -105
- package/dist/cli.js +48 -9
- package/dist/commands/context.js +19 -2
- package/dist/commands/eject.js +74 -0
- package/dist/commands/gitlab.js +46 -0
- package/dist/commands/init.js +104 -10
- package/dist/commands/presets.js +114 -0
- package/dist/commands/report.js +2 -2
- package/dist/commands/update.js +3 -2
- package/dist/config/schema.js +40 -1
- package/dist/duplicates.js +2 -2
- package/dist/errors.js +1 -1
- package/dist/gitlab/api.js +50 -0
- package/dist/gitlab/settings.js +181 -0
- package/dist/model.js +8 -0
- package/dist/modules/commits.js +3 -2
- package/dist/modules/deps.js +1 -1
- package/dist/modules/health.js +3 -3
- package/dist/modules/hooks.js +7 -5
- package/dist/modules/release.js +18 -5
- package/dist/platforms/github.js +70 -12
- package/dist/platforms/gitlab.js +25 -4
- package/dist/stacks/index.js +56 -0
- package/dist/stacks/support.js +19 -0
- package/dist/templates.js +13 -1
- package/dist/version.js +2 -0
- package/package.json +6 -2
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { loadConfig } from "../config/load.js";
|
|
2
|
+
import { UsageError } from "../errors.js";
|
|
3
|
+
import { repoInfo } from "../git.js";
|
|
4
|
+
import { gitlabRestApi, resolveGitlabToken } from "../gitlab/api.js";
|
|
5
|
+
import { planGitlab } from "../gitlab/settings.js";
|
|
6
|
+
/** `repokeeper gitlab apply`: diff the project's GitLab settings against `gitlab:` and apply on confirmation. */
|
|
7
|
+
export async function gitlabApplyCommand(root, options, io) {
|
|
8
|
+
const config = await loadConfig(root);
|
|
9
|
+
if (config.platform !== "gitlab")
|
|
10
|
+
throw new UsageError(`this repository uses the ${config.platform} platform`);
|
|
11
|
+
const repo = await repoInfo(root, "gitlab");
|
|
12
|
+
if (!repo.owner)
|
|
13
|
+
throw new UsageError("the origin remote is not a GitLab project");
|
|
14
|
+
const path = `${repo.owner}/${repo.name}`;
|
|
15
|
+
const host = repo.host ?? "gitlab.com";
|
|
16
|
+
const api = io.gitlabApi ?? gitlabRestApi(await resolveGitlabToken(host), host);
|
|
17
|
+
// the jobs that wait for a secret: reported when it is missing, never created
|
|
18
|
+
const secrets = [
|
|
19
|
+
...(config.modules.release ? [{ key: "GITLAB_TOKEN", job: "release" }] : []),
|
|
20
|
+
...(config.modules.deps ? [{ key: "RENOVATE_TOKEN", job: "renovate" }] : []),
|
|
21
|
+
];
|
|
22
|
+
const { changes, notes } = await planGitlab(api, path, config.gitlab ?? {}, secrets);
|
|
23
|
+
if (changes.length === 0)
|
|
24
|
+
io.out(`GitLab settings of ${path} match .repokeeper.yml`);
|
|
25
|
+
for (const change of changes)
|
|
26
|
+
io.out(`change ${change.setting}: ${change.from} -> ${change.to}`);
|
|
27
|
+
for (const note of notes)
|
|
28
|
+
io.out(`note: ${note}`);
|
|
29
|
+
if (changes.length === 0)
|
|
30
|
+
return 0;
|
|
31
|
+
if (options.dryRun) {
|
|
32
|
+
io.out("dry run: nothing applied");
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
const question = `apply ${changes.length} change(s) to ${path}?`;
|
|
36
|
+
const confirmed = options.yes || (io.confirm ? await io.confirm(question) : false);
|
|
37
|
+
if (!confirmed) {
|
|
38
|
+
io.out("nothing applied; pass --yes to apply");
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
for (const change of changes) {
|
|
42
|
+
await change.apply(api);
|
|
43
|
+
io.out(`applied ${change.setting}`);
|
|
44
|
+
}
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
package/dist/commands/init.js
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { CONFIG_FILE, loadConfig, renderConfig } from "../config/load.js";
|
|
4
|
+
import { CONFIG_FILE, loadConfig, renderConfig, setStandard } from "../config/load.js";
|
|
5
5
|
import { defaultConfig } from "../config/types.js";
|
|
6
6
|
import { UsageError } from "../errors.js";
|
|
7
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
10
|
import { platformFor } from "../platforms/index.js";
|
|
11
|
-
import {
|
|
11
|
+
import { detectStackLayout } from "../stacks/index.js";
|
|
12
12
|
import { applySync } from "../sync/apply.js";
|
|
13
13
|
import { hashText } from "../sync/hash.js";
|
|
14
|
-
import { targetOf, writeLock } from "../sync/lock.js";
|
|
14
|
+
import { readLock, targetOf, writeLock } from "../sync/lock.js";
|
|
15
15
|
import { readCurrent } from "../sync/state.js";
|
|
16
16
|
import { computeSync, pathsToWrite } from "../sync/sync.js";
|
|
17
17
|
import { STANDARD_VERSION } from "../version.js";
|
|
18
|
+
import { assertSupportedStandard } from "./check.js";
|
|
18
19
|
import { buildContext } from "./context.js";
|
|
20
|
+
import { applyPreset, chooseModules } from "./presets.js";
|
|
19
21
|
import { nextSteps, printResult, printWarnings } from "./report.js";
|
|
20
22
|
/** A dirty path is still safe to write when every part repokeeper manages in it is exactly what it last wrote. */
|
|
21
23
|
async function onlyRepokeeperChanges(root, path, lock) {
|
|
@@ -49,15 +51,33 @@ export async function initCommand(root, options, io) {
|
|
|
49
51
|
if (options.relock)
|
|
50
52
|
return relock(root, options, io);
|
|
51
53
|
if (existsSync(join(root, CONFIG_FILE))) {
|
|
52
|
-
|
|
54
|
+
// a lock means the standard was applied here before; without one the file was written by hand
|
|
55
|
+
if ((await readLock(root)) !== null) {
|
|
56
|
+
throw new UsageError(`${CONFIG_FILE} already exists; run \`repokeeper update\` or \`repokeeper check\``);
|
|
57
|
+
}
|
|
58
|
+
return initFromConfig(root, options, io);
|
|
53
59
|
}
|
|
54
|
-
const
|
|
60
|
+
const layout = options.stacks.length > 0
|
|
61
|
+
? { stacks: options.stacks, directories: options.stackDirectories ?? {}, skipped: [] }
|
|
62
|
+
: await detectStackLayout(root);
|
|
63
|
+
const { stacks } = layout;
|
|
55
64
|
if (stacks.length === 0)
|
|
56
65
|
throw new UsageError("no supported stack detected; pass --stack node");
|
|
66
|
+
for (const id of stacks) {
|
|
67
|
+
if (layout.directories[id] !== undefined)
|
|
68
|
+
io.out(`found ${id} in ${layout.directories[id]}/`);
|
|
69
|
+
}
|
|
70
|
+
for (const { stack, directory } of layout.skipped) {
|
|
71
|
+
io.out(`note: ${directory}/ also holds a ${stack} project; one folder per stack is managed, so it is left out`);
|
|
72
|
+
}
|
|
57
73
|
const platform = options.platform ?? ((await remoteHost(root))?.includes("gitlab") ? "gitlab" : "github");
|
|
58
74
|
const repo = await repoInfo(root, platform);
|
|
59
75
|
const holder = (await gitUserName(root)) ?? repo.owner ?? "the project authors";
|
|
60
|
-
const
|
|
76
|
+
const { confirm } = io;
|
|
77
|
+
if (options.interactive && confirm === undefined) {
|
|
78
|
+
throw new UsageError("--interactive needs a terminal to ask its questions in");
|
|
79
|
+
}
|
|
80
|
+
const defaults = defaultConfig({
|
|
61
81
|
stacks,
|
|
62
82
|
standard: STANDARD_VERSION,
|
|
63
83
|
copyright: `${new Date().getFullYear()} ${holder}`,
|
|
@@ -65,12 +85,29 @@ export async function initCommand(root, options, io) {
|
|
|
65
85
|
codeowners: repo.owner ? [`@${repo.owner}`] : [],
|
|
66
86
|
platform,
|
|
67
87
|
});
|
|
88
|
+
for (const id of stacks) {
|
|
89
|
+
const directory = layout.directories[id];
|
|
90
|
+
if (directory !== undefined)
|
|
91
|
+
defaults.stack_options[id] = { directory };
|
|
92
|
+
}
|
|
68
93
|
const branch = await remoteDefaultBranch(root);
|
|
69
94
|
if (branch && branch !== "main")
|
|
70
|
-
|
|
71
|
-
const
|
|
95
|
+
defaults[platform] = { default_branch: branch };
|
|
96
|
+
const preset = options.preset ?? "standard";
|
|
97
|
+
let config = applyPreset(defaults, preset);
|
|
98
|
+
const plan = async (candidate) => {
|
|
99
|
+
const ctx = await buildContext(root, candidate, repo);
|
|
100
|
+
return { ctx, outputs: planOutputs(ctx) };
|
|
101
|
+
};
|
|
102
|
+
if (options.interactive && confirm !== undefined) {
|
|
103
|
+
// the default health settings stay at hand for a preset that left the module out
|
|
104
|
+
const health = defaults.modules.health;
|
|
105
|
+
if (health)
|
|
106
|
+
config = await chooseModules(config, health, plan, { ...io, confirm });
|
|
107
|
+
}
|
|
108
|
+
const { ctx, outputs } = await plan(config);
|
|
72
109
|
const adopt = options.adoptAll ? "all" : new Set(options.adopt);
|
|
73
|
-
const result = await computeSync(root,
|
|
110
|
+
const result = await computeSync(root, outputs, null, { adopt, accept: new Set() });
|
|
74
111
|
if (!options.dryRun)
|
|
75
112
|
await guardUncommitted(root, result, options.force);
|
|
76
113
|
printResult(io, result);
|
|
@@ -78,12 +115,69 @@ export async function initCommand(root, options, io) {
|
|
|
78
115
|
io.out("dry run: nothing written");
|
|
79
116
|
return 0;
|
|
80
117
|
}
|
|
118
|
+
if (options.interactive && confirm !== undefined && !(await confirm("Write these files?", true))) {
|
|
119
|
+
io.out("nothing written");
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
81
122
|
await writeFile(join(root, CONFIG_FILE), renderConfig(config));
|
|
82
123
|
await applySync(root, result, null, STANDARD_VERSION);
|
|
83
124
|
io.out(`applied standard ${STANDARD_VERSION}; wrote ${CONFIG_FILE}`);
|
|
84
125
|
await printWarnings(root, ctx, io);
|
|
85
126
|
for (const step of await nextSteps(root, ctx, result))
|
|
86
127
|
io.out(`next: ${step}`);
|
|
128
|
+
if (config.github?.protect || config.github?.security) {
|
|
129
|
+
io.out("next: run `repokeeper github apply` to put the branch protection and security settings in place");
|
|
130
|
+
}
|
|
131
|
+
io.out(`next: commit with "chore(repokeeper): apply standard ${STANDARD_VERSION}"`);
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Applies a `.repokeeper.yml` that was written before repokeeper ran here, by hand or by the
|
|
136
|
+
* playground: nothing is detected or asked, the file says it all.
|
|
137
|
+
*/
|
|
138
|
+
async function initFromConfig(root, options, io) {
|
|
139
|
+
const given = [
|
|
140
|
+
options.stacks.length > 0 ? "--stack" : undefined,
|
|
141
|
+
options.platform !== undefined ? "--platform" : undefined,
|
|
142
|
+
options.preset !== undefined ? "--preset" : undefined,
|
|
143
|
+
].filter((name) => name !== undefined);
|
|
144
|
+
if (given.length > 0) {
|
|
145
|
+
throw new UsageError(`${CONFIG_FILE} already exists and is applied as it is written; leave out ${given.join(", ")}, or remove the file`);
|
|
146
|
+
}
|
|
147
|
+
const { confirm } = io;
|
|
148
|
+
if (options.interactive && confirm === undefined) {
|
|
149
|
+
throw new UsageError("--interactive needs a terminal to ask its questions in");
|
|
150
|
+
}
|
|
151
|
+
const config = await loadConfig(root);
|
|
152
|
+
assertSupportedStandard(config.standard);
|
|
153
|
+
io.out(`using the ${CONFIG_FILE} that is already here`);
|
|
154
|
+
const ctx = await buildContext(root, { ...config, standard: STANDARD_VERSION });
|
|
155
|
+
const adopt = options.adoptAll ? "all" : new Set(options.adopt);
|
|
156
|
+
const result = await computeSync(root, planOutputs(ctx), null, { adopt, accept: new Set() });
|
|
157
|
+
if (!options.dryRun)
|
|
158
|
+
await guardUncommitted(root, result, options.force);
|
|
159
|
+
printResult(io, result);
|
|
160
|
+
if (options.dryRun) {
|
|
161
|
+
io.out("dry run: nothing written");
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
// the module questions of a detected init have their answers in the file already
|
|
165
|
+
if (options.interactive && confirm !== undefined && !(await confirm("Write these files?", true))) {
|
|
166
|
+
io.out("nothing written");
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
if (config.standard !== STANDARD_VERSION) {
|
|
170
|
+
const path = join(root, CONFIG_FILE);
|
|
171
|
+
await writeFile(path, setStandard(await readFile(path, "utf8"), STANDARD_VERSION));
|
|
172
|
+
}
|
|
173
|
+
await applySync(root, result, null, STANDARD_VERSION);
|
|
174
|
+
io.out(`applied standard ${STANDARD_VERSION}`);
|
|
175
|
+
await printWarnings(root, ctx, io);
|
|
176
|
+
for (const step of await nextSteps(root, ctx, result))
|
|
177
|
+
io.out(`next: ${step}`);
|
|
178
|
+
if (config.github?.protect || config.github?.security) {
|
|
179
|
+
io.out("next: run `repokeeper github apply` to put the branch protection and security settings in place");
|
|
180
|
+
}
|
|
87
181
|
io.out(`next: commit with "chore(repokeeper): apply standard ${STANDARD_VERSION}"`);
|
|
88
182
|
return 0;
|
|
89
183
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
export const PRESETS = ["essential", "standard", "strict"];
|
|
2
|
+
/** One line per preset, for the usage text and the interactive question. */
|
|
3
|
+
export const PRESET_SUMMARY = {
|
|
4
|
+
essential: "editor settings, .gitignore and CI only",
|
|
5
|
+
standard: "everything except the drift check (the default)",
|
|
6
|
+
strict: "everything, the drift check, and branch protection with a required review",
|
|
7
|
+
};
|
|
8
|
+
/** The order the modules are offered in: files first, then the automation built on them. */
|
|
9
|
+
export const MODULE_IDS = [
|
|
10
|
+
"editorconfig",
|
|
11
|
+
"gitignore",
|
|
12
|
+
"commits",
|
|
13
|
+
"hooks",
|
|
14
|
+
"ci",
|
|
15
|
+
"release",
|
|
16
|
+
"deps",
|
|
17
|
+
"health",
|
|
18
|
+
"drift",
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* Applies a preset to a freshly built default configuration.
|
|
22
|
+
*
|
|
23
|
+
* A preset only switches on what repokeeper really does. There is no tier that promises code or
|
|
24
|
+
* secret scanning, because no module writes it.
|
|
25
|
+
*/
|
|
26
|
+
export function applyPreset(config, preset) {
|
|
27
|
+
if (preset === "standard")
|
|
28
|
+
return config;
|
|
29
|
+
if (preset === "essential") {
|
|
30
|
+
return {
|
|
31
|
+
...config,
|
|
32
|
+
modules: {
|
|
33
|
+
...config.modules,
|
|
34
|
+
commits: false,
|
|
35
|
+
hooks: false,
|
|
36
|
+
release: false,
|
|
37
|
+
deps: false,
|
|
38
|
+
drift: false,
|
|
39
|
+
health: false,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const strict = { ...config, modules: { ...config.modules, drift: true } };
|
|
44
|
+
// repository settings exist on GitHub only; they take effect with `repokeeper github apply`
|
|
45
|
+
if (config.platform === "github") {
|
|
46
|
+
strict.github = {
|
|
47
|
+
...config.github,
|
|
48
|
+
security: { dependabot_alerts: true, dependabot_security_updates: true },
|
|
49
|
+
protect: {
|
|
50
|
+
require_pull_request: true,
|
|
51
|
+
required_approvals: 1,
|
|
52
|
+
allow_force_push: false,
|
|
53
|
+
...(config.modules.commits ? { required_checks: ["commits / commitlint"] } : {}),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return strict;
|
|
58
|
+
}
|
|
59
|
+
/** The files each module would write to, so a question about it can name them. */
|
|
60
|
+
function filesByModule(outputs) {
|
|
61
|
+
const files = new Map();
|
|
62
|
+
for (const output of outputs) {
|
|
63
|
+
// one line per file, however many of its keys or blocks the module manages
|
|
64
|
+
files.set(output.module, (files.get(output.module) ?? new Set()).add(output.path));
|
|
65
|
+
}
|
|
66
|
+
return new Map([...files].map(([module, paths]) => [module, [...paths].sort()]));
|
|
67
|
+
}
|
|
68
|
+
/** The drift check is a job of the CI workflow, planned by the ci module. */
|
|
69
|
+
const plannedBy = (module) => (module === "drift" ? "ci" : module);
|
|
70
|
+
/**
|
|
71
|
+
* Asks, module by module, whether to apply it, naming the files it would write. The answer that
|
|
72
|
+
* Enter gives is the preset's.
|
|
73
|
+
*
|
|
74
|
+
* @param plan Plans the outputs for a configuration; called with every module switched on, so that
|
|
75
|
+
* a module the preset leaves out can still say what it would add.
|
|
76
|
+
*/
|
|
77
|
+
export async function chooseModules(config, health, plan, io) {
|
|
78
|
+
const everything = {
|
|
79
|
+
...config,
|
|
80
|
+
modules: {
|
|
81
|
+
editorconfig: true,
|
|
82
|
+
commits: true,
|
|
83
|
+
hooks: true,
|
|
84
|
+
ci: true,
|
|
85
|
+
release: true,
|
|
86
|
+
deps: true,
|
|
87
|
+
gitignore: true,
|
|
88
|
+
drift: true,
|
|
89
|
+
health,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
const files = filesByModule((await plan(everything)).outputs);
|
|
93
|
+
const modules = { ...config.modules };
|
|
94
|
+
io.out("Choose what to apply. Enter keeps the answer in capitals.");
|
|
95
|
+
for (const id of MODULE_IDS) {
|
|
96
|
+
const listed = id === "drift"
|
|
97
|
+
? [".github/workflows/ci.yml (a job that fails a pull request when a managed file was edited by hand)"]
|
|
98
|
+
: (files.get(plannedBy(id)) ?? []);
|
|
99
|
+
if (listed.length === 0)
|
|
100
|
+
continue;
|
|
101
|
+
const shown = listed.length > 6 ? [...listed.slice(0, 6), `and ${listed.length - 6} more`] : listed;
|
|
102
|
+
io.out("");
|
|
103
|
+
io.out(`${id}:`);
|
|
104
|
+
for (const file of shown)
|
|
105
|
+
io.out(` ${file}`);
|
|
106
|
+
const wanted = await io.confirm(`Apply ${id}?`, Boolean(modules[id]));
|
|
107
|
+
if (id === "health")
|
|
108
|
+
modules.health = wanted ? health : false;
|
|
109
|
+
else
|
|
110
|
+
modules[id] = wanted;
|
|
111
|
+
}
|
|
112
|
+
io.out("");
|
|
113
|
+
return { ...config, modules };
|
|
114
|
+
}
|
package/dist/commands/report.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { parse } from "yaml";
|
|
4
4
|
import { duplicateTestRuns } from "../duplicates.js";
|
|
5
5
|
import { crlfTrackedFiles } from "../git.js";
|
|
6
|
-
import { describeOutput } from "../model.js";
|
|
6
|
+
import { describeOutput, nodeAtRoot } from "../model.js";
|
|
7
7
|
import { overriddenAttributes } from "../sync/attributes.js";
|
|
8
8
|
import { TOOL_VERSIONS } from "../version.js";
|
|
9
9
|
/** How to name the output under `owned`: the path, or `path#key` for one key of a shared file. */
|
|
@@ -47,7 +47,7 @@ export async function nextSteps(root, ctx, result) {
|
|
|
47
47
|
const steps = [];
|
|
48
48
|
const wrote = (path) => result.decisions.some((d) => d.output.path === path && WROTE.includes(d.action));
|
|
49
49
|
if (ctx.config.modules.hooks && wrote("lefthook.yml")) {
|
|
50
|
-
steps.push(ctx.stacks
|
|
50
|
+
steps.push(nodeAtRoot(ctx.stacks)
|
|
51
51
|
? "install dependencies; this installs the git hooks"
|
|
52
52
|
: `run \`npx --yes lefthook@${TOOL_VERSIONS.lefthook} install\` to enable the git hooks`);
|
|
53
53
|
}
|
package/dist/commands/update.js
CHANGED
|
@@ -11,8 +11,8 @@ import { assertSupportedStandard } from "./check.js";
|
|
|
11
11
|
import { buildContext } from "./context.js";
|
|
12
12
|
import { guardUncommitted } from "./init.js";
|
|
13
13
|
import { nextSteps, printResult, printWarnings } from "./report.js";
|
|
14
|
-
export async function updateCommand(root, options, io) {
|
|
15
|
-
const config = await loadConfig(root);
|
|
14
|
+
export async function updateCommand(root, options, io, change) {
|
|
15
|
+
const config = change?.config ?? (await loadConfig(root));
|
|
16
16
|
assertSupportedStandard(config.standard);
|
|
17
17
|
const lock = await readLock(root);
|
|
18
18
|
if (!lock)
|
|
@@ -31,6 +31,7 @@ export async function updateCommand(root, options, io) {
|
|
|
31
31
|
io.out("dry run: nothing written");
|
|
32
32
|
return 0;
|
|
33
33
|
}
|
|
34
|
+
await change?.beforeWrite();
|
|
34
35
|
await applySync(root, result, lock, STANDARD_VERSION);
|
|
35
36
|
const steps = await nextSteps(root, ctx, result);
|
|
36
37
|
await printWarnings(root, ctx, io);
|
package/dist/config/schema.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { ACCESS_LEVELS } from "../gitlab/settings.js";
|
|
1
2
|
import { STACK_IDS } from "./types.js";
|
|
2
3
|
const flag = { type: "boolean" };
|
|
4
|
+
const ACCESS = Object.keys(ACCESS_LEVELS);
|
|
3
5
|
export const configSchema = {
|
|
4
6
|
type: "object",
|
|
5
7
|
additionalProperties: false,
|
|
@@ -46,6 +48,16 @@ export const configSchema = {
|
|
|
46
48
|
additionalProperties: false,
|
|
47
49
|
properties: {
|
|
48
50
|
default_branch: { type: "string", minLength: 1 },
|
|
51
|
+
workflows: {
|
|
52
|
+
type: "object",
|
|
53
|
+
additionalProperties: false,
|
|
54
|
+
properties: {
|
|
55
|
+
// "local", or the owner/name of a repository holding a copy of the reusable workflows
|
|
56
|
+
source: { type: "string", pattern: "^(local|[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)$" },
|
|
57
|
+
// "exact", or a tag, branch or commit SHA
|
|
58
|
+
ref: { type: "string", pattern: "^[A-Za-z0-9_./-]+$" },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
49
61
|
description: { type: "string" },
|
|
50
62
|
topics: { type: "array", uniqueItems: true, items: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,49}$" } },
|
|
51
63
|
merge: {
|
|
@@ -78,7 +90,34 @@ export const configSchema = {
|
|
|
78
90
|
gitlab: {
|
|
79
91
|
type: "object",
|
|
80
92
|
additionalProperties: false,
|
|
81
|
-
properties: {
|
|
93
|
+
properties: {
|
|
94
|
+
default_branch: { type: "string", minLength: 1 },
|
|
95
|
+
description: { type: "string" },
|
|
96
|
+
topics: { type: "array", uniqueItems: true, items: { type: "string", minLength: 1 } },
|
|
97
|
+
merge: {
|
|
98
|
+
type: "object",
|
|
99
|
+
additionalProperties: false,
|
|
100
|
+
properties: {
|
|
101
|
+
method: { enum: ["merge", "rebase_merge", "ff"] },
|
|
102
|
+
squash: { enum: ["never", "always", "default_on", "default_off"] },
|
|
103
|
+
delete_source_branch: flag,
|
|
104
|
+
pipeline_must_succeed: flag,
|
|
105
|
+
discussions_must_be_resolved: flag,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
protect: {
|
|
109
|
+
anyOf: [
|
|
110
|
+
{ const: false },
|
|
111
|
+
{
|
|
112
|
+
type: "object",
|
|
113
|
+
additionalProperties: false,
|
|
114
|
+
properties: { push: { enum: ACCESS }, merge: { enum: ACCESS }, allow_force_push: flag },
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
// five cron fields, read in UTC; false removes the schedule repokeeper made
|
|
119
|
+
renovate_schedule: { anyOf: [{ const: false }, { type: "string", pattern: "^\\S+( \\S+){4}$" }] },
|
|
120
|
+
},
|
|
82
121
|
},
|
|
83
122
|
},
|
|
84
123
|
};
|
package/dist/duplicates.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { readdir, readFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { parse } from "yaml";
|
|
4
|
-
import { REUSABLE_REPO } from "./version.js";
|
|
5
4
|
const WORKFLOWS = ".github/workflows";
|
|
6
5
|
const calls = (job) => {
|
|
7
6
|
const uses = job?.uses;
|
|
8
|
-
|
|
7
|
+
// a stack workflow, wherever it is called from: repokeeper's repository, a copy of it, or this repository
|
|
8
|
+
return typeof uses === "string" && /(^\.|^[^/]+\/[^/]+)\/\.github\/workflows\/stack-[a-z]+\.yml(@|$)/.test(uses);
|
|
9
9
|
};
|
|
10
10
|
/** Workflows of the user's own that run a stack's test command, which repokeeper's ci job now runs as well. */
|
|
11
11
|
export async function duplicateTestRuns(root, stacks) {
|
package/dist/errors.js
CHANGED
|
@@ -18,7 +18,7 @@ export class UsageError extends RepokeeperError {
|
|
|
18
18
|
super(message, 2);
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
-
/** A GitHub API call failed; nothing on disk was touched. */
|
|
21
|
+
/** A GitHub or GitLab API call failed; nothing on disk was touched. */
|
|
22
22
|
export class ApiError extends RepokeeperError {
|
|
23
23
|
constructor(message) {
|
|
24
24
|
super(message, 3);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { ApiError } from "../errors.js";
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
/** `GITLAB_TOKEN`, or the token the GitLab CLI holds for the host. */
|
|
6
|
+
export async function resolveGitlabToken(host, env = process.env) {
|
|
7
|
+
if (env.GITLAB_TOKEN)
|
|
8
|
+
return env.GITLAB_TOKEN;
|
|
9
|
+
try {
|
|
10
|
+
const { stdout } = await execFileAsync("glab", ["config", "get", "token", "--host", host], { encoding: "utf8" });
|
|
11
|
+
if (stdout.trim())
|
|
12
|
+
return stdout.trim();
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// fall through to the error below
|
|
16
|
+
}
|
|
17
|
+
throw new ApiError(`no GitLab token for ${host}: set GITLAB_TOKEN or sign in with \`glab auth login\``);
|
|
18
|
+
}
|
|
19
|
+
export function gitlabRestApi(token, host) {
|
|
20
|
+
const base = `https://${host}/api/v4`;
|
|
21
|
+
return {
|
|
22
|
+
async request(method, path, body) {
|
|
23
|
+
let response;
|
|
24
|
+
try {
|
|
25
|
+
response = await fetch(`${base}${path}`, {
|
|
26
|
+
method,
|
|
27
|
+
headers: {
|
|
28
|
+
accept: "application/json",
|
|
29
|
+
// read as a personal, project or group access token, and as the OAuth token of `glab`
|
|
30
|
+
authorization: `Bearer ${token}`,
|
|
31
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
32
|
+
},
|
|
33
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
throw new ApiError(`GitLab API ${method} ${path} failed: ${error.message}`);
|
|
38
|
+
}
|
|
39
|
+
const text = await response.text();
|
|
40
|
+
let data = null;
|
|
41
|
+
try {
|
|
42
|
+
data = text ? JSON.parse(text) : null;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
data = text;
|
|
46
|
+
}
|
|
47
|
+
return { status: response.status, data };
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|