@catladder/cli 5.5.0 → 5.7.0
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 +196 -0
- package/dist/apps/cli/src/reviewApps/github.js.map +1 -0
- package/dist/bundles/catci/index.js +4 -4
- package/dist/bundles/catenv/index.js +1261 -70
- package/dist/bundles/catenv/xdg-open +0 -0
- package/dist/bundles/cli/clipboard_i686.exe +0 -0
- package/dist/bundles/cli/clipboard_x86_64.exe +0 -0
- package/dist/bundles/cli/index.js +1387 -86
- package/dist/bundles/cli/xdg-open +0 -0
- package/dist/bundles/cli/xsel +0 -0
- package/dist/bundles/runner-images/changesets/scripts/changesetsRelease +0 -0
- package/dist/bundles/runner-images/kubernetes/scripts/kubernetesCreateSecret +0 -0
- package/dist/bundles/runner-images/kubernetes/scripts/kubernetesDelete +0 -0
- package/dist/bundles/runner-images/kubernetes/scripts/kubernetesDeploy +0 -0
- package/dist/bundles/runner-images/kubernetes/scripts/kubernetesRollback +0 -0
- package/dist/bundles/skills/catladder-builds/SKILL.md +50 -2
- package/dist/bundles/skills/catladder-builds/references/build-types.md +10 -2
- 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 +97 -3
- 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/artifactArchive.d.ts +22 -0
- package/dist/packages/pipeline/src/backends/github/artifactArchive.js +84 -0
- package/dist/packages/pipeline/src/backends/github/artifactArchive.js.map +1 -0
- package/dist/packages/pipeline/src/backends/github/createGithubJobs.js +12 -29
- package/dist/packages/pipeline/src/backends/github/createGithubJobs.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 +32 -5
- 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/getPipelineStages.d.ts +4 -1
- package/dist/packages/pipeline/src/backends/gitlab/getPipelineStages.js +11 -2
- package/dist/packages/pipeline/src/backends/gitlab/getPipelineStages.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/build/types.d.ts +4 -3
- package/dist/packages/pipeline/src/build/types.js.map +1 -1
- package/dist/packages/pipeline/src/deploy/base/deploy.js +25 -12
- package/dist/packages/pipeline/src/deploy/base/deploy.js.map +1 -1
- package/dist/packages/pipeline/src/index.d.ts +2 -0
- package/dist/packages/pipeline/src/index.js +2 -0
- package/dist/packages/pipeline/src/index.js.map +1 -1
- package/dist/packages/pipeline/src/pipeline/createJobsForComponent.js +12 -2
- package/dist/packages/pipeline/src/pipeline/createJobsForComponent.js.map +1 -1
- package/dist/packages/pipeline/src/pipeline/createJobsForWorkspace.js +7 -2
- package/dist/packages/pipeline/src/pipeline/createJobsForWorkspace.js.map +1 -1
- package/dist/packages/pipeline/src/pipeline/resolveRequirements.d.ts +2 -0
- package/dist/packages/pipeline/src/pipeline/resolveRequirements.js +4 -2
- package/dist/packages/pipeline/src/pipeline/resolveRequirements.js.map +1 -1
- package/dist/packages/pipeline/src/postBuildTest/createPostBuildTestJobs.d.ts +16 -0
- package/dist/packages/pipeline/src/postBuildTest/createPostBuildTestJobs.js +167 -0
- package/dist/packages/pipeline/src/postBuildTest/createPostBuildTestJobs.js.map +1 -0
- package/dist/packages/pipeline/src/postBuildTest/index.d.ts +2 -0
- package/dist/packages/pipeline/src/postBuildTest/index.js +19 -0
- package/dist/packages/pipeline/src/postBuildTest/index.js.map +1 -0
- package/dist/packages/pipeline/src/postBuildTest/types.d.ts +61 -0
- package/dist/packages/pipeline/src/postBuildTest/types.js +3 -0
- package/dist/packages/pipeline/src/postBuildTest/types.js.map +1 -0
- 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/packages/pipeline/src/types/jobs.d.ts +2 -2
- package/dist/packages/pipeline/src/types/jobs.js +1 -0
- package/dist/packages/pipeline/src/types/jobs.js.map +1 -1
- package/dist/runner-images/changesets/scripts/changesetsRelease +0 -0
- package/dist/runner-images/kubernetes/scripts/kubernetesCreateSecret +0 -0
- package/dist/runner-images/kubernetes/scripts/kubernetesDelete +0 -0
- package/dist/runner-images/kubernetes/scripts/kubernetesDeploy +0 -0
- package/dist/runner-images/kubernetes/scripts/kubernetesRollback +0 -0
- package/dist/skills/catladder-builds/SKILL.md +50 -2
- package/dist/skills/catladder-builds/references/build-types.md +10 -2
- 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 +97 -3
- 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/github.ts +253 -0
package/package.json
CHANGED
package/src/catci.ts
CHANGED
|
@@ -30,6 +30,10 @@ import {
|
|
|
30
30
|
githubQueuedGuardJob,
|
|
31
31
|
} from "./release/githubQueuedRelease";
|
|
32
32
|
import { npmPublishJob, parseNpmPublishArgs } from "./publish/npmPublishJob";
|
|
33
|
+
import {
|
|
34
|
+
githubReviewDeployGuardJob,
|
|
35
|
+
githubReviewDeployTriggerJob,
|
|
36
|
+
} from "./reviewApps/github";
|
|
33
37
|
import { getGitlabHostUrl } from "./utils/gitlabHost";
|
|
34
38
|
|
|
35
39
|
const USAGE = `catci — catladder CI companion
|
|
@@ -78,6 +82,17 @@ usage:
|
|
|
78
82
|
queue marker when it matches the completed main run and decides
|
|
79
83
|
whether the release step runs (release=true step output)
|
|
80
84
|
|
|
85
|
+
catci review-app github-trigger
|
|
86
|
+
github only, last job of the review workflow: once the PR's CI is
|
|
87
|
+
green, dispatches the review deploy workflow when the reviewApps
|
|
88
|
+
policy (labels, draft) says the PR deploys
|
|
89
|
+
|
|
90
|
+
catci review-app github-guard
|
|
91
|
+
github only, first job of the review deploy workflow: decides
|
|
92
|
+
whether the run deploys (deploy=true step output) — explicit
|
|
93
|
+
dispatches always do, label events and ci dispatches follow the
|
|
94
|
+
policy; all need the PR open and its CI green
|
|
95
|
+
|
|
81
96
|
catci publish npm --dir <dir> --env-type <envType> [--access <access>] [--registry <url>] [--dist-tag <tag>]
|
|
82
97
|
npmPackage deploy job: derives version + dist-tag from the
|
|
83
98
|
pipeline trigger (tagged release -> tag version @latest; branch/MR
|
|
@@ -204,6 +219,20 @@ const main = async () => {
|
|
|
204
219
|
) {
|
|
205
220
|
return githubQueuedGuardJob(args[0], args[1]);
|
|
206
221
|
}
|
|
222
|
+
if (
|
|
223
|
+
group === "review-app" &&
|
|
224
|
+
command === "github-trigger" &&
|
|
225
|
+
args.length === 0
|
|
226
|
+
) {
|
|
227
|
+
return githubReviewDeployTriggerJob();
|
|
228
|
+
}
|
|
229
|
+
if (
|
|
230
|
+
group === "review-app" &&
|
|
231
|
+
command === "github-guard" &&
|
|
232
|
+
args.length === 0
|
|
233
|
+
) {
|
|
234
|
+
return githubReviewDeployGuardJob();
|
|
235
|
+
}
|
|
207
236
|
if (group === "publish" && command === "npm") {
|
|
208
237
|
const options = parseNpmPublishArgs(args);
|
|
209
238
|
if (options) {
|
package/src/commands/index.ts
CHANGED
|
@@ -30,6 +30,10 @@ export { commandCiJobOpen, commandCiJobLog } from "./project/commandGitlabCi";
|
|
|
30
30
|
export { commandProjectRestoreDb } from "./project/commandProjectRestoreDb";
|
|
31
31
|
export { commandSecurityEvaluate } from "./project/commandSecurityEvaluate";
|
|
32
32
|
export { commandMrPin, commandMrUnpin } from "./project/commandMrPin";
|
|
33
|
+
export {
|
|
34
|
+
commandMrReviewAppOn,
|
|
35
|
+
commandMrReviewAppOff,
|
|
36
|
+
} from "./project/commandMrReviewApp";
|
|
33
37
|
|
|
34
38
|
// Project k8s commands
|
|
35
39
|
export { commandNamespace } from "./project/k8s/commandNamespace";
|
|
@@ -10,7 +10,7 @@ import { doGitlabRequest, getProjectInfo } from "../../utils/gitlab";
|
|
|
10
10
|
|
|
11
11
|
const PIN_LABEL_COLOR = "#428fdc";
|
|
12
12
|
|
|
13
|
-
type GitlabMr = {
|
|
13
|
+
export type GitlabMr = {
|
|
14
14
|
iid: number;
|
|
15
15
|
title: string;
|
|
16
16
|
web_url: string;
|
|
@@ -40,7 +40,7 @@ const getPinLabel = async (): Promise<string> => {
|
|
|
40
40
|
return pinLabel;
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
-
const findMr = async (
|
|
43
|
+
export const findMr = async (
|
|
44
44
|
io: IO,
|
|
45
45
|
projectId: string,
|
|
46
46
|
mrIid: number | undefined,
|
|
@@ -61,30 +61,26 @@ const findMr = async (
|
|
|
61
61
|
return mrs[0];
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
-
const
|
|
64
|
+
export const ensureLabelExists = async (
|
|
65
65
|
io: IO,
|
|
66
66
|
projectId: string,
|
|
67
|
-
|
|
67
|
+
name: string,
|
|
68
|
+
description: string,
|
|
68
69
|
) => {
|
|
69
70
|
const existing = await doGitlabRequest<Array<{ name: string }>>(
|
|
70
71
|
io,
|
|
71
|
-
`projects/${projectId}/labels?search=${encodeURIComponent(
|
|
72
|
+
`projects/${projectId}/labels?search=${encodeURIComponent(name)}`,
|
|
72
73
|
);
|
|
73
|
-
if (existing?.some((label) => label.name ===
|
|
74
|
+
if (existing?.some((label) => label.name === name)) {
|
|
74
75
|
return;
|
|
75
76
|
}
|
|
76
77
|
await doGitlabRequest(
|
|
77
78
|
io,
|
|
78
79
|
`projects/${projectId}/labels`,
|
|
79
|
-
{
|
|
80
|
-
name: pinLabel,
|
|
81
|
-
color: PIN_LABEL_COLOR,
|
|
82
|
-
description:
|
|
83
|
-
"review apps of this MR are pinned — deploys use auto_stop_in: never (managed by catladder)",
|
|
84
|
-
},
|
|
80
|
+
{ name, color: PIN_LABEL_COLOR, description },
|
|
85
81
|
"POST",
|
|
86
82
|
);
|
|
87
|
-
io.log(`created the '${
|
|
83
|
+
io.log(`created the '${name}' label in the project`);
|
|
88
84
|
};
|
|
89
85
|
|
|
90
86
|
export const commandMrPin = defineCommand({
|
|
@@ -112,7 +108,12 @@ export const commandMrPin = defineCommand({
|
|
|
112
108
|
const { id: projectId } = await getProjectInfo(ctx);
|
|
113
109
|
const mr = await findMr(ctx, projectId, await ctx.get("mr"));
|
|
114
110
|
|
|
115
|
-
await
|
|
111
|
+
await ensureLabelExists(
|
|
112
|
+
ctx,
|
|
113
|
+
projectId,
|
|
114
|
+
pinLabel,
|
|
115
|
+
"review apps of this MR are pinned — deploys use auto_stop_in: never (managed by catladder)",
|
|
116
|
+
);
|
|
116
117
|
|
|
117
118
|
if (mr.labels?.includes(pinLabel)) {
|
|
118
119
|
ctx.log(`already pinned: mr!${mr.iid} carries the '${pinLabel}' label`);
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getEnabledPipelineTypes,
|
|
3
|
+
getReviewAppsConfig,
|
|
4
|
+
getReviewAppsSwitchLabel,
|
|
5
|
+
} from "@catladder/pipeline";
|
|
6
|
+
import { getProjectConfig } from "../../config/getProjectConfig";
|
|
7
|
+
import { defineCommand } from "../../core/defineCommand";
|
|
8
|
+
import { doGitlabRequest, getProjectInfo } from "../../utils/gitlab";
|
|
9
|
+
import { ensureLabelExists, findMr } from "./commandMrPin";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* the label switch of the project's review apps — errors when the
|
|
13
|
+
* project has none (github, or a policy without labels)
|
|
14
|
+
*/
|
|
15
|
+
const getSwitch = async () => {
|
|
16
|
+
const config = await getProjectConfig();
|
|
17
|
+
if (!config) {
|
|
18
|
+
throw new Error("no catladder config found");
|
|
19
|
+
}
|
|
20
|
+
const reviewApps = getReviewAppsConfig(config);
|
|
21
|
+
const switchLabel = getReviewAppsSwitchLabel(reviewApps);
|
|
22
|
+
if (!switchLabel) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
reviewApps.deploy === "manual"
|
|
25
|
+
? "review apps deploy on request in this project (reviewApps.deploy: manual) — play the '🚀 deploy review' job of the MR pipeline"
|
|
26
|
+
: "review apps of this project are not switched by label (set reviewApps.deploy to optIn or optOut)",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
if (!getEnabledPipelineTypes(config).includes("gitlab")) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`this command switches gitlab merge requests — on github, set the label on the pull request instead (gh pr edit --${switchLabel.deployOn === "labeled" ? "add" : "remove"}-label ${switchLabel.label}); the label change deploys by itself`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
return switchLabel;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const mrInput = {
|
|
38
|
+
mr: {
|
|
39
|
+
type: "number",
|
|
40
|
+
message:
|
|
41
|
+
"merge request IID (defaults to the open MR of the current branch)",
|
|
42
|
+
positional: true,
|
|
43
|
+
required: false,
|
|
44
|
+
},
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
export const commandMrReviewAppOn = defineCommand({
|
|
48
|
+
name: "mr review-app-on",
|
|
49
|
+
description:
|
|
50
|
+
"deploy the review apps of a merge request: switches the reviewApps label on (opt-in: adds it, opt-out: removes the skip label) and triggers a pipeline, since gitlab doesn't start one for label changes",
|
|
51
|
+
group: "mr",
|
|
52
|
+
inputs: {
|
|
53
|
+
...mrInput,
|
|
54
|
+
pipeline: {
|
|
55
|
+
type: "boolean",
|
|
56
|
+
message: "trigger a merge-request pipeline so the review apps deploy now",
|
|
57
|
+
required: false,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
execute: async (ctx) => {
|
|
61
|
+
const switchLabel = await getSwitch();
|
|
62
|
+
const { id: projectId } = await getProjectInfo(ctx);
|
|
63
|
+
const mr = await findMr(ctx, projectId, await ctx.get("mr"));
|
|
64
|
+
|
|
65
|
+
if (switchLabel.deployOn === "labeled") {
|
|
66
|
+
await ensureLabelExists(
|
|
67
|
+
ctx,
|
|
68
|
+
projectId,
|
|
69
|
+
switchLabel.label,
|
|
70
|
+
"review apps of this MR deploy (managed by catladder)",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const hasLabel = mr.labels?.includes(switchLabel.label);
|
|
74
|
+
if (hasLabel === (switchLabel.deployOn === "labeled")) {
|
|
75
|
+
ctx.log(`review apps of mr!${mr.iid} are already switched on`);
|
|
76
|
+
} else {
|
|
77
|
+
await doGitlabRequest(
|
|
78
|
+
ctx,
|
|
79
|
+
`projects/${projectId}/merge_requests/${mr.iid}`,
|
|
80
|
+
switchLabel.deployOn === "labeled"
|
|
81
|
+
? { add_labels: switchLabel.label }
|
|
82
|
+
: { remove_labels: switchLabel.label },
|
|
83
|
+
"PUT",
|
|
84
|
+
);
|
|
85
|
+
ctx.log(
|
|
86
|
+
`review apps of mr!${mr.iid} "${mr.title}" switched on — label '${switchLabel.label}' ${switchLabel.deployOn === "labeled" ? "added" : "removed"}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if ((await ctx.get("pipeline")) ?? true) {
|
|
91
|
+
const pipeline = await doGitlabRequest<{ web_url: string }>(
|
|
92
|
+
ctx,
|
|
93
|
+
`projects/${projectId}/merge_requests/${mr.iid}/pipelines`,
|
|
94
|
+
{},
|
|
95
|
+
"POST",
|
|
96
|
+
);
|
|
97
|
+
ctx.log(`🚀 triggered a pipeline that deploys them: ${pipeline.web_url}`);
|
|
98
|
+
} else {
|
|
99
|
+
ctx.log("the review apps deploy with the next pipeline of the MR");
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export const commandMrReviewAppOff = defineCommand({
|
|
105
|
+
name: "mr review-app-off",
|
|
106
|
+
description:
|
|
107
|
+
"stop deploying the review apps of a merge request: switches the reviewApps label off (opt-in: removes it, opt-out: adds the skip label). Running review apps stay until the MR closes or they auto-stop",
|
|
108
|
+
group: "mr",
|
|
109
|
+
inputs: mrInput,
|
|
110
|
+
execute: async (ctx) => {
|
|
111
|
+
const switchLabel = await getSwitch();
|
|
112
|
+
const { id: projectId } = await getProjectInfo(ctx);
|
|
113
|
+
const mr = await findMr(ctx, projectId, await ctx.get("mr"));
|
|
114
|
+
|
|
115
|
+
if (switchLabel.stopOn === "labeled") {
|
|
116
|
+
await ensureLabelExists(
|
|
117
|
+
ctx,
|
|
118
|
+
projectId,
|
|
119
|
+
switchLabel.label,
|
|
120
|
+
"review apps of this MR don't deploy (managed by catladder)",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const hasLabel = mr.labels?.includes(switchLabel.label);
|
|
124
|
+
if (hasLabel === (switchLabel.stopOn === "labeled")) {
|
|
125
|
+
ctx.log(`review apps of mr!${mr.iid} are already switched off`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
await doGitlabRequest(
|
|
129
|
+
ctx,
|
|
130
|
+
`projects/${projectId}/merge_requests/${mr.iid}`,
|
|
131
|
+
switchLabel.stopOn === "labeled"
|
|
132
|
+
? { add_labels: switchLabel.label }
|
|
133
|
+
: { remove_labels: switchLabel.label },
|
|
134
|
+
"PUT",
|
|
135
|
+
);
|
|
136
|
+
ctx.log(
|
|
137
|
+
`review apps of mr!${mr.iid} switched off — label '${switchLabel.label}' ${switchLabel.stopOn === "labeled" ? "added" : "removed"}`,
|
|
138
|
+
);
|
|
139
|
+
ctx.log(
|
|
140
|
+
"already running review apps stay until the MR is closed or they auto-stop — stop them earlier with the stop job of the MR pipeline",
|
|
141
|
+
);
|
|
142
|
+
},
|
|
143
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
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) and `label` (the switch
|
|
17
|
+
* label changed / a draft was marked ready): when the policy says
|
|
18
|
+
* so — and the CI is green. A label switched on while CI still runs
|
|
19
|
+
* is no deploy here: the trigger picks it up once CI is green.
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
type ResolvedReviewAppsConfig,
|
|
23
|
+
shouldAutoDeployReviewApps,
|
|
24
|
+
} from "../../../../packages/pipeline/src/reviewAppsPolicy";
|
|
25
|
+
import { appendFileSync } from "fs";
|
|
26
|
+
import { appendStepSummary, workflowLink } from "../release/stepSummary";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* the generated review deploy workflow. Keep in sync with
|
|
30
|
+
* REVIEW_DEPLOY_WORKFLOW_FILE in
|
|
31
|
+
* packages/pipeline/src/backends/github/reviewDeployWorkflow.ts.
|
|
32
|
+
*/
|
|
33
|
+
const GITHUB_REVIEW_DEPLOY_WORKFLOW = "catladder-deploy-review.yml";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* the review workflow's aggregate job — its check run says whether the
|
|
37
|
+
* PR's CI is green. Keep in sync with AGGREGATE_CHECK_JOB_NAME in
|
|
38
|
+
* packages/pipeline/src/backends/github/aggregateCheckJob.ts.
|
|
39
|
+
*/
|
|
40
|
+
const AGGREGATE_CHECK_NAME = "catladder ✅";
|
|
41
|
+
|
|
42
|
+
type PullRequest = {
|
|
43
|
+
number: number;
|
|
44
|
+
state: string;
|
|
45
|
+
draft: boolean;
|
|
46
|
+
labels: Array<{ name: string }>;
|
|
47
|
+
head: { sha: string; ref: string };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const requireEnv = (name: string): string => {
|
|
51
|
+
const value = process.env[name];
|
|
52
|
+
if (!value) {
|
|
53
|
+
throw new Error(`${name} is not set`);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const fail = (message: string): never => {
|
|
59
|
+
console.error(message);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const setStepOutput = (name: string, value: string) => {
|
|
64
|
+
const file = process.env.GITHUB_OUTPUT;
|
|
65
|
+
if (file) {
|
|
66
|
+
appendFileSync(file, `${name}=${value}\n`);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const getReviewAppsConfig = (): ResolvedReviewAppsConfig =>
|
|
71
|
+
JSON.parse(requireEnv("CL_REVIEW_APPS"));
|
|
72
|
+
|
|
73
|
+
const githubApi = async (path: string, init: RequestInit = {}) => {
|
|
74
|
+
const apiUrl = process.env.GITHUB_API_URL ?? "https://api.github.com";
|
|
75
|
+
const repository = requireEnv("GITHUB_REPOSITORY");
|
|
76
|
+
return fetch(`${apiUrl}/repos/${repository}/${path}`, {
|
|
77
|
+
...init,
|
|
78
|
+
headers: {
|
|
79
|
+
authorization: `Bearer ${requireEnv("GITHUB_TOKEN")}`,
|
|
80
|
+
accept: "application/vnd.github+json",
|
|
81
|
+
...init.headers,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const getPullRequest = async (prNumber: string): Promise<PullRequest> => {
|
|
87
|
+
const response = await githubApi(`pulls/${prNumber}`);
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`reading pull request #${prNumber} failed: ${response.status} ${await response.text()}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return (await response.json()) as PullRequest;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* the newest `catladder ✅` check run of a commit — null while the
|
|
98
|
+
* review workflow hasn't reported yet
|
|
99
|
+
*/
|
|
100
|
+
const getAggregateCheck = async (
|
|
101
|
+
sha: string,
|
|
102
|
+
): Promise<{ status: string; conclusion: string | null } | null> => {
|
|
103
|
+
const response = await githubApi(
|
|
104
|
+
`commits/${sha}/check-runs?check_name=${encodeURIComponent(AGGREGATE_CHECK_NAME)}&filter=latest`,
|
|
105
|
+
);
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`reading the checks of ${sha} failed: ${response.status} ${await response.text()}`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
const data = (await response.json()) as {
|
|
112
|
+
check_runs?: Array<{
|
|
113
|
+
id: number;
|
|
114
|
+
status: string;
|
|
115
|
+
conclusion: string | null;
|
|
116
|
+
}>;
|
|
117
|
+
};
|
|
118
|
+
const [newest] = [...(data.check_runs ?? [])].sort((a, b) => b.id - a.id);
|
|
119
|
+
return newest ?? null;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const short = (sha: string) => sha.slice(0, 7);
|
|
123
|
+
|
|
124
|
+
const mrState = (pr: PullRequest) => ({
|
|
125
|
+
labels: pr.labels.map((label) => label.name),
|
|
126
|
+
draft: pr.draft,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* `catci review-app github-guard` — first job of the review deploy
|
|
131
|
+
* workflow, sets the `deploy` step output
|
|
132
|
+
*/
|
|
133
|
+
export const githubReviewDeployGuardJob = async () => {
|
|
134
|
+
const config = getReviewAppsConfig();
|
|
135
|
+
const prNumber = requireEnv("CL_PR_NUMBER");
|
|
136
|
+
const source = process.env.CL_DEPLOY_SOURCE || "manual";
|
|
137
|
+
const headSha = requireEnv("CL_HEAD_SHA");
|
|
138
|
+
const explicit = source === "manual";
|
|
139
|
+
|
|
140
|
+
const skip = (message: string) => {
|
|
141
|
+
console.log(`not deploying: ${message}`);
|
|
142
|
+
appendStepSummary(`⏭️ **Not deploying** the review apps: ${message}`);
|
|
143
|
+
setStepOutput("deploy", "false");
|
|
144
|
+
};
|
|
145
|
+
// an explicit request that can't be served is an error, an automatic
|
|
146
|
+
// one simply doesn't deploy
|
|
147
|
+
const refuse = (message: string) => {
|
|
148
|
+
if (explicit) {
|
|
149
|
+
appendStepSummary(`❌ **Can't deploy** the review apps: ${message}`);
|
|
150
|
+
setStepOutput("deploy", "false");
|
|
151
|
+
fail(`can't deploy the review apps: ${message}`);
|
|
152
|
+
}
|
|
153
|
+
skip(message);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const pr = await getPullRequest(prNumber);
|
|
157
|
+
if (pr.state !== "open") {
|
|
158
|
+
return refuse(`pull request #${pr.number} is ${pr.state}`);
|
|
159
|
+
}
|
|
160
|
+
let reason = "requested explicitly";
|
|
161
|
+
if (!explicit) {
|
|
162
|
+
const decision = shouldAutoDeployReviewApps(config, mrState(pr));
|
|
163
|
+
if (!decision.deploy) {
|
|
164
|
+
return skip(decision.reason);
|
|
165
|
+
}
|
|
166
|
+
reason = decision.reason;
|
|
167
|
+
}
|
|
168
|
+
if (pr.head.sha !== headSha) {
|
|
169
|
+
return refuse(
|
|
170
|
+
explicit
|
|
171
|
+
? `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}')`
|
|
172
|
+
: `#${pr.number} moved on to ${short(pr.head.sha)} — its own review run decides`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const check = await getAggregateCheck(headSha);
|
|
176
|
+
if (!check || check.status !== "completed") {
|
|
177
|
+
return refuse(
|
|
178
|
+
`the CI of ${short(headSha)} (${AGGREGATE_CHECK_NAME}) is not finished yet` +
|
|
179
|
+
(explicit
|
|
180
|
+
? " — dispatch again once it is green"
|
|
181
|
+
: " — the review workflow starts the deploy once it is green"),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (check.conclusion !== "success") {
|
|
185
|
+
return refuse(
|
|
186
|
+
`the CI of ${short(headSha)} (${AGGREGATE_CHECK_NAME}) concluded '${check.conclusion}'`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
console.log(
|
|
190
|
+
`deploying the review apps of #${pr.number} (${short(headSha)}): ${reason}`,
|
|
191
|
+
);
|
|
192
|
+
appendStepSummary(
|
|
193
|
+
`🚀 **Deploying** the review apps of #${pr.number} (\`${short(headSha)}\`): ${reason}.`,
|
|
194
|
+
);
|
|
195
|
+
setStepOutput("deploy", "true");
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* `catci review-app github-trigger` — last job of the review workflow:
|
|
200
|
+
* dispatches the review deploy workflow when the PR deploys
|
|
201
|
+
*/
|
|
202
|
+
export const githubReviewDeployTriggerJob = async () => {
|
|
203
|
+
const config = getReviewAppsConfig();
|
|
204
|
+
const prNumber = requireEnv("CL_PR_NUMBER");
|
|
205
|
+
const headRef = requireEnv("CL_HEAD_REF");
|
|
206
|
+
const headSha = requireEnv("CL_HEAD_SHA");
|
|
207
|
+
|
|
208
|
+
const pr = await getPullRequest(prNumber);
|
|
209
|
+
if (pr.head.sha !== headSha) {
|
|
210
|
+
console.log(
|
|
211
|
+
`#${pr.number} moved on to ${short(pr.head.sha)} — its own review run decides`,
|
|
212
|
+
);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const decision = shouldAutoDeployReviewApps(config, mrState(pr));
|
|
216
|
+
if (!decision.deploy) {
|
|
217
|
+
console.log(`not deploying the review apps: ${decision.reason}`);
|
|
218
|
+
appendStepSummary(
|
|
219
|
+
`⏭️ Review apps not deployed: ${decision.reason}. ` +
|
|
220
|
+
`Deploy anyway: \`gh workflow run ${GITHUB_REVIEW_DEPLOY_WORKFLOW} --ref ${headRef} -f pr=${pr.number}\``,
|
|
221
|
+
);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const response = await githubApi(
|
|
225
|
+
`actions/workflows/${GITHUB_REVIEW_DEPLOY_WORKFLOW}/dispatches`,
|
|
226
|
+
{
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: { "content-type": "application/json" },
|
|
229
|
+
body: JSON.stringify({
|
|
230
|
+
ref: headRef,
|
|
231
|
+
inputs: { pr: String(pr.number), source: "ci" },
|
|
232
|
+
}),
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
if (response.status === 404) {
|
|
236
|
+
// github only dispatches workflows that exist on the default branch
|
|
237
|
+
const message = `the ${GITHUB_REVIEW_DEPLOY_WORKFLOW} workflow is not on the default branch yet — review apps deploy automatically once this change is merged`;
|
|
238
|
+
console.log(`::warning::${message}`);
|
|
239
|
+
appendStepSummary(`⚠️ ${message}`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (!response.ok) {
|
|
243
|
+
fail(
|
|
244
|
+
`dispatching ${GITHUB_REVIEW_DEPLOY_WORKFLOW} failed: ${response.status} ${await response.text()}`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
console.log(
|
|
248
|
+
`review deploy dispatched for #${pr.number} (${short(headSha)}): ${decision.reason}`,
|
|
249
|
+
);
|
|
250
|
+
appendStepSummary(
|
|
251
|
+
`▶️ **Review deploy started** for #${pr.number} (\`${short(headSha)}\`): ${decision.reason} — see ${workflowLink(GITHUB_REVIEW_DEPLOY_WORKFLOW, "catladder deploy review")}.`,
|
|
252
|
+
);
|
|
253
|
+
};
|