@catladder/cli 0.0.0-main-a6b45abe → 0.0.0-main-bd7c0346

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 (24) hide show
  1. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithub.js +31 -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 +70 -0
  4. package/dist/apps/cli/src/apps/cli/commands/project/githubMergeGating.js +71 -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/setupGithub.d.ts +9 -7
  7. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js +77 -31
  8. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js.map +1 -1
  9. package/dist/apps/cli/src/release/changesetsReleaseJob.js +19 -1
  10. package/dist/apps/cli/src/release/changesetsReleaseJob.js.map +1 -1
  11. package/dist/apps/cli/src/utils/github.d.ts +1 -1
  12. package/dist/apps/cli/src/utils/github.js.map +1 -1
  13. package/dist/bundles/catci/index.js +1 -1
  14. package/dist/bundles/cli/index.js +186 -40
  15. package/dist/bundles/skills/catladder-migrate-ci-backend/SKILL.md +11 -7
  16. package/dist/skills/catladder-migrate-ci-backend/SKILL.md +11 -7
  17. package/dist/tsconfig.tsbuildinfo +1 -1
  18. package/package.json +1 -1
  19. package/src/apps/cli/commands/project/__tests__/githubMergeGating.test.ts +112 -0
  20. package/src/apps/cli/commands/project/doctor/checkGithub.ts +44 -8
  21. package/src/apps/cli/commands/project/githubMergeGating.ts +90 -0
  22. package/src/apps/cli/commands/project/setup/setupGithub.ts +105 -26
  23. package/src/release/changesetsReleaseJob.ts +19 -1
  24. 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-bd7c0346",
57
57
  "scripts": {
58
58
  "lint": "eslint \"src/**/*.ts\"",
59
59
  "lint:fix": "eslint \"src/**/*.ts\" --fix",
@@ -0,0 +1,112 @@
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 the release job (actions app) bypass the gating", () => {
36
+ expect(desiredMergeGatingRuleset().bypass_actors).toEqual([
37
+ {
38
+ actor_id: GITHUB_ACTIONS_APP_ID,
39
+ actor_type: "Integration",
40
+ bypass_mode: "always",
41
+ },
42
+ ]);
43
+ });
44
+
45
+ it("satisfies its own predicate", () => {
46
+ expect(rulesetProvidesMergeGating(asRuleset())).toBe(true);
47
+ });
48
+ });
49
+
50
+ describe("rulesetProvidesMergeGating", () => {
51
+ it("rejects a disabled ruleset", () => {
52
+ expect(
53
+ rulesetProvidesMergeGating(asRuleset({ enforcement: "disabled" })),
54
+ ).toBe(false);
55
+ });
56
+
57
+ it("rejects a ruleset without the aggregate check", () => {
58
+ expect(rulesetProvidesMergeGating(asRuleset({ rules: [] }))).toBe(false);
59
+ expect(
60
+ rulesetProvidesMergeGating(
61
+ asRuleset({
62
+ rules: [
63
+ {
64
+ type: "required_status_checks",
65
+ parameters: {
66
+ required_status_checks: [{ context: "some other check" }],
67
+ },
68
+ },
69
+ ],
70
+ }),
71
+ ),
72
+ ).toBe(false);
73
+ });
74
+
75
+ it("rejects a ruleset whose release-job bypass is missing or weakened", () => {
76
+ expect(rulesetProvidesMergeGating(asRuleset({ bypass_actors: [] }))).toBe(
77
+ false,
78
+ );
79
+ expect(
80
+ rulesetProvidesMergeGating(
81
+ asRuleset({
82
+ bypass_actors: [
83
+ {
84
+ actor_id: GITHUB_ACTIONS_APP_ID,
85
+ actor_type: "Integration",
86
+ bypass_mode: "pull_request",
87
+ },
88
+ ],
89
+ }),
90
+ ),
91
+ ).toBe(false);
92
+ });
93
+
94
+ it("accepts extra rules and bypass actors alongside ours", () => {
95
+ const desired = desiredMergeGatingRuleset();
96
+ expect(
97
+ rulesetProvidesMergeGating(
98
+ asRuleset({
99
+ rules: [{ type: "deletion" }, ...desired.rules],
100
+ bypass_actors: [
101
+ {
102
+ actor_id: 5,
103
+ actor_type: "RepositoryRole",
104
+ bypass_mode: "always",
105
+ },
106
+ ...desired.bypass_actors,
107
+ ],
108
+ }),
109
+ ),
110
+ ).toBe(true);
111
+ });
112
+ });
@@ -16,6 +16,11 @@ 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
+ rulesetProvidesMergeGating,
23
+ } from "../githubMergeGating";
19
24
  import type { DoctorReport } from "./DoctorReport";
20
25
 
21
26
  /** marker for repo-level (environment-less) targets, see collectSecretTargets */
@@ -161,10 +166,13 @@ export const checkGithub = async (report: DoctorReport, config: Config) => {
161
166
 
162
167
  /**
163
168
  * 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.
169
+ * enabled and the `catladder merge gating` ruleset requiring the
170
+ * generated `catladder ✅` aggregate check on the default branch (with
171
+ * the bypass that lets the release job push). Without the check a PR
172
+ * is mergeable while its checks are still running — gitlab ships this
173
+ * behavior built-in, github silently does not. The check required via
174
+ * classic branch protection instead is flagged: that setup rejects the
175
+ * release job's own push (GH006).
168
176
  */
169
177
  const checkGithubMergeGating = async (report: DoctorReport, repo: string) => {
170
178
  let repoInfo: { default_branch: string; allow_auto_merge: boolean };
@@ -184,6 +192,36 @@ const checkGithubMergeGating = async (report: DoctorReport, repo: string) => {
184
192
  }
185
193
 
186
194
  const branch = repoInfo.default_branch;
195
+ let rulesets: Array<{ id: number; name: string }>;
196
+ try {
197
+ rulesets = await ghApiJson("GET", `repos/${repo}/rulesets?per_page=100`);
198
+ } catch (e) {
199
+ report.warn(`could not list rulesets of ${repo} (${e.message})`);
200
+ return;
201
+ }
202
+ const named = rulesets.find((r) => r.name === MERGE_GATING_RULESET_NAME);
203
+ const ruleset = named
204
+ ? await ghApiJson<GithubRuleset>(
205
+ "GET",
206
+ `repos/${repo}/rulesets/${named.id}`,
207
+ ).catch(() => null)
208
+ : null;
209
+ if (ruleset && rulesetProvidesMergeGating(ruleset)) {
210
+ report.ok(
211
+ `ruleset '${MERGE_GATING_RULESET_NAME}': '${AGGREGATE_CHECK_JOB_NAME}' required on ${branch}, release job can push`,
212
+ );
213
+ } else if (ruleset) {
214
+ report.fail(
215
+ `ruleset '${MERGE_GATING_RULESET_NAME}' exists but is incomplete (inactive, check not required, or release-job bypass missing)`,
216
+ "run: catladder project setup",
217
+ );
218
+ } else {
219
+ report.fail(
220
+ `no '${MERGE_GATING_RULESET_NAME}' ruleset on ${repo} — PRs can merge while the pipeline is red or still running`,
221
+ "run: catladder project setup",
222
+ );
223
+ }
224
+
187
225
  const protection = await ghApiJson<any>(
188
226
  "GET",
189
227
  `repos/${repo}/branches/${branch}/protection`,
@@ -191,17 +229,15 @@ const checkGithubMergeGating = async (report: DoctorReport, repo: string) => {
191
229
  const checks: Array<{ context: string }> =
192
230
  protection?.required_status_checks?.checks ?? [];
193
231
  if (checks.some((c) => c.context === AGGREGATE_CHECK_JOB_NAME)) {
194
- report.ok(`'${AGGREGATE_CHECK_JOB_NAME}' required on ${branch}`);
195
- } else {
196
232
  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`,
233
+ `'${AGGREGATE_CHECK_JOB_NAME}' is still required via classic branch protection on ${branch} — that rejects the release job's push (GH006)`,
198
234
  "run: catladder project setup",
199
235
  );
200
236
  }
201
237
  const stale = checks.filter((c) => c.context !== AGGREGATE_CHECK_JOB_NAME);
202
238
  if (stale.length > 0) {
203
239
  report.warn(
204
- `${branch} requires ${stale.length} additional named check(s) (${stale
240
+ `${branch} requires ${stale.length} additional named check(s) via classic branch protection (${stale
205
241
  .map((c) => c.context)
206
242
  .join(
207
243
  ", ",
@@ -0,0 +1,90 @@
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 with the workflow token — under classic protection
11
+ * the required `catladder ✅` check rejects that push (GH006), because
12
+ * the fresh release commit cannot have a passing check yet. The bypass
13
+ * covers the GitHub Actions app, i.e. pushes made by workflows, while
14
+ * PRs and humans stay gated. (Gitlab's counterpart is the
15
+ * maintainer-role project access token, which may push to protected
16
+ * branches by default — the same trade-off, made by the platform.)
17
+ */
18
+
19
+ /** the app id of GitHub Actions — the actor workflow tokens act as */
20
+ export const GITHUB_ACTIONS_APP_ID = 15368;
21
+
22
+ export const MERGE_GATING_RULESET_NAME = "catladder merge gating";
23
+
24
+ export type GithubRuleset = {
25
+ id: number;
26
+ name: string;
27
+ enforcement: string;
28
+ rules?: Array<{
29
+ type: string;
30
+ parameters?: {
31
+ required_status_checks?: Array<{ context: string }>;
32
+ };
33
+ }>;
34
+ bypass_actors?: Array<{
35
+ actor_id?: number | null;
36
+ actor_type: string;
37
+ bypass_mode: string;
38
+ }>;
39
+ };
40
+
41
+ /** the ruleset `project setup` creates (and restores on drift) */
42
+ export const desiredMergeGatingRuleset = () => ({
43
+ name: MERGE_GATING_RULESET_NAME,
44
+ target: "branch",
45
+ enforcement: "active",
46
+ conditions: { ref_name: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
47
+ rules: [
48
+ {
49
+ type: "required_status_checks",
50
+ parameters: {
51
+ strict_required_status_checks_policy: false,
52
+ do_not_enforce_on_create: false,
53
+ required_status_checks: [
54
+ {
55
+ context: AGGREGATE_CHECK_JOB_NAME,
56
+ integration_id: GITHUB_ACTIONS_APP_ID,
57
+ },
58
+ ],
59
+ },
60
+ },
61
+ ],
62
+ bypass_actors: [
63
+ {
64
+ actor_id: GITHUB_ACTIONS_APP_ID,
65
+ actor_type: "Integration",
66
+ bypass_mode: "always",
67
+ },
68
+ ],
69
+ });
70
+
71
+ /**
72
+ * whether a ruleset provides working merge gating: active, requires
73
+ * the aggregate check, and lets the release job (GitHub Actions app)
74
+ * bypass it. Extra rules or bypass actors someone added are fine.
75
+ */
76
+ export const rulesetProvidesMergeGating = (ruleset: GithubRuleset): boolean =>
77
+ ruleset.enforcement === "active" &&
78
+ (ruleset.rules ?? []).some(
79
+ (rule) =>
80
+ rule.type === "required_status_checks" &&
81
+ (rule.parameters?.required_status_checks ?? []).some(
82
+ (check) => check.context === AGGREGATE_CHECK_JOB_NAME,
83
+ ),
84
+ ) &&
85
+ (ruleset.bypass_actors ?? []).some(
86
+ (actor) =>
87
+ actor.actor_type === "Integration" &&
88
+ actor.actor_id === GITHUB_ACTIONS_APP_ID &&
89
+ actor.bypass_mode === "always",
90
+ );
@@ -7,6 +7,12 @@ import {
7
7
  import { getConfiguredGithubRepo } from "../../../../../commands/project/commandSecretsSyncGithub";
8
8
  import type { IO } from "../../../../../core/types";
9
9
  import { ghApiJson, isGhAuthenticated } from "../../../../../utils/github";
10
+ import type { GithubRuleset } from "../githubMergeGating";
11
+ import {
12
+ desiredMergeGatingRuleset,
13
+ MERGE_GATING_RULESET_NAME,
14
+ rulesetProvidesMergeGating,
15
+ } from "../githubMergeGating";
10
16
 
11
17
  /**
12
18
  * configures github merge gating — the sane default gitlab ships
@@ -15,15 +21,17 @@ import { ghApiJson, isGhAuthenticated } from "../../../../../utils/github";
15
21
  * auto-merge button never appears.
16
22
  *
17
23
  * - repo settings: allow auto-merge, delete merged head branches
18
- * - branch protection on the default branch: require the generated
24
+ * - a repository ruleset on the default branch requiring the generated
19
25
  * `catladder ✅` aggregate check (the one stable context — individual
20
- * job names change with every component add/rename)
26
+ * job names change with every component add/rename), with a bypass
27
+ * for the GitHub Actions app so the release job can push the release
28
+ * commit and tag (see githubMergeGating.ts for the full rationale)
21
29
  *
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.
30
+ * Earlier versions required the check via classic branch protection,
31
+ * which has no bypass list — the release job's direct push to the
32
+ * default branch bounced off catladder's own gating (GH006). Setup
33
+ * migrates that away: the aggregate context is removed from an
34
+ * existing classic rule, everything else in it is preserved.
27
35
  */
28
36
  export const setupGithub = async (instance: IO, config: Config) => {
29
37
  if (!getEnabledPipelineTypes(config).includes("github")) {
@@ -60,33 +68,99 @@ export const setupGithub = async (instance: IO, config: Config) => {
60
68
  }
61
69
 
62
70
  const branch = repoInfo.default_branch;
71
+ const rulesets = await ghApiJson<Array<{ id: number; name: string }>>(
72
+ "GET",
73
+ `repos/${repo}/rulesets?per_page=100`,
74
+ );
75
+ const existing = rulesets.find((r) => r.name === MERGE_GATING_RULESET_NAME);
76
+ if (!existing) {
77
+ await ghApiJson(
78
+ "POST",
79
+ `repos/${repo}/rulesets`,
80
+ desiredMergeGatingRuleset(),
81
+ );
82
+ instance.log(
83
+ `✅ ruleset '${MERGE_GATING_RULESET_NAME}': '${AGGREGATE_CHECK_JOB_NAME}' required on ${branch} — merges wait for the pipeline, the release job can push`,
84
+ );
85
+ } else {
86
+ const detail = await ghApiJson<GithubRuleset>(
87
+ "GET",
88
+ `repos/${repo}/rulesets/${existing.id}`,
89
+ );
90
+ if (rulesetProvidesMergeGating(detail)) {
91
+ instance.log(
92
+ `✅ ruleset '${MERGE_GATING_RULESET_NAME}' already in place on ${branch}`,
93
+ );
94
+ } else {
95
+ await ghApiJson(
96
+ "PUT",
97
+ `repos/${repo}/rulesets/${existing.id}`,
98
+ desiredMergeGatingRuleset(),
99
+ );
100
+ instance.log(
101
+ `✅ ruleset '${MERGE_GATING_RULESET_NAME}' restored — '${AGGREGATE_CHECK_JOB_NAME}' required, release-job bypass in place`,
102
+ );
103
+ }
104
+ }
105
+
106
+ await migrateAwayFromClassicProtection(instance, repo, branch);
107
+ };
108
+
109
+ /**
110
+ * removes the aggregate check from a classic branch protection rule
111
+ * set up by earlier catladder versions — with the check now required
112
+ * via the ruleset, the classic one would still reject the release
113
+ * job's push. Everything else in the rule is preserved; the whole rule
114
+ * is deleted when the check was all it carried.
115
+ */
116
+ const migrateAwayFromClassicProtection = async (
117
+ instance: IO,
118
+ repo: string,
119
+ branch: string,
120
+ ) => {
63
121
  const protection = await ghApiJson<any>(
64
122
  "GET",
65
123
  `repos/${repo}/branches/${branch}/protection`,
66
- ).catch(() => null); // 404: branch not protected yet
67
-
68
- const existingChecks: Array<{ context: string; app_id?: number }> =
124
+ ).catch(() => null); // 404: branch not protected
125
+ const checks: Array<{ context: string; app_id?: number }> =
69
126
  protection?.required_status_checks?.checks ?? [];
70
- if (existingChecks.some((c) => c.context === AGGREGATE_CHECK_JOB_NAME)) {
127
+ if (!checks.some((c) => c.context === AGGREGATE_CHECK_JOB_NAME)) {
128
+ return;
129
+ }
130
+ const remaining = checks.filter(
131
+ (c) => c.context !== AGGREGATE_CHECK_JOB_NAME,
132
+ );
133
+ const carriesAnythingElse =
134
+ remaining.length > 0 ||
135
+ protection?.enforce_admins?.enabled ||
136
+ protection?.required_pull_request_reviews ||
137
+ protection?.restrictions ||
138
+ protection?.required_linear_history?.enabled ||
139
+ protection?.required_conversation_resolution?.enabled ||
140
+ protection?.required_signatures?.enabled ||
141
+ protection?.lock_branch?.enabled ||
142
+ protection?.allow_force_pushes?.enabled ||
143
+ protection?.allow_deletions?.enabled;
144
+ if (!carriesAnythingElse) {
145
+ await ghApiJson("DELETE", `repos/${repo}/branches/${branch}/protection`);
71
146
  instance.log(
72
- `✅ '${AGGREGATE_CHECK_JOB_NAME}' already required on ${branch}`,
147
+ `✅ classic branch protection on ${branch} removed — '${AGGREGATE_CHECK_JOB_NAME}' was all it carried, and it is now required via the ruleset`,
73
148
  );
74
149
  return;
75
150
  }
76
-
77
- // merge into whatever protection exists; PUT replaces the whole rule,
78
- // so every preserved setting must be carried over explicitly
151
+ // PUT replaces the whole rule, so every preserved setting must be
152
+ // carried over explicitly
79
153
  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
- },
154
+ required_status_checks:
155
+ remaining.length > 0
156
+ ? {
157
+ strict: protection?.required_status_checks?.strict ?? false,
158
+ checks: remaining.map(({ context, app_id }) => ({
159
+ context,
160
+ ...(app_id !== undefined ? { app_id } : {}),
161
+ })),
162
+ }
163
+ : null,
90
164
  enforce_admins: protection?.enforce_admins?.enabled ?? false,
91
165
  required_pull_request_reviews: protection?.required_pull_request_reviews
92
166
  ? {
@@ -108,10 +182,15 @@ export const setupGithub = async (instance: IO, config: Config) => {
108
182
  apps: protection.restrictions.apps.map((a: any) => a.slug),
109
183
  }
110
184
  : null,
185
+ required_linear_history:
186
+ protection?.required_linear_history?.enabled ?? false,
187
+ required_conversation_resolution:
188
+ protection?.required_conversation_resolution?.enabled ?? false,
189
+ lock_branch: protection?.lock_branch?.enabled ?? false,
111
190
  allow_force_pushes: protection?.allow_force_pushes?.enabled ?? false,
112
191
  allow_deletions: protection?.allow_deletions?.enabled ?? false,
113
192
  });
114
193
  instance.log(
115
- `✅ branch protection on ${branch}: '${AGGREGATE_CHECK_JOB_NAME}' required (merges now wait for the pipeline)`,
194
+ `✅ '${AGGREGATE_CHECK_JOB_NAME}' removed from classic branch protection on ${branch} — now required via the ruleset; the rest of the rule is preserved`,
116
195
  );
117
196
  };
@@ -84,7 +84,25 @@ const pushCommitAndTag = async (tag: string) => {
84
84
  // rederived an existing version)
85
85
  if (process.env.GITHUB_ACTIONS === "true") {
86
86
  const branch = requireEnv("GITHUB_REF_NAME");
87
- await git("push", "--atomic", "origin", `HEAD:refs/heads/${branch}`, tag);
87
+ try {
88
+ await git("push", "--atomic", "origin", `HEAD:refs/heads/${branch}`, tag);
89
+ } catch (e) {
90
+ // GH006: classic branch protection with a required status check
91
+ // rejects the fresh release commit (it cannot have a passing
92
+ // check yet) — merge gating must live in a ruleset with a bypass
93
+ // for the actions app, which `project setup` configures
94
+ if (/GH006|protected branch/i.test(`${e?.message ?? e}`)) {
95
+ throw new Error(
96
+ `pushing the release to '${branch}' was rejected by its branch protection:\n` +
97
+ `${e.message}\n` +
98
+ `The release job pushes the release commit and tag with the workflow token. ` +
99
+ `Required status checks on '${branch}' must live in a repository ruleset with a ` +
100
+ `bypass for GitHub Actions — classic branch protection cannot allow that push. ` +
101
+ `Run \`catladder project setup\` to migrate the merge gating, then rerun this job.`,
102
+ );
103
+ }
104
+ throw e;
105
+ }
88
106
  return;
89
107
  }
90
108
  const token = requireEnv("GL_TOKEN");
@@ -170,7 +170,7 @@ export const ensureGithubEnvironment = async (
170
170
 
171
171
  /** runs `gh api` with an optional JSON body and returns the parsed response */
172
172
  export const ghApiJson = async <T = any>(
173
- method: "GET" | "PATCH" | "PUT",
173
+ method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE",
174
174
  path: string,
175
175
  body?: unknown,
176
176
  ): Promise<T> => {