@catladder/cli 0.0.0-next-e95e1f20 → 0.0.0-next-ca8f99e2
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 +40 -0
- package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithub.js.map +1 -1
- package/dist/apps/cli/src/apps/cli/commands/project/setup/index.js +12 -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 +20 -0
- package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js +92 -0
- package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js.map +1 -0
- package/dist/apps/cli/src/utils/github.d.ts +2 -0
- package/dist/apps/cli/src/utils/github.js +24 -1
- package/dist/apps/cli/src/utils/github.js.map +1 -1
- package/dist/bundles/catenv/index.js +72 -1
- package/dist/bundles/cli/index.js +247 -5
- package/dist/bundles/skills/catladder-migrate-ci-backend/SKILL.md +12 -0
- package/dist/packages/pipeline/src/backends/github/GithubBackend.js +12 -1
- package/dist/packages/pipeline/src/backends/github/GithubBackend.js.map +1 -1
- package/dist/packages/pipeline/src/backends/github/aggregateCheckJob.d.ts +26 -0
- package/dist/packages/pipeline/src/backends/github/aggregateCheckJob.js +52 -0
- package/dist/packages/pipeline/src/backends/github/aggregateCheckJob.js.map +1 -0
- package/dist/packages/pipeline/src/backends/github/index.d.ts +1 -0
- package/dist/packages/pipeline/src/backends/github/index.js +1 -0
- package/dist/packages/pipeline/src/backends/github/index.js.map +1 -1
- package/dist/skills/catladder-migrate-ci-backend/SKILL.md +12 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/apps/cli/commands/project/doctor/checkGithub.ts +55 -0
- package/src/apps/cli/commands/project/setup/index.ts +17 -4
- package/src/apps/cli/commands/project/setup/setupGithub.ts +117 -0
- package/src/utils/github.ts +30 -0
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Config } from "@catladder/pipeline";
|
|
2
2
|
import {
|
|
3
|
+
AGGREGATE_CHECK_JOB_NAME,
|
|
3
4
|
getEnabledPipelineTypes,
|
|
4
5
|
getPipelineGitRemote,
|
|
5
6
|
} from "@catladder/pipeline";
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
} from "../../../../../commands/project/commandSecretsSyncGithub";
|
|
10
11
|
import { mapWithConcurrency } from "../../../../../utils/concurrency";
|
|
11
12
|
import {
|
|
13
|
+
ghApiJson,
|
|
12
14
|
isGhAuthenticated,
|
|
13
15
|
listGithubEnvironments,
|
|
14
16
|
listGithubSecretNames,
|
|
@@ -153,4 +155,57 @@ export const checkGithub = async (report: DoctorReport, config: Config) => {
|
|
|
153
155
|
);
|
|
154
156
|
}
|
|
155
157
|
}
|
|
158
|
+
|
|
159
|
+
await checkGithubMergeGating(report, repo);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 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.
|
|
168
|
+
*/
|
|
169
|
+
const checkGithubMergeGating = async (report: DoctorReport, repo: string) => {
|
|
170
|
+
let repoInfo: { default_branch: string; allow_auto_merge: boolean };
|
|
171
|
+
try {
|
|
172
|
+
repoInfo = await ghApiJson("GET", `repos/${repo}`);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
report.warn(`could not read repo settings of ${repo} (${e.message})`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (repoInfo.allow_auto_merge) {
|
|
178
|
+
report.ok("auto-merge enabled");
|
|
179
|
+
} else {
|
|
180
|
+
report.fail(
|
|
181
|
+
"auto-merge disabled — the merge-when-pipeline-succeeds button cannot appear",
|
|
182
|
+
"run: catladder project setup",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const branch = repoInfo.default_branch;
|
|
187
|
+
const protection = await ghApiJson<any>(
|
|
188
|
+
"GET",
|
|
189
|
+
`repos/${repo}/branches/${branch}/protection`,
|
|
190
|
+
).catch(() => null);
|
|
191
|
+
const checks: Array<{ context: string }> =
|
|
192
|
+
protection?.required_status_checks?.checks ?? [];
|
|
193
|
+
if (checks.some((c) => c.context === AGGREGATE_CHECK_JOB_NAME)) {
|
|
194
|
+
report.ok(`'${AGGREGATE_CHECK_JOB_NAME}' required on ${branch}`);
|
|
195
|
+
} else {
|
|
196
|
+
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`,
|
|
198
|
+
"run: catladder project setup",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const stale = checks.filter((c) => c.context !== AGGREGATE_CHECK_JOB_NAME);
|
|
202
|
+
if (stale.length > 0) {
|
|
203
|
+
report.warn(
|
|
204
|
+
`${branch} requires ${stale.length} additional named check(s) (${stale
|
|
205
|
+
.map((c) => c.context)
|
|
206
|
+
.join(
|
|
207
|
+
", ",
|
|
208
|
+
)}) — job names change with components; prefer only '${AGGREGATE_CHECK_JOB_NAME}'`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
156
211
|
};
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createCatenvContext,
|
|
3
|
+
generateAgentSkills,
|
|
4
|
+
getEnabledPipelineTypes,
|
|
5
|
+
} from "@catladder/pipeline";
|
|
2
6
|
import type { IO } from "../../../../../core/types";
|
|
3
7
|
import {
|
|
4
8
|
getAllPipelineContexts,
|
|
@@ -8,6 +12,7 @@ import { setupAccessTokens } from "./setupAccessTokens";
|
|
|
8
12
|
import { setupContext } from "./setupContext";
|
|
9
13
|
import { setupTopic } from "./setupTopic";
|
|
10
14
|
import { setupAgents } from "./setupAgents";
|
|
15
|
+
import { setupGithub } from "./setupGithub";
|
|
11
16
|
import { logSection } from "./logSection";
|
|
12
17
|
import { ensureGcloudProjectNumbers } from "./ensureGcloudProjectNumbers";
|
|
13
18
|
|
|
@@ -33,17 +38,25 @@ export const setupProject = async (
|
|
|
33
38
|
instance.log(` - ${context.name}:${context.env}`);
|
|
34
39
|
});
|
|
35
40
|
|
|
41
|
+
const enabledPipelines = config ? getEnabledPipelineTypes(config) : [];
|
|
36
42
|
await logSection(instance, "base setup", async () => {
|
|
37
|
-
|
|
43
|
+
// gitlab-specific provisioning (access token, agent webhooks) —
|
|
44
|
+
// meaningless (and failing) on a github-only project
|
|
45
|
+
if (enabledPipelines.includes("gitlab")) {
|
|
46
|
+
await setupAccessTokens(instance);
|
|
47
|
+
await setupAgents(instance);
|
|
48
|
+
}
|
|
38
49
|
await setupTopic(instance);
|
|
39
|
-
|
|
50
|
+
if (config) {
|
|
51
|
+
await setupGithub(instance, config);
|
|
52
|
+
}
|
|
40
53
|
});
|
|
41
54
|
for (const context of allContext) {
|
|
42
55
|
await setupContext(instance, context);
|
|
43
56
|
}
|
|
44
57
|
|
|
45
58
|
instance.log("");
|
|
46
|
-
instance.log("gitlab is ready!
|
|
59
|
+
instance.log(`${enabledPipelines.join(" + ") || "gitlab"} is ready! 🥂`);
|
|
47
60
|
instance.log("\n");
|
|
48
61
|
instance.log("do not forget to make sure that:");
|
|
49
62
|
[
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Config } from "@catladder/pipeline";
|
|
2
|
+
import {
|
|
3
|
+
AGGREGATE_CHECK_JOB_NAME,
|
|
4
|
+
getEnabledPipelineTypes,
|
|
5
|
+
getPipelineGitRemote,
|
|
6
|
+
} from "@catladder/pipeline";
|
|
7
|
+
import { getConfiguredGithubRepo } from "../../../../../commands/project/commandSecretsSyncGithub";
|
|
8
|
+
import type { IO } from "../../../../../core/types";
|
|
9
|
+
import { ghApiJson, isGhAuthenticated } from "../../../../../utils/github";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* configures github merge gating — the sane default gitlab ships
|
|
13
|
+
* built-in and a fresh github repository lacks entirely: without it a
|
|
14
|
+
* PR is mergeable the moment it opens, checks still running, and the
|
|
15
|
+
* auto-merge button never appears.
|
|
16
|
+
*
|
|
17
|
+
* - repo settings: allow auto-merge, delete merged head branches
|
|
18
|
+
* - branch protection on the default branch: require the generated
|
|
19
|
+
* `catladder ✅` aggregate check (the one stable context — individual
|
|
20
|
+
* job names change with every component add/rename)
|
|
21
|
+
*
|
|
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.
|
|
27
|
+
*/
|
|
28
|
+
export const setupGithub = async (instance: IO, config: Config) => {
|
|
29
|
+
if (!getEnabledPipelineTypes(config).includes("github")) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (!(await isGhAuthenticated())) {
|
|
33
|
+
instance.log(
|
|
34
|
+
"⚠️ github cli (gh) not authenticated — skipping github merge-gating setup (run: gh auth login)",
|
|
35
|
+
);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const repo = await getConfiguredGithubRepo();
|
|
39
|
+
if (!repo) {
|
|
40
|
+
instance.log(
|
|
41
|
+
`⚠️ no github repository found on the '${getPipelineGitRemote(config, "github")}' remote — skipping github merge-gating setup`,
|
|
42
|
+
);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const repoInfo = await ghApiJson<{
|
|
47
|
+
default_branch: string;
|
|
48
|
+
allow_auto_merge: boolean;
|
|
49
|
+
delete_branch_on_merge: boolean;
|
|
50
|
+
}>("GET", `repos/${repo}`);
|
|
51
|
+
|
|
52
|
+
if (!repoInfo.allow_auto_merge || !repoInfo.delete_branch_on_merge) {
|
|
53
|
+
await ghApiJson("PATCH", `repos/${repo}`, {
|
|
54
|
+
allow_auto_merge: true,
|
|
55
|
+
delete_branch_on_merge: true,
|
|
56
|
+
});
|
|
57
|
+
instance.log("✅ auto-merge + delete-merged-branches enabled");
|
|
58
|
+
} else {
|
|
59
|
+
instance.log("✅ auto-merge already enabled");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const branch = repoInfo.default_branch;
|
|
63
|
+
const protection = await ghApiJson<any>(
|
|
64
|
+
"GET",
|
|
65
|
+
`repos/${repo}/branches/${branch}/protection`,
|
|
66
|
+
).catch(() => null); // 404: branch not protected yet
|
|
67
|
+
|
|
68
|
+
const existingChecks: Array<{ context: string; app_id?: number }> =
|
|
69
|
+
protection?.required_status_checks?.checks ?? [];
|
|
70
|
+
if (existingChecks.some((c) => c.context === AGGREGATE_CHECK_JOB_NAME)) {
|
|
71
|
+
instance.log(
|
|
72
|
+
`✅ '${AGGREGATE_CHECK_JOB_NAME}' already required on ${branch}`,
|
|
73
|
+
);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// merge into whatever protection exists; PUT replaces the whole rule,
|
|
78
|
+
// so every preserved setting must be carried over explicitly
|
|
79
|
+
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
|
+
},
|
|
90
|
+
enforce_admins: protection?.enforce_admins?.enabled ?? false,
|
|
91
|
+
required_pull_request_reviews: protection?.required_pull_request_reviews
|
|
92
|
+
? {
|
|
93
|
+
required_approving_review_count:
|
|
94
|
+
protection.required_pull_request_reviews
|
|
95
|
+
.required_approving_review_count ?? 0,
|
|
96
|
+
dismiss_stale_reviews:
|
|
97
|
+
protection.required_pull_request_reviews.dismiss_stale_reviews ??
|
|
98
|
+
false,
|
|
99
|
+
require_code_owner_reviews:
|
|
100
|
+
protection.required_pull_request_reviews
|
|
101
|
+
.require_code_owner_reviews ?? false,
|
|
102
|
+
}
|
|
103
|
+
: null,
|
|
104
|
+
restrictions: protection?.restrictions
|
|
105
|
+
? {
|
|
106
|
+
users: protection.restrictions.users.map((u: any) => u.login),
|
|
107
|
+
teams: protection.restrictions.teams.map((t: any) => t.slug),
|
|
108
|
+
apps: protection.restrictions.apps.map((a: any) => a.slug),
|
|
109
|
+
}
|
|
110
|
+
: null,
|
|
111
|
+
allow_force_pushes: protection?.allow_force_pushes?.enabled ?? false,
|
|
112
|
+
allow_deletions: protection?.allow_deletions?.enabled ?? false,
|
|
113
|
+
});
|
|
114
|
+
instance.log(
|
|
115
|
+
`✅ branch protection on ${branch}: '${AGGREGATE_CHECK_JOB_NAME}' required (merges now wait for the pipeline)`,
|
|
116
|
+
);
|
|
117
|
+
};
|
package/src/utils/github.ts
CHANGED
|
@@ -167,3 +167,33 @@ export const ensureGithubEnvironment = async (
|
|
|
167
167
|
),
|
|
168
168
|
);
|
|
169
169
|
});
|
|
170
|
+
|
|
171
|
+
/** runs `gh api` with an optional JSON body and returns the parsed response */
|
|
172
|
+
export const ghApiJson = async <T = any>(
|
|
173
|
+
method: "GET" | "PATCH" | "PUT",
|
|
174
|
+
path: string,
|
|
175
|
+
body?: unknown,
|
|
176
|
+
): Promise<T> => {
|
|
177
|
+
const args = ["api", "-X", method, path];
|
|
178
|
+
const child = spawn("gh", [...args, ...(body ? ["--input", "-"] : [])], {
|
|
179
|
+
stdio: [body ? "pipe" : "ignore", "pipe", "pipe"],
|
|
180
|
+
});
|
|
181
|
+
let stdout = "";
|
|
182
|
+
let stderr = "";
|
|
183
|
+
child.stdout?.on("data", (data) => (stdout += data));
|
|
184
|
+
child.stderr?.on("data", (data) => (stderr += data));
|
|
185
|
+
if (body) {
|
|
186
|
+
child.stdin?.write(JSON.stringify(body));
|
|
187
|
+
child.stdin?.end();
|
|
188
|
+
}
|
|
189
|
+
return new Promise<T>((resolve, reject) => {
|
|
190
|
+
child.on("error", reject);
|
|
191
|
+
child.on("close", (code) =>
|
|
192
|
+
code === 0
|
|
193
|
+
? resolve(stdout ? JSON.parse(stdout) : (undefined as T))
|
|
194
|
+
: reject(
|
|
195
|
+
new Error(`gh api ${method} ${path} failed: ${stderr.trim()}`),
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
};
|