@catladder/cli 5.6.0 → 5.7.1
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/catci.js +22 -0
- package/dist/apps/cli/src/catci.js.map +1 -1
- package/dist/apps/cli/src/commands/index.d.ts +1 -0
- package/dist/apps/cli/src/commands/index.js +4 -1
- package/dist/apps/cli/src/commands/index.js.map +1 -1
- package/dist/apps/cli/src/commands/project/commandMrPin.d.ts +9 -0
- package/dist/apps/cli/src/commands/project/commandMrPin.js +11 -13
- package/dist/apps/cli/src/commands/project/commandMrPin.js.map +1 -1
- package/dist/apps/cli/src/commands/project/commandMrReviewApp.d.ts +21 -0
- package/dist/apps/cli/src/commands/project/commandMrReviewApp.js +102 -0
- package/dist/apps/cli/src/commands/project/commandMrReviewApp.js.map +1 -0
- package/dist/apps/cli/src/reviewApps/github.d.ts +10 -0
- package/dist/apps/cli/src/reviewApps/github.js +240 -0
- package/dist/apps/cli/src/reviewApps/github.js.map +1 -0
- package/dist/bundles/catci/index.js +5 -5
- package/dist/bundles/catenv/index.js +897 -25
- package/dist/bundles/cli/index.js +1021 -39
- package/dist/bundles/skills/catladder-cli/references/commands.md +13 -0
- package/dist/bundles/skills/catladder-config/SKILL.md +4 -0
- package/dist/bundles/skills/catladder-deploys/SKILL.md +6 -0
- package/dist/bundles/skills/catladder-pipelines/SKILL.md +85 -1
- package/dist/packages/pipeline/src/backends/github/GithubBackend.js +60 -3
- package/dist/packages/pipeline/src/backends/github/GithubBackend.js.map +1 -1
- package/dist/packages/pipeline/src/backends/github/aggregateCheckJob.d.ts +1 -1
- package/dist/packages/pipeline/src/backends/github/aggregateCheckJob.js +22 -8
- package/dist/packages/pipeline/src/backends/github/aggregateCheckJob.js.map +1 -1
- package/dist/packages/pipeline/src/backends/github/reviewDeployWorkflow.d.ts +128 -0
- package/dist/packages/pipeline/src/backends/github/reviewDeployWorkflow.js +510 -0
- package/dist/packages/pipeline/src/backends/github/reviewDeployWorkflow.js.map +1 -0
- package/dist/packages/pipeline/src/backends/gitlab/GitlabBackend.js +29 -4
- package/dist/packages/pipeline/src/backends/gitlab/GitlabBackend.js.map +1 -1
- package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.d.ts +11 -1
- package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.js +17 -5
- package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.js.map +1 -1
- package/dist/packages/pipeline/src/backends/gitlab/reviewDeployGate.d.ts +26 -0
- package/dist/packages/pipeline/src/backends/gitlab/reviewDeployGate.js +94 -0
- package/dist/packages/pipeline/src/backends/gitlab/reviewDeployGate.js.map +1 -0
- package/dist/packages/pipeline/src/deploy/base/deploy.js +10 -3
- package/dist/packages/pipeline/src/deploy/base/deploy.js.map +1 -1
- package/dist/packages/pipeline/src/index.d.ts +1 -0
- package/dist/packages/pipeline/src/index.js +1 -0
- package/dist/packages/pipeline/src/index.js.map +1 -1
- package/dist/packages/pipeline/src/pipeline/resolveRequirements.d.ts +2 -0
- package/dist/packages/pipeline/src/pipeline/resolveRequirements.js +3 -2
- package/dist/packages/pipeline/src/pipeline/resolveRequirements.js.map +1 -1
- package/dist/packages/pipeline/src/reviewApps.d.ts +38 -0
- package/dist/packages/pipeline/src/reviewApps.js +86 -0
- package/dist/packages/pipeline/src/reviewApps.js.map +1 -0
- package/dist/packages/pipeline/src/reviewAppsPolicy.d.ts +46 -0
- package/dist/packages/pipeline/src/reviewAppsPolicy.js +37 -0
- package/dist/packages/pipeline/src/reviewAppsPolicy.js.map +1 -0
- package/dist/packages/pipeline/src/types/config.d.ts +55 -0
- package/dist/packages/pipeline/src/types/config.js.map +1 -1
- package/dist/skills/catladder-cli/references/commands.md +13 -0
- package/dist/skills/catladder-config/SKILL.md +4 -0
- package/dist/skills/catladder-deploys/SKILL.md +6 -0
- package/dist/skills/catladder-pipelines/SKILL.md +85 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/catci.ts +29 -0
- package/src/commands/index.ts +4 -0
- package/src/commands/project/commandMrPin.ts +15 -14
- package/src/commands/project/commandMrReviewApp.ts +143 -0
- package/src/reviewApps/__tests__/github.test.ts +118 -0
- package/src/reviewApps/github.ts +307 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* the review deploy switch on github (see the pipeline's
|
|
3
|
+
* `reviewApps.ts` and `backends/github/reviewDeployWorkflow.ts`).
|
|
4
|
+
*
|
|
5
|
+
* github runs can't pause for a click, so the switch is a workflow of
|
|
6
|
+
* its own, `▶️ catladder deploy review`, holding the review delivery
|
|
7
|
+
* chains (docker → deploy → verify) behind a guard:
|
|
8
|
+
*
|
|
9
|
+
* - `github-trigger` is the last job of the review workflow: once the
|
|
10
|
+
* PR's CI is green it reads the PR fresh (labels, draft) and
|
|
11
|
+
* dispatches the deploy workflow when the policy says it deploys
|
|
12
|
+
* - `github-guard` is the first job of the deploy workflow and decides
|
|
13
|
+
* whether the run deploys (`deploy` step output):
|
|
14
|
+
* - source `manual` (a dispatch by a human or agent): always,
|
|
15
|
+
* provided the PR is open and its CI is green
|
|
16
|
+
* - source `ci` (dispatched by the trigger, which runs after the
|
|
17
|
+
* aggregate check succeeded): when the policy says so; the CI is
|
|
18
|
+
* green by construction, the guard verifies only the head
|
|
19
|
+
* - source `label` (the switch label changed / a draft was marked
|
|
20
|
+
* ready): when the policy says so — and the CI is green. A label
|
|
21
|
+
* switched on while CI still runs is no deploy here (status
|
|
22
|
+
* `pending`): the trigger picks it up once CI is green.
|
|
23
|
+
*/
|
|
24
|
+
import {
|
|
25
|
+
type ResolvedReviewAppsConfig,
|
|
26
|
+
shouldAutoDeployReviewApps,
|
|
27
|
+
} from "../../../../packages/pipeline/src/reviewAppsPolicy";
|
|
28
|
+
import { appendFileSync } from "fs";
|
|
29
|
+
import { appendStepSummary, workflowLink } from "../release/stepSummary";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* the generated review deploy workflow. Keep in sync with
|
|
33
|
+
* REVIEW_DEPLOY_WORKFLOW_FILE in
|
|
34
|
+
* packages/pipeline/src/backends/github/reviewDeployWorkflow.ts.
|
|
35
|
+
*/
|
|
36
|
+
const GITHUB_REVIEW_DEPLOY_WORKFLOW = "catladder-deploy-review.yml";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* the review workflow's aggregate job — its check run says whether the
|
|
40
|
+
* PR's CI is green. Keep in sync with AGGREGATE_CHECK_JOB_NAME in
|
|
41
|
+
* packages/pipeline/src/backends/github/aggregateCheckJob.ts.
|
|
42
|
+
*/
|
|
43
|
+
const AGGREGATE_CHECK_NAME = "catladder ✅";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* the commit status the review app result is reported on. Keep in sync
|
|
47
|
+
* with REVIEW_APP_STATUS_CONTEXT in
|
|
48
|
+
* packages/pipeline/src/backends/github/reviewDeployWorkflow.ts.
|
|
49
|
+
*/
|
|
50
|
+
const REVIEW_APP_STATUS_CONTEXT = "catladder review app";
|
|
51
|
+
|
|
52
|
+
type PullRequest = {
|
|
53
|
+
number: number;
|
|
54
|
+
state: string;
|
|
55
|
+
draft: boolean;
|
|
56
|
+
labels: Array<{ name: string }>;
|
|
57
|
+
head: { sha: string; ref: string };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const requireEnv = (name: string): string => {
|
|
61
|
+
const value = process.env[name];
|
|
62
|
+
if (!value) {
|
|
63
|
+
throw new Error(`${name} is not set`);
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const fail = (message: string): never => {
|
|
69
|
+
console.error(message);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const setStepOutput = (name: string, value: string) => {
|
|
74
|
+
const file = process.env.GITHUB_OUTPUT;
|
|
75
|
+
if (file) {
|
|
76
|
+
appendFileSync(file, `${name}=${value}\n`);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const getReviewAppsConfig = (): ResolvedReviewAppsConfig =>
|
|
81
|
+
JSON.parse(requireEnv("CL_REVIEW_APPS"));
|
|
82
|
+
|
|
83
|
+
const githubApi = async (path: string, init: RequestInit = {}) => {
|
|
84
|
+
const apiUrl = process.env.GITHUB_API_URL ?? "https://api.github.com";
|
|
85
|
+
const repository = requireEnv("GITHUB_REPOSITORY");
|
|
86
|
+
return fetch(`${apiUrl}/repos/${repository}/${path}`, {
|
|
87
|
+
...init,
|
|
88
|
+
headers: {
|
|
89
|
+
authorization: `Bearer ${requireEnv("GITHUB_TOKEN")}`,
|
|
90
|
+
accept: "application/vnd.github+json",
|
|
91
|
+
...init.headers,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const getPullRequest = async (prNumber: string): Promise<PullRequest> => {
|
|
97
|
+
const response = await githubApi(`pulls/${prNumber}`);
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`reading pull request #${prNumber} failed: ${response.status} ${await response.text()}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return (await response.json()) as PullRequest;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* the newest `catladder ✅` check run of a commit — null while the
|
|
108
|
+
* review workflow hasn't reported yet
|
|
109
|
+
*/
|
|
110
|
+
const getAggregateCheck = async (
|
|
111
|
+
sha: string,
|
|
112
|
+
): Promise<{ status: string; conclusion: string | null } | null> => {
|
|
113
|
+
const response = await githubApi(
|
|
114
|
+
`commits/${sha}/check-runs?check_name=${encodeURIComponent(AGGREGATE_CHECK_NAME)}&filter=latest`,
|
|
115
|
+
);
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`reading the checks of ${sha} failed: ${response.status} ${await response.text()}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const data = (await response.json()) as {
|
|
122
|
+
check_runs?: Array<{
|
|
123
|
+
id: number;
|
|
124
|
+
status: string;
|
|
125
|
+
conclusion: string | null;
|
|
126
|
+
}>;
|
|
127
|
+
};
|
|
128
|
+
const [newest] = [...(data.check_runs ?? [])].sort((a, b) => b.id - a.id);
|
|
129
|
+
return newest ?? null;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* a deploy that waits for the CI must not leave the PR without a
|
|
134
|
+
* status: a merge gate would see nothing and wait without a reason
|
|
135
|
+
*/
|
|
136
|
+
const reportWaitingForCi = async (sha: string, message: string) => {
|
|
137
|
+
const response = await githubApi(`statuses/${sha}`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { "content-type": "application/json" },
|
|
140
|
+
body: JSON.stringify({
|
|
141
|
+
state: "pending",
|
|
142
|
+
context: REVIEW_APP_STATUS_CONTEXT,
|
|
143
|
+
description: message.slice(0, 140),
|
|
144
|
+
target_url: `${process.env.GITHUB_SERVER_URL ?? "https://github.com"}/${requireEnv("GITHUB_REPOSITORY")}/actions/runs/${process.env.GITHUB_RUN_ID ?? ""}`,
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
if (!response.ok) {
|
|
148
|
+
// informational only — never fail the guard over it
|
|
149
|
+
console.log(
|
|
150
|
+
`::warning::reporting the pending review app status failed: ${response.status}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const short = (sha: string) => sha.slice(0, 7);
|
|
156
|
+
|
|
157
|
+
const mrState = (pr: PullRequest) => ({
|
|
158
|
+
labels: pr.labels.map((label) => label.name),
|
|
159
|
+
draft: pr.draft,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `catci review-app github-guard` — first job of the review deploy
|
|
164
|
+
* workflow, sets the `deploy` step output
|
|
165
|
+
*/
|
|
166
|
+
export const githubReviewDeployGuardJob = async () => {
|
|
167
|
+
const config = getReviewAppsConfig();
|
|
168
|
+
const prNumber = requireEnv("CL_PR_NUMBER");
|
|
169
|
+
const source = process.env.CL_DEPLOY_SOURCE || "manual";
|
|
170
|
+
const headSha = requireEnv("CL_HEAD_SHA");
|
|
171
|
+
const explicit = source === "manual";
|
|
172
|
+
|
|
173
|
+
const skip = (message: string) => {
|
|
174
|
+
console.log(`not deploying: ${message}`);
|
|
175
|
+
appendStepSummary(`⏭️ **Not deploying** the review apps: ${message}`);
|
|
176
|
+
setStepOutput("deploy", "false");
|
|
177
|
+
};
|
|
178
|
+
// an explicit request that can't be served is an error, an automatic
|
|
179
|
+
// one simply doesn't deploy
|
|
180
|
+
const refuse = (message: string) => {
|
|
181
|
+
if (explicit) {
|
|
182
|
+
appendStepSummary(`❌ **Can't deploy** the review apps: ${message}`);
|
|
183
|
+
setStepOutput("deploy", "false");
|
|
184
|
+
fail(`can't deploy the review apps: ${message}`);
|
|
185
|
+
}
|
|
186
|
+
skip(message);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const pr = await getPullRequest(prNumber);
|
|
190
|
+
if (pr.state !== "open") {
|
|
191
|
+
return refuse(`pull request #${pr.number} is ${pr.state}`);
|
|
192
|
+
}
|
|
193
|
+
let reason = "requested explicitly";
|
|
194
|
+
if (!explicit) {
|
|
195
|
+
const decision = shouldAutoDeployReviewApps(config, mrState(pr));
|
|
196
|
+
if (!decision.deploy) {
|
|
197
|
+
return skip(decision.reason);
|
|
198
|
+
}
|
|
199
|
+
reason = decision.reason;
|
|
200
|
+
}
|
|
201
|
+
if (pr.head.sha !== headSha) {
|
|
202
|
+
return refuse(
|
|
203
|
+
explicit
|
|
204
|
+
? `this run is for ${short(headSha)}, but #${pr.number}'s head is ${short(pr.head.sha)} — dispatch the workflow from the pull request's branch ('${pr.head.ref}')`
|
|
205
|
+
: `#${pr.number} moved on to ${short(pr.head.sha)} — its own review run decides`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
// the trigger job dispatches with source `ci` only after the review
|
|
209
|
+
// run's aggregate check succeeded for this very head — reading the
|
|
210
|
+
// check again here raced the checks api (a deploy was lost 17 s after
|
|
211
|
+
// the check completed, zue-zuerich-tourismus#159): the head match
|
|
212
|
+
// above is the whole verification for a ci dispatch
|
|
213
|
+
if (source === "ci") {
|
|
214
|
+
console.log(
|
|
215
|
+
`deploying the review apps of #${pr.number} (${short(headSha)}): ${reason}; the review run (${AGGREGATE_CHECK_NAME}) dispatched this deploy after it was green`,
|
|
216
|
+
);
|
|
217
|
+
appendStepSummary(
|
|
218
|
+
`🚀 **Deploying** the review apps of #${pr.number} (\`${short(headSha)}\`): ${reason}.`,
|
|
219
|
+
);
|
|
220
|
+
setStepOutput("deploy", "true");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const check = await getAggregateCheck(headSha);
|
|
224
|
+
if (!check || check.status !== "completed") {
|
|
225
|
+
if (!explicit) {
|
|
226
|
+
await reportWaitingForCi(
|
|
227
|
+
headSha,
|
|
228
|
+
`waiting for the CI of ${short(headSha)} (${AGGREGATE_CHECK_NAME}) — the review apps deploy once it is green`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
return refuse(
|
|
232
|
+
`the CI of ${short(headSha)} (${AGGREGATE_CHECK_NAME}) is not finished yet` +
|
|
233
|
+
(explicit
|
|
234
|
+
? " — dispatch again once it is green"
|
|
235
|
+
: " — the review workflow starts the deploy once it is green"),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
if (check.conclusion !== "success") {
|
|
239
|
+
return refuse(
|
|
240
|
+
`the CI of ${short(headSha)} (${AGGREGATE_CHECK_NAME}) concluded '${check.conclusion}'`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
console.log(
|
|
244
|
+
`deploying the review apps of #${pr.number} (${short(headSha)}): ${reason}`,
|
|
245
|
+
);
|
|
246
|
+
appendStepSummary(
|
|
247
|
+
`🚀 **Deploying** the review apps of #${pr.number} (\`${short(headSha)}\`): ${reason}.`,
|
|
248
|
+
);
|
|
249
|
+
setStepOutput("deploy", "true");
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* `catci review-app github-trigger` — last job of the review workflow:
|
|
254
|
+
* dispatches the review deploy workflow when the PR deploys
|
|
255
|
+
*/
|
|
256
|
+
export const githubReviewDeployTriggerJob = async () => {
|
|
257
|
+
const config = getReviewAppsConfig();
|
|
258
|
+
const prNumber = requireEnv("CL_PR_NUMBER");
|
|
259
|
+
const headRef = requireEnv("CL_HEAD_REF");
|
|
260
|
+
const headSha = requireEnv("CL_HEAD_SHA");
|
|
261
|
+
|
|
262
|
+
const pr = await getPullRequest(prNumber);
|
|
263
|
+
if (pr.head.sha !== headSha) {
|
|
264
|
+
console.log(
|
|
265
|
+
`#${pr.number} moved on to ${short(pr.head.sha)} — its own review run decides`,
|
|
266
|
+
);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const decision = shouldAutoDeployReviewApps(config, mrState(pr));
|
|
270
|
+
if (!decision.deploy) {
|
|
271
|
+
console.log(`not deploying the review apps: ${decision.reason}`);
|
|
272
|
+
appendStepSummary(
|
|
273
|
+
`⏭️ Review apps not deployed: ${decision.reason}. ` +
|
|
274
|
+
`Deploy anyway: \`gh workflow run ${GITHUB_REVIEW_DEPLOY_WORKFLOW} --ref ${headRef} -f pr=${pr.number}\``,
|
|
275
|
+
);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const response = await githubApi(
|
|
279
|
+
`actions/workflows/${GITHUB_REVIEW_DEPLOY_WORKFLOW}/dispatches`,
|
|
280
|
+
{
|
|
281
|
+
method: "POST",
|
|
282
|
+
headers: { "content-type": "application/json" },
|
|
283
|
+
body: JSON.stringify({
|
|
284
|
+
ref: headRef,
|
|
285
|
+
inputs: { pr: String(pr.number), source: "ci" },
|
|
286
|
+
}),
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
if (response.status === 404) {
|
|
290
|
+
// github only dispatches workflows that exist on the default branch
|
|
291
|
+
const message = `the ${GITHUB_REVIEW_DEPLOY_WORKFLOW} workflow is not on the default branch yet — review apps deploy automatically once this change is merged`;
|
|
292
|
+
console.log(`::warning::${message}`);
|
|
293
|
+
appendStepSummary(`⚠️ ${message}`);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (!response.ok) {
|
|
297
|
+
fail(
|
|
298
|
+
`dispatching ${GITHUB_REVIEW_DEPLOY_WORKFLOW} failed: ${response.status} ${await response.text()}`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
console.log(
|
|
302
|
+
`review deploy dispatched for #${pr.number} (${short(headSha)}): ${decision.reason}`,
|
|
303
|
+
);
|
|
304
|
+
appendStepSummary(
|
|
305
|
+
`▶️ **Review deploy started** for #${pr.number} (\`${short(headSha)}\`): ${decision.reason} — see ${workflowLink(GITHUB_REVIEW_DEPLOY_WORKFLOW, "catladder deploy review")}.`,
|
|
306
|
+
);
|
|
307
|
+
};
|