@cldmv/gh-broker 0.7.0 → 0.8.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 +134 -40
- package/config.example.json +21 -8
- package/config.sample.json +45 -0
- package/deploy/README.md +73 -15
- package/deploy/install.mjs +166 -101
- package/deploy/install.sh +0 -0
- package/deploy/lib/config-merge.mjs +174 -16
- package/deploy/lib/credentials.mjs +143 -0
- package/deploy/lib/install-args.mjs +144 -0
- package/deploy/lib/npm-setup.mjs +164 -0
- package/deploy/lib/secret-store.mjs +20 -2
- package/deploy/lib/system.mjs +26 -2
- package/dist/index.mjs +1638 -202
- package/dist/index.mjs.map +4 -4
- package/docs/TOOLS.md +192 -1
- package/docs/changelog/v0/v0.8.0.md +115 -0
- package/docs/deploy-architecture.md +13 -11
- package/package.json +7 -7
- package/src/availability.ts +42 -0
- package/src/catalog.ts +18 -1
- package/src/config.ts +29 -2
- package/src/router.ts +43 -2
- package/src/secrets.ts +34 -4
- package/src/types.ts +25 -1
- package/src/verbs/ghcr/archive.ts +314 -0
- package/src/verbs/ghcr/config.ts +142 -0
- package/src/verbs/ghcr/jobs.ts +141 -0
- package/src/verbs/ghcr/push.ts +192 -0
- package/src/verbs/ghcr/registry.ts +275 -0
- package/src/verbs/ghcr/tar.ts +203 -0
- package/src/verbs/ghcr_push.ts +264 -0
- package/src/verbs/npm-registry.ts +168 -0
- package/src/verbs/npm-tarball.ts +76 -0
- package/src/verbs/npm_scaffold.ts +325 -0
- package/src/verbs/release-analysis.ts +3 -1
- package/tests/catalog.test.vitest.mts +55 -1
- package/tests/config-merge.test.vitest.mts +275 -8
- package/tests/config.test.vitest.mts +75 -1
- package/tests/credentials.test.vitest.mts +127 -0
- package/tests/ghcr-archive.test.vitest.mts +439 -0
- package/tests/ghcr-config.test.vitest.mts +96 -0
- package/tests/ghcr-push.test.vitest.mts +526 -0
- package/tests/ghcr-registry.test.vitest.mts +206 -0
- package/tests/helpers/ghcr-fixtures.mts +373 -0
- package/tests/install-args.test.vitest.mts +152 -0
- package/tests/issue-labels.test.vitest.mts +15 -0
- package/tests/npm-scaffold.test.vitest.mts +479 -0
- package/tests/npm-setup.test.vitest.mts +196 -0
- package/tests/paths.test.vitest.mts +5 -0
- package/tests/prompt.test.vitest.mts +128 -0
- package/tests/release-prs.test.vitest.mts +10 -0
- package/tests/router.test.vitest.mts +108 -2
- package/tests/secret-store.test.vitest.mts +64 -3
- package/tests/secrets.test.vitest.mts +53 -0
- package/tests/security-alerts.test.vitest.mts +10 -0
- package/tests/server.test.vitest.mts +187 -7
- package/tests/smoke.test.vitest.mts +177 -0
- package/tests/system.test.vitest.mts +355 -0
- package/tests/verb-helpers.test.vitest.mts +46 -0
- package/.configs/fix-headers.json +0 -3
- package/.configs/vitest.config.mjs +0 -44
- package/.github/dependabot.yml +0 -111
- package/.github/workflows/branch-retention.yml +0 -44
- package/.github/workflows/bundle-size.yml +0 -52
- package/.github/workflows/ci.yml +0 -389
- package/.github/workflows/cla.yml +0 -66
- package/.github/workflows/codeql.yml +0 -87
- package/.github/workflows/dependabot-auto-merge.yml +0 -62
- package/.github/workflows/dependabot-recreate.yml +0 -67
- package/.github/workflows/dependency-review.yml +0 -46
- package/.github/workflows/feature-pr.yml +0 -75
- package/.github/workflows/hotfix-redirector.yml +0 -67
- package/.github/workflows/hotfixes-release.yml +0 -70
- package/.github/workflows/labeler.yml +0 -48
- package/.github/workflows/master-commit-audit.yml +0 -56
- package/.github/workflows/member-auto-merge.yml +0 -75
- package/.github/workflows/next-release.yml +0 -76
- package/.github/workflows/next-reset.yml +0 -50
- package/.github/workflows/pr-notify.yml +0 -44
- package/.github/workflows/pr-title-normalizer.yml +0 -52
- package/.github/workflows/provenance.yml +0 -51
- package/.github/workflows/publish.yml +0 -146
- package/.github/workflows/release-merge.yml +0 -104
- package/.github/workflows/release-notify.yml +0 -41
- package/.github/workflows/scorecard.yml +0 -64
- package/.github/workflows/stale.yml +0 -50
- package/.github/workflows/tag-health.yml +0 -70
- package/.github/workflows/update-major-version-tags.yml +0 -94
- package/.github/workflows/v4-bootstrap.yml +0 -115
- package/.github/workflows/welcome.yml +0 -42
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Every host-touching primitive is mocked: nothing here spawns a process or touches the real filesystem.
|
|
4
|
+
const h = vi.hoisted(() => ({ spawnSync: vi.fn() }));
|
|
5
|
+
vi.mock("node:child_process", () => ({ spawnSync: h.spawnSync }));
|
|
6
|
+
vi.mock("node:fs", () => ({
|
|
7
|
+
existsSync: vi.fn(),
|
|
8
|
+
mkdirSync: vi.fn(),
|
|
9
|
+
chmodSync: vi.fn(),
|
|
10
|
+
chownSync: vi.fn(),
|
|
11
|
+
lstatSync: vi.fn(),
|
|
12
|
+
writeFileSync: vi.fn(),
|
|
13
|
+
cpSync: vi.fn(),
|
|
14
|
+
rmSync: vi.fn(),
|
|
15
|
+
symlinkSync: vi.fn(),
|
|
16
|
+
realpathSync: vi.fn()
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
import * as fs from "node:fs";
|
|
20
|
+
import * as sys from "../deploy/lib/system.mjs";
|
|
21
|
+
|
|
22
|
+
const f = vi.mocked(fs) as any;
|
|
23
|
+
const ok = (stdout = "", stderr = "") => ({ status: 0, stdout, stderr });
|
|
24
|
+
const fail = (status = 1, stdout = "", stderr = "") => ({ status, stdout, stderr });
|
|
25
|
+
|
|
26
|
+
/** Route spawnSync by `cmd args...` string prefix; unmatched commands succeed with empty output. */
|
|
27
|
+
function route(map: Record<string, any>) {
|
|
28
|
+
h.spawnSync.mockImplementation((cmd: string, args: string[] = []) => {
|
|
29
|
+
const line = [cmd, ...args].join(" ");
|
|
30
|
+
for (const [k, v] of Object.entries(map)) if (line.startsWith(k)) return typeof v === "function" ? v(line) : v;
|
|
31
|
+
return ok();
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
const calls = () => h.spawnSync.mock.calls.map((c: any[]) => [c[0], ...c[1]].join(" "));
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
h.spawnSync.mockReset();
|
|
38
|
+
for (const k of Object.keys(f)) if (typeof f[k]?.mockReset === "function") f[k].mockReset();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("run / has / isRoot", () => {
|
|
42
|
+
it("returns the result on success and passes options through", () => {
|
|
43
|
+
h.spawnSync.mockReturnValue(ok("x"));
|
|
44
|
+
expect(sys.run("echo", ["a"], { input: "i" }).stdout).toBe("x");
|
|
45
|
+
expect(h.spawnSync).toHaveBeenCalledWith("echo", ["a"], { encoding: "utf8", input: "i" });
|
|
46
|
+
sys.run("echo"); // default args
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("throws when the process could not be spawned", () => {
|
|
50
|
+
h.spawnSync.mockReturnValue({ error: new Error("ENOENT") });
|
|
51
|
+
expect(() => sys.run("nope")).toThrow("nope: ENOENT");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("throws on a non-zero exit with stderr, then stdout, then nothing", () => {
|
|
55
|
+
h.spawnSync.mockReturnValue(fail(2, "out", "err"));
|
|
56
|
+
expect(() => sys.run("c", ["a", "b"])).toThrow("c a b exited 2: err");
|
|
57
|
+
h.spawnSync.mockReturnValue(fail(2, "out", ""));
|
|
58
|
+
expect(() => sys.run("c")).toThrow("exited 2: out");
|
|
59
|
+
h.spawnSync.mockReturnValue(fail(2, "", ""));
|
|
60
|
+
expect(() => sys.run("c")).toThrow(/exited 2: $/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("allowFail returns the failing result instead of throwing", () => {
|
|
64
|
+
h.spawnSync.mockReturnValue(fail(3));
|
|
65
|
+
expect(sys.run("c", [], { allowFail: true }).status).toBe(3);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("has() reflects `command -v`", () => {
|
|
69
|
+
h.spawnSync.mockReturnValue({ status: 0 });
|
|
70
|
+
expect(sys.has("rsync")).toBe(true);
|
|
71
|
+
h.spawnSync.mockReturnValue({ status: 1 });
|
|
72
|
+
expect(sys.has("rsync")).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("isRoot() follows getuid", () => {
|
|
76
|
+
const orig = process.getuid;
|
|
77
|
+
try {
|
|
78
|
+
process.getuid = () => 0;
|
|
79
|
+
expect(sys.isRoot()).toBe(true);
|
|
80
|
+
process.getuid = () => 1000;
|
|
81
|
+
expect(sys.isRoot()).toBe(false);
|
|
82
|
+
(process as any).getuid = undefined;
|
|
83
|
+
expect(sys.isRoot()).toBe(false);
|
|
84
|
+
} finally {
|
|
85
|
+
process.getuid = orig;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("user / group lookups", () => {
|
|
91
|
+
it("userExists / groupExists use the exit status", () => {
|
|
92
|
+
route({ id: ok(), getent: fail(2) });
|
|
93
|
+
expect(sys.userExists("u")).toBe(true);
|
|
94
|
+
expect(sys.groupExists("g")).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("gidOf parses getent output, and is undefined on failure or junk", () => {
|
|
98
|
+
route({ getent: ok("grp:x:994:\n") });
|
|
99
|
+
expect(sys.gidOf("grp")).toBe(994);
|
|
100
|
+
route({ getent: fail(2) });
|
|
101
|
+
expect(sys.gidOf("grp")).toBeUndefined();
|
|
102
|
+
route({ getent: ok("grp:x:abc:\n") });
|
|
103
|
+
expect(sys.gidOf("grp")).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("uidOf parses `id -u`, and is undefined on failure or junk", () => {
|
|
107
|
+
route({ id: ok("1001\n") });
|
|
108
|
+
expect(sys.uidOf("u")).toBe(1001);
|
|
109
|
+
route({ id: fail(1) });
|
|
110
|
+
expect(sys.uidOf("u")).toBeUndefined();
|
|
111
|
+
route({ id: ok("junk\n") });
|
|
112
|
+
expect(sys.uidOf("u")).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("requireGid / requireUid throw a clear error when unresolved", () => {
|
|
116
|
+
route({ getent: ok("g:x:5:\n"), id: ok("7\n") });
|
|
117
|
+
expect(sys.requireGid("g")).toBe(5);
|
|
118
|
+
expect(sys.requireUid("u")).toBe(7);
|
|
119
|
+
route({ getent: fail(2), id: fail(1) });
|
|
120
|
+
expect(() => sys.requireGid("g")).toThrow('cannot resolve gid for group "g"');
|
|
121
|
+
expect(() => sys.requireUid("u")).toThrow('cannot resolve uid for user "u"');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("account creation", () => {
|
|
126
|
+
it("ensureGroup only creates a missing group", () => {
|
|
127
|
+
route({ getent: ok() });
|
|
128
|
+
sys.ensureGroup("g");
|
|
129
|
+
expect(calls()).not.toContain("groupadd --system g");
|
|
130
|
+
h.spawnSync.mockReset();
|
|
131
|
+
route({ getent: fail(2) });
|
|
132
|
+
sys.ensureGroup("g");
|
|
133
|
+
expect(calls()).toContain("groupadd --system g");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("addUserToGroup appends the group", () => {
|
|
137
|
+
route({});
|
|
138
|
+
sys.addUserToGroup("u", "g");
|
|
139
|
+
expect(calls()).toContain("usermod --append --groups g u");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("ensureServiceUser creates a missing user", () => {
|
|
143
|
+
route({ getent: ok(), id: fail(1) });
|
|
144
|
+
sys.ensureServiceUser("svc", "svcg", "clients");
|
|
145
|
+
const useradd = calls().find((c) => c.startsWith("useradd"))!;
|
|
146
|
+
expect(useradd).toContain("--system --gid svcg --groups clients");
|
|
147
|
+
expect(useradd.endsWith(" svc")).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("ensureServiceUser only adds an existing user to the clients group", () => {
|
|
151
|
+
route({ getent: ok(), id: ok() });
|
|
152
|
+
sys.ensureServiceUser("svc", "svcg", "clients");
|
|
153
|
+
expect(calls().some((c) => c.startsWith("useradd"))).toBe(false);
|
|
154
|
+
expect(calls()).toContain("usermod --append --groups clients svc");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("createAgentUser creates a plain user", () => {
|
|
158
|
+
route({});
|
|
159
|
+
sys.createAgentUser("agent");
|
|
160
|
+
expect(calls()).toContain("useradd --create-home --shell /bin/bash agent");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("systemd-creds", () => {
|
|
165
|
+
it("trySystemdCredDecrypt returns null when the file or the tool is missing", () => {
|
|
166
|
+
f.existsSync.mockReturnValue(false);
|
|
167
|
+
expect(sys.trySystemdCredDecrypt("n", "/c")).toBeNull();
|
|
168
|
+
f.existsSync.mockReturnValue(true);
|
|
169
|
+
route({ sh: fail(1) });
|
|
170
|
+
expect(sys.trySystemdCredDecrypt("n", "/c")).toBeNull();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("trySystemdCredDecrypt returns the token without its trailing newline", () => {
|
|
174
|
+
f.existsSync.mockReturnValue(true);
|
|
175
|
+
route({ sh: ok(), "systemd-creds": ok("tok\r\n") });
|
|
176
|
+
expect(sys.trySystemdCredDecrypt("n", "/c")).toBe("tok");
|
|
177
|
+
expect(calls()).toContain("systemd-creds decrypt --name=n /c -");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("trySystemdCredDecrypt returns null for a failed decrypt, empty output or a spawn error", () => {
|
|
181
|
+
f.existsSync.mockReturnValue(true);
|
|
182
|
+
route({ sh: ok(), "systemd-creds": fail(1) });
|
|
183
|
+
expect(sys.trySystemdCredDecrypt("n", "/c")).toBeNull();
|
|
184
|
+
route({ sh: ok(), "systemd-creds": ok("\n") });
|
|
185
|
+
expect(sys.trySystemdCredDecrypt("n", "/c")).toBeNull();
|
|
186
|
+
route({ sh: ok(), "systemd-creds": () => ({ error: new Error("spawn failed") }) });
|
|
187
|
+
expect(sys.trySystemdCredDecrypt("n", "/c")).toBeNull();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("systemdCredsEncrypt feeds the plaintext on stdin and locks the file down", () => {
|
|
191
|
+
route({});
|
|
192
|
+
sys.systemdCredsEncrypt("n", "secret", "/etc/x/n.cred");
|
|
193
|
+
expect(h.spawnSync).toHaveBeenCalledWith(
|
|
194
|
+
"systemd-creds",
|
|
195
|
+
["encrypt", "--name=n", "-", "/etc/x/n.cred"],
|
|
196
|
+
expect.objectContaining({ input: "secret" })
|
|
197
|
+
);
|
|
198
|
+
expect(f.chmodSync).toHaveBeenCalledWith("/etc/x/n.cred", 0o400);
|
|
199
|
+
expect(f.chownSync).toHaveBeenCalledWith("/etc/x/n.cred", 0, 0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("ensureCredsDir creates <configDir>/creds as 0750 root:group", () => {
|
|
203
|
+
route({ getent: ok("g:x:42:\n") });
|
|
204
|
+
f.existsSync.mockReturnValue(false);
|
|
205
|
+
sys.ensureCredsDir("/etc/gh-broker", "g");
|
|
206
|
+
expect(f.mkdirSync).toHaveBeenCalledWith("/etc/gh-broker/creds", { recursive: true, mode: 0o750 });
|
|
207
|
+
expect(f.chmodSync).toHaveBeenCalledWith("/etc/gh-broker/creds", 0o750);
|
|
208
|
+
expect(f.chownSync).toHaveBeenCalledWith("/etc/gh-broker/creds", 0, 42);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("ensureCredsDir leaves an existing directory in place but re-applies mode and owner", () => {
|
|
212
|
+
route({ getent: ok("g:x:42:\n") });
|
|
213
|
+
f.existsSync.mockReturnValue(true);
|
|
214
|
+
sys.ensureCredsDir("/etc/gh-broker", "g");
|
|
215
|
+
expect(f.mkdirSync).not.toHaveBeenCalled();
|
|
216
|
+
expect(f.chownSync).toHaveBeenCalled();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("writeCredsDropin writes one LoadCredentialEncrypted line per name and reloads", () => {
|
|
220
|
+
route({});
|
|
221
|
+
f.existsSync.mockReturnValue(true);
|
|
222
|
+
sys.writeCredsDropin(["a", "b"], "/etc/gh-broker/creds");
|
|
223
|
+
const [path, body, opts] = f.writeFileSync.mock.calls[0];
|
|
224
|
+
expect(path).toBe("/etc/systemd/system/gh-broker.service.d/creds.conf");
|
|
225
|
+
expect(body).toBe(
|
|
226
|
+
"[Service]\nLoadCredentialEncrypted=a:/etc/gh-broker/creds/a.cred\nLoadCredentialEncrypted=b:/etc/gh-broker/creds/b.cred\n"
|
|
227
|
+
);
|
|
228
|
+
expect(opts).toEqual({ mode: 0o644 });
|
|
229
|
+
expect(calls()).toContain("systemctl daemon-reload");
|
|
230
|
+
sys.writeCredsDropin([], "/c", "other.service");
|
|
231
|
+
expect(f.writeFileSync.mock.calls[1][0]).toBe("/etc/systemd/system/other.service.d/creds.conf");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("removeCredsDropin removes the drop-in and reloads only when it exists", () => {
|
|
235
|
+
route({});
|
|
236
|
+
f.existsSync.mockReturnValue(false);
|
|
237
|
+
sys.removeCredsDropin();
|
|
238
|
+
expect(f.rmSync).not.toHaveBeenCalled();
|
|
239
|
+
expect(calls()).toEqual([]);
|
|
240
|
+
f.existsSync.mockReturnValue(true);
|
|
241
|
+
sys.removeCredsDropin("x.service");
|
|
242
|
+
expect(f.rmSync).toHaveBeenCalledWith("/etc/systemd/system/x.service.d/creds.conf", { force: true });
|
|
243
|
+
expect(calls()).toContain("systemctl daemon-reload");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("node symlinks", () => {
|
|
248
|
+
it("links only the binaries that exist next to the given node", () => {
|
|
249
|
+
f.existsSync.mockImplementation((p: string) => p !== "/opt/node/bin/npx");
|
|
250
|
+
expect(sys.pointNodeSymlinks("/opt/node/bin/node")).toEqual(["node", "npm"]);
|
|
251
|
+
expect(f.rmSync).toHaveBeenCalledWith("/usr/local/bin/node", { force: true });
|
|
252
|
+
expect(f.symlinkSync).toHaveBeenCalledWith("/opt/node/bin/node", "/usr/local/bin/node");
|
|
253
|
+
expect(f.symlinkSync).not.toHaveBeenCalledWith("/opt/node/bin/npx", "/usr/local/bin/npx");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("currentNodeBin resolves the running node's real path", () => {
|
|
257
|
+
f.realpathSync.mockReturnValue("/real/node");
|
|
258
|
+
expect(sys.currentNodeBin()).toBe("/real/node");
|
|
259
|
+
expect(f.realpathSync).toHaveBeenCalledWith(process.execPath);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("ensureDropDir", () => {
|
|
264
|
+
const st = (kind: "dir" | "link" | "file") => ({
|
|
265
|
+
isSymbolicLink: () => kind === "link",
|
|
266
|
+
isDirectory: () => kind === "dir"
|
|
267
|
+
});
|
|
268
|
+
beforeEach(() => route({ getent: ok("g:x:42:\n"), id: ok("1001\n") }));
|
|
269
|
+
|
|
270
|
+
it("creates a missing parent (0755) and a missing dir (0750), then sets owner and 2750", () => {
|
|
271
|
+
f.existsSync.mockReturnValue(false);
|
|
272
|
+
f.lstatSync.mockImplementation(() => {
|
|
273
|
+
throw new Error("ENOENT");
|
|
274
|
+
});
|
|
275
|
+
sys.ensureDropDir("/var/lib/drop/in", "agent", "g");
|
|
276
|
+
expect(f.mkdirSync).toHaveBeenCalledWith("/var/lib/drop", { recursive: true, mode: 0o755 });
|
|
277
|
+
expect(f.mkdirSync).toHaveBeenCalledWith("/var/lib/drop/in", { mode: 0o750 });
|
|
278
|
+
expect(f.chownSync).toHaveBeenCalledWith("/var/lib/drop/in", 1001, 42);
|
|
279
|
+
expect(f.chmodSync).toHaveBeenCalledWith("/var/lib/drop/in", 0o2750);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("reuses an existing parent and directory", () => {
|
|
283
|
+
f.existsSync.mockReturnValue(true);
|
|
284
|
+
f.lstatSync.mockReturnValue(st("dir"));
|
|
285
|
+
sys.ensureDropDir("/var/lib/drop/in", "agent", "g");
|
|
286
|
+
expect(f.mkdirSync).not.toHaveBeenCalled();
|
|
287
|
+
expect(f.chmodSync).toHaveBeenCalledWith("/var/lib/drop/in", 0o2750);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("refuses a symlink and a non-directory", () => {
|
|
291
|
+
f.existsSync.mockReturnValue(true);
|
|
292
|
+
f.lstatSync.mockReturnValue(st("link"));
|
|
293
|
+
expect(() => sys.ensureDropDir("/d/x", "agent", "g")).toThrow("is a symlink");
|
|
294
|
+
f.lstatSync.mockReturnValue(st("file"));
|
|
295
|
+
expect(() => sys.ensureDropDir("/d/x", "agent", "g")).toThrow("is not a directory");
|
|
296
|
+
expect(f.chownSync).not.toHaveBeenCalled();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("directories, unit and service", () => {
|
|
301
|
+
beforeEach(() => route({ getent: ok("g:x:42:\n"), id: ok("1001\n") }));
|
|
302
|
+
|
|
303
|
+
it("ensureDirs creates config (0750 root:group) and app (0755 svc:group) dirs", () => {
|
|
304
|
+
f.existsSync.mockReturnValue(false);
|
|
305
|
+
sys.ensureDirs("/etc/gh-broker", "/opt/gh-broker", "svc", "g");
|
|
306
|
+
expect(f.chownSync).toHaveBeenCalledWith("/etc/gh-broker", 0, 42);
|
|
307
|
+
expect(f.chownSync).toHaveBeenCalledWith("/opt/gh-broker", 1001, 42);
|
|
308
|
+
expect(f.chmodSync).toHaveBeenCalledWith("/opt/gh-broker", 0o755);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("installUnit copies, chmods and reloads (default and explicit destination)", () => {
|
|
312
|
+
sys.installUnit("/src/u.service");
|
|
313
|
+
expect(f.cpSync).toHaveBeenCalledWith("/src/u.service", "/etc/systemd/system/gh-broker.service");
|
|
314
|
+
sys.installUnit("/src/u.service", "/x/y.service");
|
|
315
|
+
expect(f.chmodSync).toHaveBeenCalledWith("/x/y.service", 0o644);
|
|
316
|
+
expect(calls().filter((c) => c === "systemctl daemon-reload")).toHaveLength(2);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("enable / restart / serviceActive drive systemctl", () => {
|
|
320
|
+
route({ "systemctl enable": fail(1), "systemctl is-active": ok("active\n") });
|
|
321
|
+
sys.enableService(); // allowFail: a failing enable does not throw
|
|
322
|
+
sys.restartService();
|
|
323
|
+
expect(sys.serviceActive()).toBe("active");
|
|
324
|
+
expect(calls()).toContain("systemctl restart gh-broker.service");
|
|
325
|
+
route({ "systemctl restart": fail(1, "", "boom") });
|
|
326
|
+
expect(() => sys.restartService()).toThrow("boom");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("deployDist rsyncs when rsync exists", () => {
|
|
330
|
+
route({ getent: ok("g:x:42:\n"), id: ok("1001\n"), sh: ok() });
|
|
331
|
+
f.existsSync.mockReturnValue(true);
|
|
332
|
+
sys.deployDist("/src/dist", "/opt/gh-broker", "svc", "g");
|
|
333
|
+
expect(calls()).toContain("rsync -a --delete /src/dist/ /opt/gh-broker/dist/");
|
|
334
|
+
expect(calls()).toContain("chown -R 1001:42 /opt/gh-broker");
|
|
335
|
+
expect(f.cpSync).not.toHaveBeenCalled();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("deployDist falls back to rm + cp without rsync", () => {
|
|
339
|
+
route({ getent: ok("g:x:42:\n"), id: ok("1001\n"), sh: fail(1) });
|
|
340
|
+
f.existsSync.mockReturnValue(true);
|
|
341
|
+
sys.deployDist("/src/dist", "/opt/gh-broker", "svc", "g");
|
|
342
|
+
expect(f.rmSync).toHaveBeenCalledWith("/opt/gh-broker/dist", { recursive: true, force: true });
|
|
343
|
+
expect(f.cpSync).toHaveBeenCalledWith("/src/dist", "/opt/gh-broker/dist", { recursive: true });
|
|
344
|
+
expect(calls().some((c) => c.startsWith("rsync"))).toBe(false);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("writeConfigFile writes tab-indented JSON as 0640 root:group", () => {
|
|
348
|
+
sys.writeConfigFile("/etc/gh-broker/config.json", { a: 1 }, "g");
|
|
349
|
+
expect(f.writeFileSync).toHaveBeenCalledWith("/etc/gh-broker/config.json", '{\n\t"a": 1\n}\n', { mode: 0o640 });
|
|
350
|
+
expect(f.chmodSync).toHaveBeenCalledWith("/etc/gh-broker/config.json", 0o640);
|
|
351
|
+
expect(f.chownSync).toHaveBeenCalledWith("/etc/gh-broker/config.json", 0, 42);
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
afterEach(() => vi.clearAllMocks());
|
|
@@ -79,6 +79,17 @@ describe("report_security_alerts — summaryOnly / filters / roll-up", () => {
|
|
|
79
79
|
expect(by).toEqual({ "CLDMV/v4repo": "dev-trunk", "CLDMV/old": "non-v4" });
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
it("classify resolves a repo name that has no owner prefix against the queried owner", async () => {
|
|
83
|
+
const o = octokitWith(async (route, p) => {
|
|
84
|
+
if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [dep(1, "x", "high", { repository: { full_name: "plain" } })], headers: {} };
|
|
85
|
+
if (route.startsWith("GET /orgs/")) return { data: [], headers: {} };
|
|
86
|
+
expect(p).toMatchObject({ owner: "cldmv", repo: "plain" });
|
|
87
|
+
throw status(404);
|
|
88
|
+
});
|
|
89
|
+
const out = await reportSecurityAlerts(o, { owner: "cldmv", classify: true });
|
|
90
|
+
expect(out.byRepo[0]).toMatchObject({ repo: "plain", tier: "non-v4" });
|
|
91
|
+
});
|
|
92
|
+
|
|
82
93
|
it("classify stops cleanly when the call budget runs out", async () => {
|
|
83
94
|
const o = octokitWith(async (route) => {
|
|
84
95
|
if (route === "GET /orgs/{owner}/dependabot/alerts") return { data: [dep(1, "a", "high")], headers: {} };
|
|
@@ -145,6 +156,15 @@ describe("classifyRepo", () => {
|
|
|
145
156
|
it("v4 when a dev branch exists but next carries real work", async () => {
|
|
146
157
|
expect((await run(make({ workflow: true, next: "n", dev: "d" }))).tier).toBe("v4");
|
|
147
158
|
});
|
|
159
|
+
it("classifies a v4 repo whose info lacks a default branch, comparing nothing against it", async () => {
|
|
160
|
+
const o = octokitWith(async (route, p) => {
|
|
161
|
+
if (route === "GET /repos/{owner}/{repo}") return { data: {}, headers: {} };
|
|
162
|
+
if (route.endsWith("/contents/{path}")) return { data: {}, headers: {} };
|
|
163
|
+
if (p.branch === "next") return { data: { commit: { sha: "n" } }, headers: {} };
|
|
164
|
+
throw status(404);
|
|
165
|
+
});
|
|
166
|
+
expect(await run(o)).toEqual({ tier: "v4", signals: { v4Workflow: true, next: true, dev: false } });
|
|
167
|
+
});
|
|
148
168
|
it("copes with a repo whose info can't be read", async () => {
|
|
149
169
|
const o = octokitWith(async (route) => {
|
|
150
170
|
if (route === "GET /repos/{owner}/{repo}") throw status(404);
|
|
@@ -198,6 +218,7 @@ describe("release-analysis helpers", () => {
|
|
|
198
218
|
expect(classifyOpenPr({ ...base, title: "feat: y" }).kind).toBe("feature");
|
|
199
219
|
expect(classifyOpenPr({ ...base, title: "feat: y", draft: true }).kind).toBe("draft");
|
|
200
220
|
expect(classifyOpenPr({ ...base, title: "docs: no version" }).version).toBeUndefined();
|
|
221
|
+
expect(classifyOpenPr({ ...base, title: "feat: y", labels: [{}] }).kind).toBe("feature"); // a label entry with no name
|
|
201
222
|
});
|
|
202
223
|
|
|
203
224
|
const long = "# P v1.2.3 Changelog\n\n## Overview\n\n" + "text ".repeat(100);
|
|
@@ -216,6 +237,10 @@ describe("release-analysis helpers", () => {
|
|
|
216
237
|
|
|
217
238
|
const fresh = (status: any, internal = 0): any => ({ status, expectedPrs: 1, missingUserFacing: [], missingInternal: new Array(internal).fill({ number: 1, title: "x" }), headingMatchesVersion: true, readmeMentionsVersion: true });
|
|
218
239
|
const pr = (kind: any, number = 5, version?: string): any => ({ number, title: "t", url: "u", kind, ...(version ? { version } : {}) });
|
|
240
|
+
it("decideVerdict words the reason for a changelog it has no freshness for", () => {
|
|
241
|
+
expect(decideVerdict({ hasChangelog: true, unknownVersion: false, openPrs: [], version: "1.0.0" })).toMatchObject({ verdict: "update", reason: expect.stringContaining("changelog is out of date") });
|
|
242
|
+
});
|
|
243
|
+
|
|
219
244
|
it("decideVerdict walks the decision table", () => {
|
|
220
245
|
expect(decideVerdict({ hasChangelog: false, unknownVersion: true, openPrs: [], version: null }).verdict).toBe("inspect");
|
|
221
246
|
expect(decideVerdict({ hasChangelog: true, unknownVersion: false, freshness: fresh("current"), openPrs: [pr("feature")], version: "1.0.0" }).verdict).toBe("done");
|
|
@@ -321,6 +346,27 @@ describe("report_release_prs analyze", () => {
|
|
|
321
346
|
expect(b.freshness!.status).toBe("version-drift");
|
|
322
347
|
});
|
|
323
348
|
|
|
349
|
+
it("flags a doc PR for another version, tolerates commits with no message, and stops on a rate limit while reading", async () => {
|
|
350
|
+
const flight = async (route: string, p: any) => {
|
|
351
|
+
if (route === "GET /repos/{owner}/{repo}/pulls") return { data: [{ number: 21, title: "docs: v9.9.9 changelog", html_url: "u", head: { ref: "docs/x" } }], headers: {} };
|
|
352
|
+
if (route === "GET /repos/{owner}/{repo}/contents/{path}") throw status(404);
|
|
353
|
+
return handler()(route, p);
|
|
354
|
+
};
|
|
355
|
+
const out = await reportReleasePrs(octokitWith(flight), { owner: "cldmv", analyze: true });
|
|
356
|
+
expect(out.releasePrs[0]!.analysis).toMatchObject({ verdict: "in-flight", docVersionMismatch: true });
|
|
357
|
+
|
|
358
|
+
const bare = handler({ "GET /repos/{owner}/{repo}/pulls/{pull_number}/commits": { data: [{}, { commit: {} }], headers: {} } });
|
|
359
|
+
const quiet = await reportReleasePrs(octokitWith(bare), { owner: "cldmv", analyze: true });
|
|
360
|
+
expect(quiet.releasePrs[0]!.analysis).toMatchObject({ verdict: "done" });
|
|
361
|
+
|
|
362
|
+
const limited = async (route: string, p: any) => {
|
|
363
|
+
if (route === "GET /repos/{owner}/{repo}/contents/{path}" && p.path === "README.md") throw status(429);
|
|
364
|
+
return handler()(route, p);
|
|
365
|
+
};
|
|
366
|
+
const cut = await reportReleasePrs(octokitWith(limited), { owner: "cldmv", analyze: true });
|
|
367
|
+
expect(cut.truncatedReason).toBe("rate-limited");
|
|
368
|
+
});
|
|
369
|
+
|
|
324
370
|
it("propagates a non-404 error from a content read", async () => {
|
|
325
371
|
const o = octokitWith(async (route, p) => {
|
|
326
372
|
if (route === "GET /repos/{owner}/{repo}/contents/{path}" && p.path === "README.md") throw status(500);
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
* @Project: @cldmv/gh-broker
|
|
4
|
-
* @Filename: /.configs/vitest.config.mjs
|
|
5
|
-
* @Date: 2026-08-02T16:55:52-07:00 (1785714952)
|
|
6
|
-
* @Author: Nate Corcoran <CLDMV>
|
|
7
|
-
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
|
-
* -----
|
|
9
|
-
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
-
* @Last modified time: 2026-10-02T11:30:57-07:00 (1790965857)
|
|
11
|
-
* -----
|
|
12
|
-
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
-
*
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { defineConfig } from "vitest/config";
|
|
17
|
-
import { fileURLToPath } from "node:url";
|
|
18
|
-
import path from "node:path";
|
|
19
|
-
|
|
20
|
-
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
21
|
-
|
|
22
|
-
export default defineConfig({
|
|
23
|
-
root,
|
|
24
|
-
test: {
|
|
25
|
-
include: ["tests/**/*.test.vitest.mts"],
|
|
26
|
-
exclude: ["node_modules", "dist"],
|
|
27
|
-
environment: "node",
|
|
28
|
-
testTimeout: 30000,
|
|
29
|
-
reporters: ["dot"],
|
|
30
|
-
coverage: {
|
|
31
|
-
provider: "v8",
|
|
32
|
-
include: ["src/**/*.ts"],
|
|
33
|
-
// index.ts is the process entrypoint (arg parse + wire + listen);
|
|
34
|
-
// types.ts is type-only (elided at compile time — zero runtime code).
|
|
35
|
-
// Neither is unit-testable. server.ts (the stdio<->socket bridge) is
|
|
36
|
-
// deliberately NOT excluded — its logic is real and only partly covered,
|
|
37
|
-
// so it should show honestly rather than be hidden. Measured units are
|
|
38
|
-
// the pure logic: config, router/owner-routing, catalog, audit, secrets,
|
|
39
|
-
// verbs, upstream, appauth.
|
|
40
|
-
exclude: ["tests/**", "**/*.json", "src/index.ts", "src/types.ts"],
|
|
41
|
-
reporter: ["text", "html", "json-summary", "json"]
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
});
|
package/.github/dependabot.yml
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
#
|
|
2
|
-
# @Project: @cldmv/gh-broker
|
|
3
|
-
# @Filename: /.github/dependabot.yml
|
|
4
|
-
# @Date: 2026-05-26T00:00:00-07:00 (1779778800)
|
|
5
|
-
# @Author: Nate Corcoran <CLDMV>
|
|
6
|
-
# @Email: <Shinrai@users.noreply.github.com>
|
|
7
|
-
# -----
|
|
8
|
-
# @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
9
|
-
# @Last modified time: 2026-10-02T11:30:57-07:00 (1790965857)
|
|
10
|
-
# -----
|
|
11
|
-
# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
# Individual repo: .github/dependabot.yml
|
|
15
|
-
#
|
|
16
|
-
# Dependabot configuration tuned for the v4 staging-branch release flow.
|
|
17
|
-
#
|
|
18
|
-
# How it fits with v4:
|
|
19
|
-
# - All Dependabot PRs target `next` so they pool with every other
|
|
20
|
-
# contributor change and batch into the next release.
|
|
21
|
-
# - `dependabot-auto-merge.yml` (in this same folder) auto-merges
|
|
22
|
-
# patch/minor bumps into `next` after CI passes — zero-touch pooling.
|
|
23
|
-
# Delete that workflow if you'd rather review each bump by hand.
|
|
24
|
-
# - Security updates are detected by `hotfix-redirector.yml`
|
|
25
|
-
# (release-flow-v4/) by GHSA references in the PR body and auto-promoted
|
|
26
|
-
# from `next` → `hotfixes` so they ship via the hotfix lane, not the
|
|
27
|
-
# next-batch release. No special routing needed in this config.
|
|
28
|
-
#
|
|
29
|
-
# Customize per repo:
|
|
30
|
-
# - Add or remove `package-ecosystem` blocks for your stack (gomod, pip,
|
|
31
|
-
# bundler, gradle, maven, cargo, docker, etc.).
|
|
32
|
-
# - Adjust `directory` if your manifests don't live at the repo root.
|
|
33
|
-
# - Tighten `open-pull-requests-limit` if Dependabot's noise is too much.
|
|
34
|
-
# - Add `allow` / `ignore` rules for specific packages.
|
|
35
|
-
# - Add `groups` to bundle related bumps into a single PR.
|
|
36
|
-
|
|
37
|
-
version: 2
|
|
38
|
-
updates:
|
|
39
|
-
# GitHub Actions: keep pinned action SHAs / version tags fresh.
|
|
40
|
-
- package-ecosystem: "github-actions"
|
|
41
|
-
directory: "/"
|
|
42
|
-
target-branch: "next"
|
|
43
|
-
schedule:
|
|
44
|
-
interval: "weekly"
|
|
45
|
-
open-pull-requests-limit: 5
|
|
46
|
-
commit-message:
|
|
47
|
-
prefix: "deps"
|
|
48
|
-
# Grouped PRs cut noise: one PR per (security | patch | minor) bundle
|
|
49
|
-
# per week instead of N separate PRs. Security PRs still get retargeted
|
|
50
|
-
# to `hotfixes` by hotfix-redirector.yml when GHSA refs appear in the
|
|
51
|
-
# body — bundling N GHSA fixes into one PR is fine, the redirector
|
|
52
|
-
# only needs one match to retarget.
|
|
53
|
-
groups:
|
|
54
|
-
security:
|
|
55
|
-
applies-to: security-updates
|
|
56
|
-
patterns: ["*"]
|
|
57
|
-
patch:
|
|
58
|
-
applies-to: version-updates
|
|
59
|
-
update-types: ["patch"]
|
|
60
|
-
minor:
|
|
61
|
-
applies-to: version-updates
|
|
62
|
-
update-types: ["minor"]
|
|
63
|
-
|
|
64
|
-
# NPM: package.json + package-lock.json updates.
|
|
65
|
-
# Delete this block if your repo isn't a Node project.
|
|
66
|
-
- package-ecosystem: "npm"
|
|
67
|
-
directory: "/"
|
|
68
|
-
target-branch: "next"
|
|
69
|
-
schedule:
|
|
70
|
-
interval: "weekly"
|
|
71
|
-
open-pull-requests-limit: 5
|
|
72
|
-
commit-message:
|
|
73
|
-
prefix: "deps"
|
|
74
|
-
groups:
|
|
75
|
-
# vitest and @vitest/coverage-v8 (and other @vitest/* packages) peer
|
|
76
|
-
# each other EXACTLY, so a partial bump (e.g. vitest to 5.0.0 while
|
|
77
|
-
# @vitest/coverage-v8 stays 4.1.11) breaks `npm ci` with an ERESOLVE.
|
|
78
|
-
# Bump the whole family together in one PR so the exact-peer
|
|
79
|
-
# versions never diverge. Must come before security/patch/minor
|
|
80
|
-
# below — Dependabot assigns each update to the FIRST matching
|
|
81
|
-
# group, and this one has no applies-to restriction so it always
|
|
82
|
-
# wins for vitest-family packages regardless of update type.
|
|
83
|
-
vitest:
|
|
84
|
-
patterns:
|
|
85
|
-
- "vitest"
|
|
86
|
-
- "@vitest/*"
|
|
87
|
-
# @eslint/js peers eslint with a major-locked range ("^10.0.0"), so a
|
|
88
|
-
# future eslint major bump without @eslint/js moving too would hit
|
|
89
|
-
# the same npm ci ERESOLVE class as the vitest/coverage-v8 split.
|
|
90
|
-
# @cldmv/eslint-plugin-jsonv doesn't peer eslint directly but is
|
|
91
|
-
# bundled here to keep the lint-config family moving together.
|
|
92
|
-
eslint:
|
|
93
|
-
patterns:
|
|
94
|
-
- "eslint"
|
|
95
|
-
- "@eslint/*"
|
|
96
|
-
- "@cldmv/eslint-plugin-*"
|
|
97
|
-
# @cldmv/prettier-plugin-jsonv peers prettier with a major-locked
|
|
98
|
-
# range ("^3.0.0") — same reasoning as the eslint group above.
|
|
99
|
-
prettier:
|
|
100
|
-
patterns:
|
|
101
|
-
- "prettier"
|
|
102
|
-
- "@cldmv/prettier-plugin-*"
|
|
103
|
-
security:
|
|
104
|
-
applies-to: security-updates
|
|
105
|
-
patterns: ["*"]
|
|
106
|
-
patch:
|
|
107
|
-
applies-to: version-updates
|
|
108
|
-
update-types: ["patch"]
|
|
109
|
-
minor:
|
|
110
|
-
applies-to: version-updates
|
|
111
|
-
update-types: ["minor"]
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
#
|
|
2
|
-
# @Project: @cldmv/gh-broker
|
|
3
|
-
# @Filename: /.github/workflows/branch-retention.yml
|
|
4
|
-
# @Date: 2026-05-20T00:00:00-07:00 (1779260400)
|
|
5
|
-
# @Author: Nate Corcoran <CLDMV>
|
|
6
|
-
# @Email: <Shinrai@users.noreply.github.com>
|
|
7
|
-
# -----
|
|
8
|
-
# @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
9
|
-
# @Last modified time: 2026-10-02T11:30:57-07:00 (1790965857)
|
|
10
|
-
# -----
|
|
11
|
-
# @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
# Individual repo: .github/workflows/branch-retention.yml
|
|
15
|
-
#
|
|
16
|
-
# On PR merge: most branches deleted immediately; release/* keeps last 5,
|
|
17
|
-
# hotfix/* keeps last 3. master/main/badges/gh-pages never touched.
|
|
18
|
-
#
|
|
19
|
-
# v4 flow: feature PRs merge into `next` and hotfix PRs into `hotfixes`
|
|
20
|
-
# (not directly into master). next/hotfixes are in the branches: filter
|
|
21
|
-
# below so this workflow fires on those PR closures too — otherwise
|
|
22
|
-
# feat/* / fix/* / chore/* etc. would pile up on origin indefinitely.
|
|
23
|
-
# (Repos that haven't adopted v4 just won't see those branches; the
|
|
24
|
-
# extra entries in the filter are harmless.)
|
|
25
|
-
name: 🌿 Branch Retention
|
|
26
|
-
|
|
27
|
-
on:
|
|
28
|
-
pull_request:
|
|
29
|
-
types: [closed]
|
|
30
|
-
branches: [master, main, next, hotfixes]
|
|
31
|
-
|
|
32
|
-
permissions:
|
|
33
|
-
contents: read
|
|
34
|
-
|
|
35
|
-
jobs:
|
|
36
|
-
retain:
|
|
37
|
-
permissions:
|
|
38
|
-
contents: write
|
|
39
|
-
pull-requests: read
|
|
40
|
-
if: github.event.pull_request.merged == true
|
|
41
|
-
uses: CLDMV/.github/.github/workflows/reusable-branch-retention.yml@v4
|
|
42
|
-
secrets:
|
|
43
|
-
BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }}
|
|
44
|
-
BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }}
|