@catladder/cli 5.7.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/reviewApps/github.js +48 -4
- package/dist/apps/cli/src/reviewApps/github.js.map +1 -1
- package/dist/bundles/catci/index.js +4 -4
- package/dist/bundles/skills/catladder-pipelines/SKILL.md +8 -2
- package/dist/skills/catladder-pipelines/SKILL.md +8 -2
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/reviewApps/__tests__/github.test.ts +118 -0
- package/src/reviewApps/github.ts +58 -4
package/package.json
CHANGED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { githubReviewDeployGuardJob } from "../github";
|
|
3
|
+
|
|
4
|
+
const HEAD = "4a60abe0000000000000000000000000000000aa";
|
|
5
|
+
|
|
6
|
+
type Call = { url: string; method: string; body?: any };
|
|
7
|
+
|
|
8
|
+
/** a github api double: one PR, a `catladder ✅` check in a given state */
|
|
9
|
+
const mockGithub = (
|
|
10
|
+
check: { status: string; conclusion: string | null } | null,
|
|
11
|
+
) => {
|
|
12
|
+
const calls: Call[] = [];
|
|
13
|
+
vi.stubGlobal(
|
|
14
|
+
"fetch",
|
|
15
|
+
vi.fn(async (url: string, init: any = {}) => {
|
|
16
|
+
const path = url.replace("https://api.github.com/repos/o/r/", "");
|
|
17
|
+
calls.push({
|
|
18
|
+
url: path,
|
|
19
|
+
method: init.method ?? "GET",
|
|
20
|
+
body: init.body ? JSON.parse(init.body) : undefined,
|
|
21
|
+
});
|
|
22
|
+
const json = (data: unknown) => ({
|
|
23
|
+
ok: true,
|
|
24
|
+
status: 200,
|
|
25
|
+
json: async () => data,
|
|
26
|
+
text: async () => JSON.stringify(data),
|
|
27
|
+
});
|
|
28
|
+
if (path.startsWith("pulls/")) {
|
|
29
|
+
return json({
|
|
30
|
+
number: 159,
|
|
31
|
+
state: "open",
|
|
32
|
+
draft: false,
|
|
33
|
+
labels: [{ name: "catladder:review-app" }],
|
|
34
|
+
head: { sha: HEAD, ref: "feat/x" },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (path.startsWith("commits/")) {
|
|
38
|
+
return json({ check_runs: check ? [{ id: 1, ...check }] : [] });
|
|
39
|
+
}
|
|
40
|
+
return json({});
|
|
41
|
+
}),
|
|
42
|
+
);
|
|
43
|
+
return calls;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
describe("review deploy guard: label added while the CI runs", () => {
|
|
47
|
+
let output: string;
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
output = "";
|
|
50
|
+
vi.spyOn(console, "log").mockImplementation(() => {});
|
|
51
|
+
process.env.GITHUB_REPOSITORY = "o/r";
|
|
52
|
+
process.env.GITHUB_TOKEN = "t";
|
|
53
|
+
process.env.GITHUB_RUN_ID = "1";
|
|
54
|
+
process.env.CL_PR_NUMBER = "159";
|
|
55
|
+
process.env.CL_HEAD_SHA = HEAD;
|
|
56
|
+
process.env.CL_DEPLOY_SOURCE = "label";
|
|
57
|
+
process.env.CL_REVIEW_APPS = JSON.stringify({
|
|
58
|
+
deploy: "optIn",
|
|
59
|
+
label: "catladder:review-app",
|
|
60
|
+
skipLabel: "catladder:no-review-app",
|
|
61
|
+
drafts: "likeReady",
|
|
62
|
+
});
|
|
63
|
+
// capture the step output
|
|
64
|
+
const file = `${process.env.TMPDIR ?? "/tmp"}/gh-output-${process.pid}-${Math.random()}`;
|
|
65
|
+
process.env.GITHUB_OUTPUT = file;
|
|
66
|
+
output = file;
|
|
67
|
+
});
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
vi.unstubAllGlobals();
|
|
70
|
+
vi.restoreAllMocks();
|
|
71
|
+
});
|
|
72
|
+
const deployOutput = async () =>
|
|
73
|
+
(await import("fs")).readFileSync(output, "utf8");
|
|
74
|
+
|
|
75
|
+
it("defers and reports a pending status while the CI is not finished", async () => {
|
|
76
|
+
const calls = mockGithub({ status: "in_progress", conclusion: null });
|
|
77
|
+
await githubReviewDeployGuardJob();
|
|
78
|
+
expect(await deployOutput()).toContain("deploy=false");
|
|
79
|
+
const status = calls.find((c) => c.url === `statuses/${HEAD}`);
|
|
80
|
+
expect(status?.method).toBe("POST");
|
|
81
|
+
expect(status?.body).toMatchObject({
|
|
82
|
+
state: "pending",
|
|
83
|
+
context: "catladder review app",
|
|
84
|
+
});
|
|
85
|
+
expect(status?.body.description).toContain("waiting for the CI");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("defers and reports pending while the CI has not even reported", async () => {
|
|
89
|
+
const calls = mockGithub(null);
|
|
90
|
+
await githubReviewDeployGuardJob();
|
|
91
|
+
expect(await deployOutput()).toContain("deploy=false");
|
|
92
|
+
expect(calls.some((c) => c.url === `statuses/${HEAD}`)).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("deploys without a pending status when the CI already finished green", async () => {
|
|
96
|
+
const calls = mockGithub({ status: "completed", conclusion: "success" });
|
|
97
|
+
await githubReviewDeployGuardJob();
|
|
98
|
+
expect(await deployOutput()).toContain("deploy=true");
|
|
99
|
+
expect(calls.some((c) => c.url.startsWith("statuses/"))).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("trusts a ci dispatch: deploys although the checks api still reports the CI in progress", async () => {
|
|
103
|
+
process.env.CL_DEPLOY_SOURCE = "ci";
|
|
104
|
+
const calls = mockGithub({ status: "in_progress", conclusion: null });
|
|
105
|
+
await githubReviewDeployGuardJob();
|
|
106
|
+
expect(await deployOutput()).toContain("deploy=true");
|
|
107
|
+
expect(calls.some((c) => c.url.startsWith("commits/"))).toBe(false);
|
|
108
|
+
expect(calls.some((c) => c.url.startsWith("statuses/"))).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("a ci dispatch for a head the PR moved away from does not deploy", async () => {
|
|
112
|
+
process.env.CL_DEPLOY_SOURCE = "ci";
|
|
113
|
+
process.env.CL_HEAD_SHA = "0000000000000000000000000000000000000000";
|
|
114
|
+
mockGithub({ status: "completed", conclusion: "success" });
|
|
115
|
+
await githubReviewDeployGuardJob();
|
|
116
|
+
expect(await deployOutput()).toContain("deploy=false");
|
|
117
|
+
});
|
|
118
|
+
});
|
package/src/reviewApps/github.ts
CHANGED
|
@@ -13,10 +13,13 @@
|
|
|
13
13
|
* whether the run deploys (`deploy` step output):
|
|
14
14
|
* - source `manual` (a dispatch by a human or agent): always,
|
|
15
15
|
* provided the PR is open and its CI is green
|
|
16
|
-
* - source `ci` (dispatched by the trigger
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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.
|
|
20
23
|
*/
|
|
21
24
|
import {
|
|
22
25
|
type ResolvedReviewAppsConfig,
|
|
@@ -39,6 +42,13 @@ const GITHUB_REVIEW_DEPLOY_WORKFLOW = "catladder-deploy-review.yml";
|
|
|
39
42
|
*/
|
|
40
43
|
const AGGREGATE_CHECK_NAME = "catladder ✅";
|
|
41
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
|
+
|
|
42
52
|
type PullRequest = {
|
|
43
53
|
number: number;
|
|
44
54
|
state: string;
|
|
@@ -119,6 +129,29 @@ const getAggregateCheck = async (
|
|
|
119
129
|
return newest ?? null;
|
|
120
130
|
};
|
|
121
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
|
+
|
|
122
155
|
const short = (sha: string) => sha.slice(0, 7);
|
|
123
156
|
|
|
124
157
|
const mrState = (pr: PullRequest) => ({
|
|
@@ -172,8 +205,29 @@ export const githubReviewDeployGuardJob = async () => {
|
|
|
172
205
|
: `#${pr.number} moved on to ${short(pr.head.sha)} — its own review run decides`,
|
|
173
206
|
);
|
|
174
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
|
+
}
|
|
175
223
|
const check = await getAggregateCheck(headSha);
|
|
176
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
|
+
}
|
|
177
231
|
return refuse(
|
|
178
232
|
`the CI of ${short(headSha)} (${AGGREGATE_CHECK_NAME}) is not finished yet` +
|
|
179
233
|
(explicit
|