@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.
- package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithub.js +31 -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 +70 -0
- package/dist/apps/cli/src/apps/cli/commands/project/githubMergeGating.js +71 -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/setupGithub.d.ts +9 -7
- package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js +77 -31
- package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js.map +1 -1
- package/dist/apps/cli/src/release/changesetsReleaseJob.js +19 -1
- package/dist/apps/cli/src/release/changesetsReleaseJob.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 +1 -1
- package/dist/bundles/cli/index.js +186 -40
- package/dist/bundles/skills/catladder-migrate-ci-backend/SKILL.md +11 -7
- package/dist/skills/catladder-migrate-ci-backend/SKILL.md +11 -7
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/apps/cli/commands/project/__tests__/githubMergeGating.test.ts +112 -0
- package/src/apps/cli/commands/project/doctor/checkGithub.ts +44 -8
- package/src/apps/cli/commands/project/githubMergeGating.ts +90 -0
- package/src/apps/cli/commands/project/setup/setupGithub.ts +105 -26
- package/src/release/changesetsReleaseJob.ts +19 -1
- package/src/utils/github.ts +1 -1
package/package.json
CHANGED
|
@@ -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
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
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
|
|
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
|
-
* -
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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 (
|
|
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}'
|
|
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
|
-
//
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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}
|
|
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
|
-
|
|
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");
|
package/src/utils/github.ts
CHANGED
|
@@ -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> => {
|