@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.
Files changed (36) hide show
  1. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithub.js +48 -9
  2. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithub.js.map +1 -1
  3. package/dist/apps/cli/src/apps/cli/commands/project/githubMergeGating.d.ts +83 -0
  4. package/dist/apps/cli/src/apps/cli/commands/project/githubMergeGating.js +80 -0
  5. package/dist/apps/cli/src/apps/cli/commands/project/githubMergeGating.js.map +1 -0
  6. package/dist/apps/cli/src/apps/cli/commands/project/setup/index.js +4 -3
  7. package/dist/apps/cli/src/apps/cli/commands/project/setup/index.js.map +1 -1
  8. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.d.ts +12 -7
  9. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js +138 -31
  10. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js.map +1 -1
  11. package/dist/apps/cli/src/release/changesetsReleaseJob.js +64 -4
  12. package/dist/apps/cli/src/release/changesetsReleaseJob.js.map +1 -1
  13. package/dist/apps/cli/src/release/releaseGit.d.ts +2 -0
  14. package/dist/apps/cli/src/release/releaseGit.js +9 -1
  15. package/dist/apps/cli/src/release/releaseGit.js.map +1 -1
  16. package/dist/apps/cli/src/utils/github.d.ts +1 -1
  17. package/dist/apps/cli/src/utils/github.js.map +1 -1
  18. package/dist/bundles/catci/index.js +5 -5
  19. package/dist/bundles/catenv/index.js +5 -0
  20. package/dist/bundles/cli/index.js +282 -43
  21. package/dist/bundles/runner-images/changesets/Dockerfile +2 -1
  22. package/dist/bundles/skills/catladder-migrate-ci-backend/SKILL.md +15 -7
  23. package/dist/packages/pipeline/src/backends/github/githubReleaseJobs.js +5 -0
  24. package/dist/packages/pipeline/src/backends/github/githubReleaseJobs.js.map +1 -1
  25. package/dist/runner-images/changesets/Dockerfile +2 -1
  26. package/dist/skills/catladder-migrate-ci-backend/SKILL.md +15 -7
  27. package/dist/tsconfig.tsbuildinfo +1 -1
  28. package/package.json +1 -1
  29. package/src/apps/cli/commands/project/__tests__/githubMergeGating.test.ts +123 -0
  30. package/src/apps/cli/commands/project/doctor/checkGithub.ts +74 -8
  31. package/src/apps/cli/commands/project/githubMergeGating.ts +101 -0
  32. package/src/apps/cli/commands/project/setup/index.ts +4 -3
  33. package/src/apps/cli/commands/project/setup/setupGithub.ts +190 -27
  34. package/src/release/changesetsReleaseJob.ts +81 -6
  35. package/src/release/releaseGit.ts +11 -0
  36. package/src/utils/github.ts +1 -1
package/package.json CHANGED
@@ -53,7 +53,7 @@
53
53
  }
54
54
  ],
55
55
  "license": "MIT",
56
- "version": "0.0.0-main-a6b45abe",
56
+ "version": "0.0.0-main-fd9c5f51",
57
57
  "scripts": {
58
58
  "lint": "eslint \"src/**/*.ts\"",
59
59
  "lint:fix": "eslint \"src/**/*.ts\" --fix",
@@ -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 generated `catladder ✅` aggregate check required on
165
- * the default branch. Without it a PR is mergeable while its checks
166
- * are still running — gitlab ships this behavior built-in, github
167
- * silently does not.
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 not a required check on ${branch} — PRs can merge while the pipeline is red or still running`,
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 github-only project
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 { ghApiJson, isGhAuthenticated } from "../../../../../utils/github";
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
- * - branch protection on the default branch: require the generated
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
- * Deliberately non-destructive: existing protection settings
23
- * (enforce-admins, required reviews, restrictions, strictness) are
24
- * preserved; the aggregate context is merged into the required checks
25
- * rather than replacing them. Admins can still bypass per PR — this is
26
- * a default, not a cage.
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 yet
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 (existingChecks.some((c) => c.context === AGGREGATE_CHECK_JOB_NAME)) {
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}' already required on ${branch}`,
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
- // merge into whatever protection exists; PUT replaces the whole rule,
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
- strict: protection?.required_status_checks?.strict ?? false,
82
- checks: [
83
- ...existingChecks.map(({ context, app_id }) => ({
84
- context,
85
- ...(app_id !== undefined ? { app_id } : {}),
86
- })),
87
- { context: AGGREGATE_CHECK_JOB_NAME },
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}: '${AGGREGATE_CHECK_JOB_NAME}' required (merges now wait for the pipeline)`,
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
  };