@catladder/cli 0.0.0-main-a6b45abe → 0.0.0-main-fd9c5f51
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/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithub.js +48 -9
- package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithub.js.map +1 -1
- package/dist/apps/cli/src/apps/cli/commands/project/githubMergeGating.d.ts +83 -0
- package/dist/apps/cli/src/apps/cli/commands/project/githubMergeGating.js +80 -0
- package/dist/apps/cli/src/apps/cli/commands/project/githubMergeGating.js.map +1 -0
- package/dist/apps/cli/src/apps/cli/commands/project/setup/index.js +4 -3
- package/dist/apps/cli/src/apps/cli/commands/project/setup/index.js.map +1 -1
- package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.d.ts +12 -7
- package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js +138 -31
- package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js.map +1 -1
- package/dist/apps/cli/src/release/changesetsReleaseJob.js +64 -4
- package/dist/apps/cli/src/release/changesetsReleaseJob.js.map +1 -1
- package/dist/apps/cli/src/release/releaseGit.d.ts +2 -0
- package/dist/apps/cli/src/release/releaseGit.js +9 -1
- package/dist/apps/cli/src/release/releaseGit.js.map +1 -1
- package/dist/apps/cli/src/utils/github.d.ts +1 -1
- package/dist/apps/cli/src/utils/github.js.map +1 -1
- package/dist/bundles/catci/index.js +5 -5
- package/dist/bundles/catenv/index.js +5 -0
- package/dist/bundles/cli/index.js +282 -43
- package/dist/bundles/runner-images/changesets/Dockerfile +2 -1
- package/dist/bundles/skills/catladder-migrate-ci-backend/SKILL.md +15 -7
- package/dist/packages/pipeline/src/backends/github/githubReleaseJobs.js +5 -0
- package/dist/packages/pipeline/src/backends/github/githubReleaseJobs.js.map +1 -1
- package/dist/runner-images/changesets/Dockerfile +2 -1
- package/dist/skills/catladder-migrate-ci-backend/SKILL.md +15 -7
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/apps/cli/commands/project/__tests__/githubMergeGating.test.ts +123 -0
- package/src/apps/cli/commands/project/doctor/checkGithub.ts +74 -8
- package/src/apps/cli/commands/project/githubMergeGating.ts +101 -0
- package/src/apps/cli/commands/project/setup/index.ts +4 -3
- package/src/apps/cli/commands/project/setup/setupGithub.ts +190 -27
- package/src/release/changesetsReleaseJob.ts +81 -6
- package/src/release/releaseGit.ts +11 -0
- package/src/utils/github.ts +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { AGGREGATE_CHECK_JOB_NAME } from "@catladder/pipeline";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
desiredMergeGatingRuleset,
|
|
5
|
+
GITHUB_ACTIONS_APP_ID,
|
|
6
|
+
rulesetProvidesMergeGating,
|
|
7
|
+
} from "../githubMergeGating";
|
|
8
|
+
|
|
9
|
+
const asRuleset = (overrides: object = {}) => ({
|
|
10
|
+
id: 1,
|
|
11
|
+
...desiredMergeGatingRuleset(),
|
|
12
|
+
...overrides,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("desiredMergeGatingRuleset", () => {
|
|
16
|
+
it("requires the aggregate check on the default branch", () => {
|
|
17
|
+
const ruleset = desiredMergeGatingRuleset();
|
|
18
|
+
expect(ruleset.enforcement).toBe("active");
|
|
19
|
+
expect(ruleset.conditions.ref_name.include).toEqual(["~DEFAULT_BRANCH"]);
|
|
20
|
+
expect(ruleset.rules).toEqual([
|
|
21
|
+
expect.objectContaining({
|
|
22
|
+
type: "required_status_checks",
|
|
23
|
+
parameters: expect.objectContaining({
|
|
24
|
+
required_status_checks: [
|
|
25
|
+
{
|
|
26
|
+
context: AGGREGATE_CHECK_JOB_NAME,
|
|
27
|
+
integration_id: GITHUB_ACTIONS_APP_ID,
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
}),
|
|
31
|
+
}),
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("lets deploy keys (the release push) bypass the gating — never the workflow token", () => {
|
|
36
|
+
expect(desiredMergeGatingRuleset().bypass_actors).toEqual([
|
|
37
|
+
{ actor_id: null, actor_type: "DeployKey", bypass_mode: "always" },
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("satisfies its own predicate", () => {
|
|
42
|
+
expect(rulesetProvidesMergeGating(asRuleset())).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("rulesetProvidesMergeGating", () => {
|
|
47
|
+
it("rejects a disabled ruleset", () => {
|
|
48
|
+
expect(
|
|
49
|
+
rulesetProvidesMergeGating(asRuleset({ enforcement: "disabled" })),
|
|
50
|
+
).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rejects a ruleset without the aggregate check", () => {
|
|
54
|
+
expect(rulesetProvidesMergeGating(asRuleset({ rules: [] }))).toBe(false);
|
|
55
|
+
expect(
|
|
56
|
+
rulesetProvidesMergeGating(
|
|
57
|
+
asRuleset({
|
|
58
|
+
rules: [
|
|
59
|
+
{
|
|
60
|
+
type: "required_status_checks",
|
|
61
|
+
parameters: {
|
|
62
|
+
required_status_checks: [{ context: "some other check" }],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
}),
|
|
67
|
+
),
|
|
68
|
+
).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("rejects a ruleset whose deploy-key bypass is missing or weakened", () => {
|
|
72
|
+
expect(rulesetProvidesMergeGating(asRuleset({ bypass_actors: [] }))).toBe(
|
|
73
|
+
false,
|
|
74
|
+
);
|
|
75
|
+
expect(
|
|
76
|
+
rulesetProvidesMergeGating(
|
|
77
|
+
asRuleset({
|
|
78
|
+
bypass_actors: [
|
|
79
|
+
{
|
|
80
|
+
actor_id: null,
|
|
81
|
+
actor_type: "DeployKey",
|
|
82
|
+
bypass_mode: "pull_request",
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
}),
|
|
86
|
+
),
|
|
87
|
+
).toBe(false);
|
|
88
|
+
// the broken pre-deploy-key encoding: github refuses the built-in
|
|
89
|
+
// actions app as a bypass actor, so it must not count as gating
|
|
90
|
+
expect(
|
|
91
|
+
rulesetProvidesMergeGating(
|
|
92
|
+
asRuleset({
|
|
93
|
+
bypass_actors: [
|
|
94
|
+
{
|
|
95
|
+
actor_id: GITHUB_ACTIONS_APP_ID,
|
|
96
|
+
actor_type: "Integration",
|
|
97
|
+
bypass_mode: "always",
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
}),
|
|
101
|
+
),
|
|
102
|
+
).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("accepts extra rules and bypass actors alongside ours", () => {
|
|
106
|
+
const desired = desiredMergeGatingRuleset();
|
|
107
|
+
expect(
|
|
108
|
+
rulesetProvidesMergeGating(
|
|
109
|
+
asRuleset({
|
|
110
|
+
rules: [{ type: "deletion" }, ...desired.rules],
|
|
111
|
+
bypass_actors: [
|
|
112
|
+
{
|
|
113
|
+
actor_id: 5,
|
|
114
|
+
actor_type: "RepositoryRole",
|
|
115
|
+
bypass_mode: "always",
|
|
116
|
+
},
|
|
117
|
+
...desired.bypass_actors,
|
|
118
|
+
],
|
|
119
|
+
}),
|
|
120
|
+
),
|
|
121
|
+
).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -16,6 +16,13 @@ import {
|
|
|
16
16
|
listGithubSecretNames,
|
|
17
17
|
listGithubVariableNames,
|
|
18
18
|
} from "../../../../../utils/github";
|
|
19
|
+
import type { GithubRuleset } from "../githubMergeGating";
|
|
20
|
+
import {
|
|
21
|
+
MERGE_GATING_RULESET_NAME,
|
|
22
|
+
RELEASE_DEPLOY_KEY_SECRET,
|
|
23
|
+
RELEASE_DEPLOY_KEY_TITLE,
|
|
24
|
+
rulesetProvidesMergeGating,
|
|
25
|
+
} from "../githubMergeGating";
|
|
19
26
|
import type { DoctorReport } from "./DoctorReport";
|
|
20
27
|
|
|
21
28
|
/** marker for repo-level (environment-less) targets, see collectSecretTargets */
|
|
@@ -161,10 +168,13 @@ export const checkGithub = async (report: DoctorReport, config: Config) => {
|
|
|
161
168
|
|
|
162
169
|
/**
|
|
163
170
|
* verifies the merge gating `project setup` configures: auto-merge
|
|
164
|
-
* enabled and the
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
171
|
+
* enabled and the `catladder merge gating` ruleset requiring the
|
|
172
|
+
* generated `catladder ✅` aggregate check on the default branch (with
|
|
173
|
+
* the bypass that lets the release job push). Without the check a PR
|
|
174
|
+
* is mergeable while its checks are still running — gitlab ships this
|
|
175
|
+
* behavior built-in, github silently does not. The check required via
|
|
176
|
+
* classic branch protection instead is flagged: that setup rejects the
|
|
177
|
+
* release job's own push (GH006).
|
|
168
178
|
*/
|
|
169
179
|
const checkGithubMergeGating = async (report: DoctorReport, repo: string) => {
|
|
170
180
|
let repoInfo: { default_branch: string; allow_auto_merge: boolean };
|
|
@@ -184,6 +194,64 @@ const checkGithubMergeGating = async (report: DoctorReport, repo: string) => {
|
|
|
184
194
|
}
|
|
185
195
|
|
|
186
196
|
const branch = repoInfo.default_branch;
|
|
197
|
+
let rulesets: Array<{ id: number; name: string }>;
|
|
198
|
+
try {
|
|
199
|
+
rulesets = await ghApiJson("GET", `repos/${repo}/rulesets?per_page=100`);
|
|
200
|
+
} catch (e) {
|
|
201
|
+
report.warn(`could not list rulesets of ${repo} (${e.message})`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const named = rulesets.find((r) => r.name === MERGE_GATING_RULESET_NAME);
|
|
205
|
+
const ruleset = named
|
|
206
|
+
? await ghApiJson<GithubRuleset>(
|
|
207
|
+
"GET",
|
|
208
|
+
`repos/${repo}/rulesets/${named.id}`,
|
|
209
|
+
).catch(() => null)
|
|
210
|
+
: null;
|
|
211
|
+
if (ruleset && rulesetProvidesMergeGating(ruleset)) {
|
|
212
|
+
report.ok(
|
|
213
|
+
`ruleset '${MERGE_GATING_RULESET_NAME}': '${AGGREGATE_CHECK_JOB_NAME}' required on ${branch}, deploy-key bypass for the release push`,
|
|
214
|
+
);
|
|
215
|
+
} else if (ruleset) {
|
|
216
|
+
report.fail(
|
|
217
|
+
`ruleset '${MERGE_GATING_RULESET_NAME}' exists but is incomplete (inactive, check not required, or deploy-key bypass missing)`,
|
|
218
|
+
"run: catladder project setup",
|
|
219
|
+
);
|
|
220
|
+
} else {
|
|
221
|
+
report.fail(
|
|
222
|
+
`no '${MERGE_GATING_RULESET_NAME}' ruleset on ${repo} — PRs can merge while the pipeline is red or still running`,
|
|
223
|
+
"run: catladder project setup",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const [keys, secretNames] = await Promise.all([
|
|
229
|
+
ghApiJson<Array<{ title: string; read_only: boolean }>>(
|
|
230
|
+
"GET",
|
|
231
|
+
`repos/${repo}/keys?per_page=100`,
|
|
232
|
+
),
|
|
233
|
+
listGithubSecretNames(repo),
|
|
234
|
+
]);
|
|
235
|
+
const key = keys.find((k) => k.title === RELEASE_DEPLOY_KEY_TITLE);
|
|
236
|
+
const hasSecret = secretNames.some(
|
|
237
|
+
(name) => name.toUpperCase() === RELEASE_DEPLOY_KEY_SECRET,
|
|
238
|
+
);
|
|
239
|
+
if (key && !key.read_only && hasSecret) {
|
|
240
|
+
report.ok(
|
|
241
|
+
`release deploy key '${RELEASE_DEPLOY_KEY_TITLE}' + secret ${RELEASE_DEPLOY_KEY_SECRET} in place`,
|
|
242
|
+
);
|
|
243
|
+
} else {
|
|
244
|
+
report.fail(
|
|
245
|
+
`release deploy key incomplete (key: ${
|
|
246
|
+
key ? (key.read_only ? "read-only" : "ok") : "missing"
|
|
247
|
+
}, secret ${RELEASE_DEPLOY_KEY_SECRET}: ${hasSecret ? "set" : "missing"}) — the release job cannot push past the merge gating`,
|
|
248
|
+
"run: catladder project setup",
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
} catch (e) {
|
|
252
|
+
report.warn(`could not check the release deploy key (${e.message})`);
|
|
253
|
+
}
|
|
254
|
+
|
|
187
255
|
const protection = await ghApiJson<any>(
|
|
188
256
|
"GET",
|
|
189
257
|
`repos/${repo}/branches/${branch}/protection`,
|
|
@@ -191,17 +259,15 @@ const checkGithubMergeGating = async (report: DoctorReport, repo: string) => {
|
|
|
191
259
|
const checks: Array<{ context: string }> =
|
|
192
260
|
protection?.required_status_checks?.checks ?? [];
|
|
193
261
|
if (checks.some((c) => c.context === AGGREGATE_CHECK_JOB_NAME)) {
|
|
194
|
-
report.ok(`'${AGGREGATE_CHECK_JOB_NAME}' required on ${branch}`);
|
|
195
|
-
} else {
|
|
196
262
|
report.fail(
|
|
197
|
-
`'${AGGREGATE_CHECK_JOB_NAME}' is
|
|
263
|
+
`'${AGGREGATE_CHECK_JOB_NAME}' is still required via classic branch protection on ${branch} — that rejects the release job's push (GH006)`,
|
|
198
264
|
"run: catladder project setup",
|
|
199
265
|
);
|
|
200
266
|
}
|
|
201
267
|
const stale = checks.filter((c) => c.context !== AGGREGATE_CHECK_JOB_NAME);
|
|
202
268
|
if (stale.length > 0) {
|
|
203
269
|
report.warn(
|
|
204
|
-
`${branch} requires ${stale.length} additional named check(s) (${stale
|
|
270
|
+
`${branch} requires ${stale.length} additional named check(s) via classic branch protection (${stale
|
|
205
271
|
.map((c) => c.context)
|
|
206
272
|
.join(
|
|
207
273
|
", ",
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { AGGREGATE_CHECK_JOB_NAME } from "@catladder/pipeline";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* github merge gating — shared between `project setup` (writes it) and
|
|
5
|
+
* `project doctor` (verifies it).
|
|
6
|
+
*
|
|
7
|
+
* The gating lives in a repository RULESET, not classic branch
|
|
8
|
+
* protection: only rulesets can carry bypass actors, and the release
|
|
9
|
+
* job needs one. It pushes the release commit and tag straight to the
|
|
10
|
+
* default branch — under classic protection the required `catladder ✅`
|
|
11
|
+
* check rejects that push (GH006), because the fresh release commit
|
|
12
|
+
* cannot have a passing check yet.
|
|
13
|
+
*
|
|
14
|
+
* The bypass actor is a DEPLOY KEY: github deliberately refuses the
|
|
15
|
+
* built-in GitHub Actions app on bypass lists (any collaborator could
|
|
16
|
+
* push anywhere by authoring a workflow), so the workflow token can
|
|
17
|
+
* never bypass. Instead `project setup` provisions a write deploy key
|
|
18
|
+
* (public half on the repo, private half as an actions secret) and the
|
|
19
|
+
* release job pushes over ssh with it. Deploy keys are repo-scoped,
|
|
20
|
+
* only admins can add them, and they never expire — gitlab's
|
|
21
|
+
* counterpart is the maintainer-role `GL_TOKEN`, which has to be
|
|
22
|
+
* renewed yearly.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** the app id of GitHub Actions — the integration reporting the check */
|
|
26
|
+
export const GITHUB_ACTIONS_APP_ID = 15368;
|
|
27
|
+
|
|
28
|
+
export const MERGE_GATING_RULESET_NAME = "catladder merge gating";
|
|
29
|
+
|
|
30
|
+
/** title of the deploy key `project setup` registers on the repo */
|
|
31
|
+
export const RELEASE_DEPLOY_KEY_TITLE = "catladder release";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* actions secret holding the deploy key's private half; the generated
|
|
35
|
+
* release jobs pass it to catci, which pushes over ssh when it is set
|
|
36
|
+
*/
|
|
37
|
+
export const RELEASE_DEPLOY_KEY_SECRET = "CATLADDER_RELEASE_KEY";
|
|
38
|
+
|
|
39
|
+
export type GithubRuleset = {
|
|
40
|
+
id: number;
|
|
41
|
+
name: string;
|
|
42
|
+
enforcement: string;
|
|
43
|
+
rules?: Array<{
|
|
44
|
+
type: string;
|
|
45
|
+
parameters?: {
|
|
46
|
+
required_status_checks?: Array<{ context: string }>;
|
|
47
|
+
};
|
|
48
|
+
}>;
|
|
49
|
+
bypass_actors?: Array<{
|
|
50
|
+
actor_id?: number | null;
|
|
51
|
+
actor_type: string;
|
|
52
|
+
bypass_mode: string;
|
|
53
|
+
}>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** the ruleset `project setup` creates (and restores on drift) */
|
|
57
|
+
export const desiredMergeGatingRuleset = () => ({
|
|
58
|
+
name: MERGE_GATING_RULESET_NAME,
|
|
59
|
+
target: "branch",
|
|
60
|
+
enforcement: "active",
|
|
61
|
+
conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
|
|
62
|
+
rules: [
|
|
63
|
+
{
|
|
64
|
+
type: "required_status_checks",
|
|
65
|
+
parameters: {
|
|
66
|
+
strict_required_status_checks_policy: false,
|
|
67
|
+
do_not_enforce_on_create: false,
|
|
68
|
+
required_status_checks: [
|
|
69
|
+
{
|
|
70
|
+
context: AGGREGATE_CHECK_JOB_NAME,
|
|
71
|
+
integration_id: GITHUB_ACTIONS_APP_ID,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
bypass_actors: [
|
|
78
|
+
// deploy keys carry no id — the bypass covers every deploy key of
|
|
79
|
+
// the repository (adding one requires admin anyway)
|
|
80
|
+
{ actor_id: null, actor_type: "DeployKey", bypass_mode: "always" },
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* whether a ruleset provides working merge gating: active, requires
|
|
86
|
+
* the aggregate check, and lets the release deploy key bypass it.
|
|
87
|
+
* Extra rules or bypass actors someone added are fine.
|
|
88
|
+
*/
|
|
89
|
+
export const rulesetProvidesMergeGating = (ruleset: GithubRuleset): boolean =>
|
|
90
|
+
ruleset.enforcement === "active" &&
|
|
91
|
+
(ruleset.rules ?? []).some(
|
|
92
|
+
(rule) =>
|
|
93
|
+
rule.type === "required_status_checks" &&
|
|
94
|
+
(rule.parameters?.required_status_checks ?? []).some(
|
|
95
|
+
(check) => check.context === AGGREGATE_CHECK_JOB_NAME,
|
|
96
|
+
),
|
|
97
|
+
) &&
|
|
98
|
+
(ruleset.bypass_actors ?? []).some(
|
|
99
|
+
(actor) =>
|
|
100
|
+
actor.actor_type === "DeployKey" && actor.bypass_mode === "always",
|
|
101
|
+
);
|
|
@@ -40,13 +40,14 @@ export const setupProject = async (
|
|
|
40
40
|
|
|
41
41
|
const enabledPipelines = config ? getEnabledPipelineTypes(config) : [];
|
|
42
42
|
await logSection(instance, "base setup", async () => {
|
|
43
|
-
// gitlab-specific provisioning (access token, agent webhooks
|
|
44
|
-
// meaningless (and failing) on a
|
|
43
|
+
// gitlab-specific provisioning (access token, agent webhooks, the
|
|
44
|
+
// `catladder` project topic) — meaningless (and failing) on a
|
|
45
|
+
// github-only project
|
|
45
46
|
if (enabledPipelines.includes("gitlab")) {
|
|
46
47
|
await setupAccessTokens(instance);
|
|
47
48
|
await setupAgents(instance);
|
|
49
|
+
await setupTopic(instance);
|
|
48
50
|
}
|
|
49
|
-
await setupTopic(instance);
|
|
50
51
|
if (config) {
|
|
51
52
|
await setupGithub(instance, config);
|
|
52
53
|
}
|
|
@@ -4,9 +4,29 @@ import {
|
|
|
4
4
|
getEnabledPipelineTypes,
|
|
5
5
|
getPipelineGitRemote,
|
|
6
6
|
} from "@catladder/pipeline";
|
|
7
|
+
import { execFile as execFileCb } from "child_process";
|
|
8
|
+
import { mkdtemp, readFile, rm } from "fs/promises";
|
|
9
|
+
import { tmpdir } from "os";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import { promisify } from "util";
|
|
7
12
|
import { getConfiguredGithubRepo } from "../../../../../commands/project/commandSecretsSyncGithub";
|
|
8
13
|
import type { IO } from "../../../../../core/types";
|
|
9
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
ghApiJson,
|
|
16
|
+
isGhAuthenticated,
|
|
17
|
+
listGithubSecretNames,
|
|
18
|
+
setGithubSecret,
|
|
19
|
+
} from "../../../../../utils/github";
|
|
20
|
+
import type { GithubRuleset } from "../githubMergeGating";
|
|
21
|
+
import {
|
|
22
|
+
desiredMergeGatingRuleset,
|
|
23
|
+
MERGE_GATING_RULESET_NAME,
|
|
24
|
+
RELEASE_DEPLOY_KEY_SECRET,
|
|
25
|
+
RELEASE_DEPLOY_KEY_TITLE,
|
|
26
|
+
rulesetProvidesMergeGating,
|
|
27
|
+
} from "../githubMergeGating";
|
|
28
|
+
|
|
29
|
+
const execFile = promisify(execFileCb);
|
|
10
30
|
|
|
11
31
|
/**
|
|
12
32
|
* configures github merge gating — the sane default gitlab ships
|
|
@@ -15,15 +35,20 @@ import { ghApiJson, isGhAuthenticated } from "../../../../../utils/github";
|
|
|
15
35
|
* auto-merge button never appears.
|
|
16
36
|
*
|
|
17
37
|
* - repo settings: allow auto-merge, delete merged head branches
|
|
18
|
-
* -
|
|
38
|
+
* - the release deploy key: a write deploy key on the repo, its
|
|
39
|
+
* private half stored as the CATLADDER_RELEASE_KEY actions secret —
|
|
40
|
+
* the release job pushes the release commit and tag with it
|
|
41
|
+
* - a repository ruleset on the default branch requiring the generated
|
|
19
42
|
* `catladder ✅` aggregate check (the one stable context — individual
|
|
20
|
-
* job names change with every component add/rename)
|
|
43
|
+
* job names change with every component add/rename), with a deploy
|
|
44
|
+
* key bypass so the release push goes through (see
|
|
45
|
+
* githubMergeGating.ts for the full rationale)
|
|
21
46
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
47
|
+
* Earlier versions required the check via classic branch protection,
|
|
48
|
+
* which has no bypass list — the release job's direct push to the
|
|
49
|
+
* default branch bounced off catladder's own gating (GH006). Setup
|
|
50
|
+
* migrates that away: the aggregate context is removed from an
|
|
51
|
+
* existing classic rule, everything else in it is preserved.
|
|
27
52
|
*/
|
|
28
53
|
export const setupGithub = async (instance: IO, config: Config) => {
|
|
29
54
|
if (!getEnabledPipelineTypes(config).includes("github")) {
|
|
@@ -59,34 +84,167 @@ export const setupGithub = async (instance: IO, config: Config) => {
|
|
|
59
84
|
instance.log("✅ auto-merge already enabled");
|
|
60
85
|
}
|
|
61
86
|
|
|
87
|
+
await ensureReleaseDeployKey(instance, repo);
|
|
88
|
+
|
|
62
89
|
const branch = repoInfo.default_branch;
|
|
90
|
+
const rulesets = await ghApiJson<Array<{ id: number; name: string }>>(
|
|
91
|
+
"GET",
|
|
92
|
+
`repos/${repo}/rulesets?per_page=100`,
|
|
93
|
+
);
|
|
94
|
+
const existing = rulesets.find((r) => r.name === MERGE_GATING_RULESET_NAME);
|
|
95
|
+
if (!existing) {
|
|
96
|
+
await ghApiJson(
|
|
97
|
+
"POST",
|
|
98
|
+
`repos/${repo}/rulesets`,
|
|
99
|
+
desiredMergeGatingRuleset(),
|
|
100
|
+
);
|
|
101
|
+
instance.log(
|
|
102
|
+
`✅ ruleset '${MERGE_GATING_RULESET_NAME}': '${AGGREGATE_CHECK_JOB_NAME}' required on ${branch} — merges wait for the pipeline, the release job can push`,
|
|
103
|
+
);
|
|
104
|
+
} else {
|
|
105
|
+
const detail = await ghApiJson<GithubRuleset>(
|
|
106
|
+
"GET",
|
|
107
|
+
`repos/${repo}/rulesets/${existing.id}`,
|
|
108
|
+
);
|
|
109
|
+
if (rulesetProvidesMergeGating(detail)) {
|
|
110
|
+
instance.log(
|
|
111
|
+
`✅ ruleset '${MERGE_GATING_RULESET_NAME}' already in place on ${branch}`,
|
|
112
|
+
);
|
|
113
|
+
} else {
|
|
114
|
+
await ghApiJson(
|
|
115
|
+
"PUT",
|
|
116
|
+
`repos/${repo}/rulesets/${existing.id}`,
|
|
117
|
+
desiredMergeGatingRuleset(),
|
|
118
|
+
);
|
|
119
|
+
instance.log(
|
|
120
|
+
`✅ ruleset '${MERGE_GATING_RULESET_NAME}' restored — '${AGGREGATE_CHECK_JOB_NAME}' required, release-job bypass in place`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await migrateAwayFromClassicProtection(instance, repo, branch);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* provisions the release deploy key: an ed25519 keypair whose public
|
|
130
|
+
* half becomes a write deploy key on the repo and whose private half
|
|
131
|
+
* becomes the CATLADDER_RELEASE_KEY actions secret. The key and the
|
|
132
|
+
* secret only work as a pair, so when either half is missing the pair
|
|
133
|
+
* is rotated (github secrets are write-only — the private half can
|
|
134
|
+
* never be read back to check it matches).
|
|
135
|
+
*/
|
|
136
|
+
const ensureReleaseDeployKey = async (instance: IO, repo: string) => {
|
|
137
|
+
const [keys, secretNames] = await Promise.all([
|
|
138
|
+
ghApiJson<Array<{ id: number; title: string; read_only: boolean }>>(
|
|
139
|
+
"GET",
|
|
140
|
+
`repos/${repo}/keys?per_page=100`,
|
|
141
|
+
),
|
|
142
|
+
listGithubSecretNames(repo),
|
|
143
|
+
]);
|
|
144
|
+
const existingKey = keys.find((k) => k.title === RELEASE_DEPLOY_KEY_TITLE);
|
|
145
|
+
const hasSecret = secretNames.some(
|
|
146
|
+
(name) => name.toUpperCase() === RELEASE_DEPLOY_KEY_SECRET,
|
|
147
|
+
);
|
|
148
|
+
if (existingKey && !existingKey.read_only && hasSecret) {
|
|
149
|
+
instance.log(
|
|
150
|
+
`✅ release deploy key '${RELEASE_DEPLOY_KEY_TITLE}' + secret ${RELEASE_DEPLOY_KEY_SECRET} in place`,
|
|
151
|
+
);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (existingKey) {
|
|
155
|
+
await ghApiJson("DELETE", `repos/${repo}/keys/${existingKey.id}`);
|
|
156
|
+
instance.log(
|
|
157
|
+
`♻️ release deploy key '${RELEASE_DEPLOY_KEY_TITLE}' rotated (${
|
|
158
|
+
existingKey.read_only
|
|
159
|
+
? "was read-only"
|
|
160
|
+
: `secret ${RELEASE_DEPLOY_KEY_SECRET} missing`
|
|
161
|
+
})`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const dir = await mkdtemp(join(tmpdir(), "catladder-release-key-"));
|
|
166
|
+
try {
|
|
167
|
+
const keyFile = join(dir, "id_ed25519");
|
|
168
|
+
await execFile("ssh-keygen", [
|
|
169
|
+
...["-t", "ed25519"],
|
|
170
|
+
...["-C", `${RELEASE_DEPLOY_KEY_TITLE} (${repo})`],
|
|
171
|
+
...["-N", ""],
|
|
172
|
+
...["-f", keyFile],
|
|
173
|
+
"-q",
|
|
174
|
+
]);
|
|
175
|
+
const [privateKey, publicKey] = await Promise.all([
|
|
176
|
+
readFile(keyFile, "utf-8"),
|
|
177
|
+
readFile(`${keyFile}.pub`, "utf-8"),
|
|
178
|
+
]);
|
|
179
|
+
await ghApiJson("POST", `repos/${repo}/keys`, {
|
|
180
|
+
title: RELEASE_DEPLOY_KEY_TITLE,
|
|
181
|
+
key: publicKey.trim(),
|
|
182
|
+
read_only: false,
|
|
183
|
+
});
|
|
184
|
+
await setGithubSecret(repo, RELEASE_DEPLOY_KEY_SECRET, privateKey);
|
|
185
|
+
} finally {
|
|
186
|
+
await rm(dir, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
instance.log(
|
|
189
|
+
`✅ release deploy key '${RELEASE_DEPLOY_KEY_TITLE}' registered, private half stored as secret ${RELEASE_DEPLOY_KEY_SECRET} — the release job pushes with it`,
|
|
190
|
+
);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* removes the aggregate check from a classic branch protection rule
|
|
195
|
+
* set up by earlier catladder versions — with the check now required
|
|
196
|
+
* via the ruleset, the classic one would still reject the release
|
|
197
|
+
* job's push. Everything else in the rule is preserved; the whole rule
|
|
198
|
+
* is deleted when the check was all it carried.
|
|
199
|
+
*/
|
|
200
|
+
const migrateAwayFromClassicProtection = async (
|
|
201
|
+
instance: IO,
|
|
202
|
+
repo: string,
|
|
203
|
+
branch: string,
|
|
204
|
+
) => {
|
|
63
205
|
const protection = await ghApiJson<any>(
|
|
64
206
|
"GET",
|
|
65
207
|
`repos/${repo}/branches/${branch}/protection`,
|
|
66
|
-
).catch(() => null); // 404: branch not protected
|
|
67
|
-
|
|
68
|
-
const existingChecks: Array<{ context: string; app_id?: number }> =
|
|
208
|
+
).catch(() => null); // 404: branch not protected
|
|
209
|
+
const checks: Array<{ context: string; app_id?: number }> =
|
|
69
210
|
protection?.required_status_checks?.checks ?? [];
|
|
70
|
-
if (
|
|
211
|
+
if (!checks.some((c) => c.context === AGGREGATE_CHECK_JOB_NAME)) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const remaining = checks.filter(
|
|
215
|
+
(c) => c.context !== AGGREGATE_CHECK_JOB_NAME,
|
|
216
|
+
);
|
|
217
|
+
const carriesAnythingElse =
|
|
218
|
+
remaining.length > 0 ||
|
|
219
|
+
protection?.enforce_admins?.enabled ||
|
|
220
|
+
protection?.required_pull_request_reviews ||
|
|
221
|
+
protection?.restrictions ||
|
|
222
|
+
protection?.required_linear_history?.enabled ||
|
|
223
|
+
protection?.required_conversation_resolution?.enabled ||
|
|
224
|
+
protection?.required_signatures?.enabled ||
|
|
225
|
+
protection?.lock_branch?.enabled ||
|
|
226
|
+
protection?.allow_force_pushes?.enabled ||
|
|
227
|
+
protection?.allow_deletions?.enabled;
|
|
228
|
+
if (!carriesAnythingElse) {
|
|
229
|
+
await ghApiJson("DELETE", `repos/${repo}/branches/${branch}/protection`);
|
|
71
230
|
instance.log(
|
|
72
|
-
`✅ '${AGGREGATE_CHECK_JOB_NAME}'
|
|
231
|
+
`✅ classic branch protection on ${branch} removed — '${AGGREGATE_CHECK_JOB_NAME}' was all it carried, and it is now required via the ruleset`,
|
|
73
232
|
);
|
|
74
233
|
return;
|
|
75
234
|
}
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
// so every preserved setting must be carried over explicitly
|
|
235
|
+
// PUT replaces the whole rule, so every preserved setting must be
|
|
236
|
+
// carried over explicitly
|
|
79
237
|
await ghApiJson("PUT", `repos/${repo}/branches/${branch}/protection`, {
|
|
80
|
-
required_status_checks:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
238
|
+
required_status_checks:
|
|
239
|
+
remaining.length > 0
|
|
240
|
+
? {
|
|
241
|
+
strict: protection?.required_status_checks?.strict ?? false,
|
|
242
|
+
checks: remaining.map(({ context, app_id }) => ({
|
|
243
|
+
context,
|
|
244
|
+
...(app_id !== undefined ? { app_id } : {}),
|
|
245
|
+
})),
|
|
246
|
+
}
|
|
247
|
+
: null,
|
|
90
248
|
enforce_admins: protection?.enforce_admins?.enabled ?? false,
|
|
91
249
|
required_pull_request_reviews: protection?.required_pull_request_reviews
|
|
92
250
|
? {
|
|
@@ -108,10 +266,15 @@ export const setupGithub = async (instance: IO, config: Config) => {
|
|
|
108
266
|
apps: protection.restrictions.apps.map((a: any) => a.slug),
|
|
109
267
|
}
|
|
110
268
|
: null,
|
|
269
|
+
required_linear_history:
|
|
270
|
+
protection?.required_linear_history?.enabled ?? false,
|
|
271
|
+
required_conversation_resolution:
|
|
272
|
+
protection?.required_conversation_resolution?.enabled ?? false,
|
|
273
|
+
lock_branch: protection?.lock_branch?.enabled ?? false,
|
|
111
274
|
allow_force_pushes: protection?.allow_force_pushes?.enabled ?? false,
|
|
112
275
|
allow_deletions: protection?.allow_deletions?.enabled ?? false,
|
|
113
276
|
});
|
|
114
277
|
instance.log(
|
|
115
|
-
`✅ branch protection on ${branch}
|
|
278
|
+
`✅ '${AGGREGATE_CHECK_JOB_NAME}' removed from classic branch protection on ${branch} — now required via the ruleset; the rest of the rule is preserved`,
|
|
116
279
|
);
|
|
117
280
|
};
|