@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.
Files changed (65) hide show
  1. package/dist/apps/cli/src/catci.js +22 -0
  2. package/dist/apps/cli/src/catci.js.map +1 -1
  3. package/dist/apps/cli/src/commands/index.d.ts +1 -0
  4. package/dist/apps/cli/src/commands/index.js +4 -1
  5. package/dist/apps/cli/src/commands/index.js.map +1 -1
  6. package/dist/apps/cli/src/commands/project/commandMrPin.d.ts +9 -0
  7. package/dist/apps/cli/src/commands/project/commandMrPin.js +11 -13
  8. package/dist/apps/cli/src/commands/project/commandMrPin.js.map +1 -1
  9. package/dist/apps/cli/src/commands/project/commandMrReviewApp.d.ts +21 -0
  10. package/dist/apps/cli/src/commands/project/commandMrReviewApp.js +102 -0
  11. package/dist/apps/cli/src/commands/project/commandMrReviewApp.js.map +1 -0
  12. package/dist/apps/cli/src/reviewApps/github.d.ts +10 -0
  13. package/dist/apps/cli/src/reviewApps/github.js +240 -0
  14. package/dist/apps/cli/src/reviewApps/github.js.map +1 -0
  15. package/dist/bundles/catci/index.js +5 -5
  16. package/dist/bundles/catenv/index.js +897 -25
  17. package/dist/bundles/cli/index.js +1021 -39
  18. package/dist/bundles/skills/catladder-cli/references/commands.md +13 -0
  19. package/dist/bundles/skills/catladder-config/SKILL.md +4 -0
  20. package/dist/bundles/skills/catladder-deploys/SKILL.md +6 -0
  21. package/dist/bundles/skills/catladder-pipelines/SKILL.md +85 -1
  22. package/dist/packages/pipeline/src/backends/github/GithubBackend.js +60 -3
  23. package/dist/packages/pipeline/src/backends/github/GithubBackend.js.map +1 -1
  24. package/dist/packages/pipeline/src/backends/github/aggregateCheckJob.d.ts +1 -1
  25. package/dist/packages/pipeline/src/backends/github/aggregateCheckJob.js +22 -8
  26. package/dist/packages/pipeline/src/backends/github/aggregateCheckJob.js.map +1 -1
  27. package/dist/packages/pipeline/src/backends/github/reviewDeployWorkflow.d.ts +128 -0
  28. package/dist/packages/pipeline/src/backends/github/reviewDeployWorkflow.js +510 -0
  29. package/dist/packages/pipeline/src/backends/github/reviewDeployWorkflow.js.map +1 -0
  30. package/dist/packages/pipeline/src/backends/gitlab/GitlabBackend.js +29 -4
  31. package/dist/packages/pipeline/src/backends/gitlab/GitlabBackend.js.map +1 -1
  32. package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.d.ts +11 -1
  33. package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.js +17 -5
  34. package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.js.map +1 -1
  35. package/dist/packages/pipeline/src/backends/gitlab/reviewDeployGate.d.ts +26 -0
  36. package/dist/packages/pipeline/src/backends/gitlab/reviewDeployGate.js +94 -0
  37. package/dist/packages/pipeline/src/backends/gitlab/reviewDeployGate.js.map +1 -0
  38. package/dist/packages/pipeline/src/deploy/base/deploy.js +10 -3
  39. package/dist/packages/pipeline/src/deploy/base/deploy.js.map +1 -1
  40. package/dist/packages/pipeline/src/index.d.ts +1 -0
  41. package/dist/packages/pipeline/src/index.js +1 -0
  42. package/dist/packages/pipeline/src/index.js.map +1 -1
  43. package/dist/packages/pipeline/src/pipeline/resolveRequirements.d.ts +2 -0
  44. package/dist/packages/pipeline/src/pipeline/resolveRequirements.js +3 -2
  45. package/dist/packages/pipeline/src/pipeline/resolveRequirements.js.map +1 -1
  46. package/dist/packages/pipeline/src/reviewApps.d.ts +38 -0
  47. package/dist/packages/pipeline/src/reviewApps.js +86 -0
  48. package/dist/packages/pipeline/src/reviewApps.js.map +1 -0
  49. package/dist/packages/pipeline/src/reviewAppsPolicy.d.ts +46 -0
  50. package/dist/packages/pipeline/src/reviewAppsPolicy.js +37 -0
  51. package/dist/packages/pipeline/src/reviewAppsPolicy.js.map +1 -0
  52. package/dist/packages/pipeline/src/types/config.d.ts +55 -0
  53. package/dist/packages/pipeline/src/types/config.js.map +1 -1
  54. package/dist/skills/catladder-cli/references/commands.md +13 -0
  55. package/dist/skills/catladder-config/SKILL.md +4 -0
  56. package/dist/skills/catladder-deploys/SKILL.md +6 -0
  57. package/dist/skills/catladder-pipelines/SKILL.md +85 -1
  58. package/dist/tsconfig.tsbuildinfo +1 -1
  59. package/package.json +1 -1
  60. package/src/catci.ts +29 -0
  61. package/src/commands/index.ts +4 -0
  62. package/src/commands/project/commandMrPin.ts +15 -14
  63. package/src/commands/project/commandMrReviewApp.ts +143 -0
  64. package/src/reviewApps/__tests__/github.test.ts +118 -0
  65. package/src/reviewApps/github.ts +307 -0
package/package.json CHANGED
@@ -53,7 +53,7 @@
53
53
  }
54
54
  ],
55
55
  "license": "MIT",
56
- "version": "5.6.0",
56
+ "version": "5.7.1",
57
57
  "scripts": {
58
58
  "lint": "eslint \"src/**/*.ts\"",
59
59
  "lint:fix": "eslint \"src/**/*.ts\" --fix",
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) {
@@ -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 ensurePinLabelExists = async (
64
+ export const ensureLabelExists = async (
65
65
  io: IO,
66
66
  projectId: string,
67
- pinLabel: string,
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(pinLabel)}`,
72
+ `projects/${projectId}/labels?search=${encodeURIComponent(name)}`,
72
73
  );
73
- if (existing?.some((label) => label.name === pinLabel)) {
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 '${pinLabel}' label in the project`);
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 ensurePinLabelExists(ctx, projectId, pinLabel);
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,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
+ });