@catladder/cli 5.0.1 → 5.1.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 (131) hide show
  1. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithub.js +3 -3
  2. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithub.js.map +1 -1
  3. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithubImageCasing.d.ts +15 -0
  4. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithubImageCasing.js +60 -0
  5. package/dist/apps/cli/src/apps/cli/commands/project/doctor/checkGithubImageCasing.js.map +1 -0
  6. package/dist/apps/cli/src/apps/cli/commands/project/doctor/index.js +2 -0
  7. package/dist/apps/cli/src/apps/cli/commands/project/doctor/index.js.map +1 -1
  8. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupAccessTokens.js +2 -2
  9. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupAccessTokens.js.map +1 -1
  10. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js +2 -2
  11. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js.map +1 -1
  12. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupKubernetes.js +23 -7
  13. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupKubernetes.js.map +1 -1
  14. package/dist/apps/cli/src/commands/index.d.ts +1 -0
  15. package/dist/apps/cli/src/commands/index.js +4 -1
  16. package/dist/apps/cli/src/commands/index.js.map +1 -1
  17. package/dist/apps/cli/src/commands/project/commandConfigSecrets.js +2 -1
  18. package/dist/apps/cli/src/commands/project/commandConfigSecrets.js.map +1 -1
  19. package/dist/apps/cli/src/commands/project/commandMrPin.d.ts +21 -0
  20. package/dist/apps/cli/src/commands/project/commandMrPin.js +119 -0
  21. package/dist/apps/cli/src/commands/project/commandMrPin.js.map +1 -0
  22. package/dist/apps/cli/src/commands/project/commandSecretsSyncGithub.d.ts +1 -42
  23. package/dist/apps/cli/src/commands/project/commandSecretsSyncGithub.js +5 -124
  24. package/dist/apps/cli/src/commands/project/commandSecretsSyncGithub.js.map +1 -1
  25. package/dist/apps/cli/src/commands/project/secrets/commandSecretsPush.js +2 -1
  26. package/dist/apps/cli/src/commands/project/secrets/commandSecretsPush.js.map +1 -1
  27. package/dist/apps/cli/src/commands/project/secrets/commandSecretsSet.js +2 -2
  28. package/dist/apps/cli/src/commands/project/secrets/commandSecretsSet.js.map +1 -1
  29. package/dist/apps/cli/src/commands/project/secrets/document.d.ts +1 -12
  30. package/dist/apps/cli/src/commands/project/secrets/document.js +1 -79
  31. package/dist/apps/cli/src/commands/project/secrets/document.js.map +1 -1
  32. package/dist/apps/cli/src/gcloud/serviceAccounts.js +15 -4
  33. package/dist/apps/cli/src/gcloud/serviceAccounts.js.map +1 -1
  34. package/dist/apps/cli/src/secrets/github.d.ts +41 -0
  35. package/dist/apps/cli/src/secrets/github.js +127 -0
  36. package/dist/apps/cli/src/secrets/github.js.map +1 -0
  37. package/dist/apps/cli/src/secrets/index.d.ts +2 -0
  38. package/dist/apps/cli/src/secrets/index.js +19 -0
  39. package/dist/apps/cli/src/secrets/index.js.map +1 -0
  40. package/dist/apps/cli/src/secrets/writeSecrets.d.ts +18 -0
  41. package/dist/apps/cli/src/secrets/writeSecrets.js +88 -0
  42. package/dist/apps/cli/src/secrets/writeSecrets.js.map +1 -0
  43. package/dist/apps/cli/src/utils/github.js +2 -2
  44. package/dist/apps/cli/src/utils/github.js.map +1 -1
  45. package/dist/apps/cli/src/utils/gitlab.d.ts +10 -0
  46. package/dist/apps/cli/src/utils/gitlab.js +30 -5
  47. package/dist/apps/cli/src/utils/gitlab.js.map +1 -1
  48. package/dist/apps/cli/src/vault/BitwardenVault.d.ts +6 -0
  49. package/dist/apps/cli/src/vault/BitwardenVault.js +11 -1
  50. package/dist/apps/cli/src/vault/BitwardenVault.js.map +1 -1
  51. package/dist/apps/cli/src/vault/GitlabVault.d.ts +2 -2
  52. package/dist/apps/cli/src/vault/GitlabVault.js +3 -2
  53. package/dist/apps/cli/src/vault/GitlabVault.js.map +1 -1
  54. package/dist/apps/cli/src/vault/VaultManager.d.ts +2 -2
  55. package/dist/apps/cli/src/vault/VaultManager.js +2 -2
  56. package/dist/apps/cli/src/vault/VaultManager.js.map +1 -1
  57. package/dist/apps/cli/src/vault/types.d.ts +16 -2
  58. package/dist/bundles/catenv/index.js +265 -28
  59. package/dist/bundles/cli/index.js +851 -329
  60. package/dist/bundles/skills/catladder-cli/references/commands.md +13 -0
  61. package/dist/bundles/skills/catladder-config/SKILL.md +3 -1
  62. package/dist/bundles/skills/catladder-deploys/SKILL.md +7 -0
  63. package/dist/bundles/skills/catladder-migrate-ci-backend/SKILL.md +18 -2
  64. package/dist/bundles/skills/catladder-pipelines/SKILL.md +51 -1
  65. package/dist/bundles/skills/catladder-secrets/SKILL.md +16 -0
  66. package/dist/packages/pipeline/src/autoStop.d.ts +22 -0
  67. package/dist/packages/pipeline/src/autoStop.js +30 -0
  68. package/dist/packages/pipeline/src/autoStop.js.map +1 -0
  69. package/dist/packages/pipeline/src/backends/github/GithubBackend.js +11 -5
  70. package/dist/packages/pipeline/src/backends/github/GithubBackend.js.map +1 -1
  71. package/dist/packages/pipeline/src/backends/github/ciVariables.d.ts +13 -2
  72. package/dist/packages/pipeline/src/backends/github/ciVariables.js +18 -5
  73. package/dist/packages/pipeline/src/backends/github/ciVariables.js.map +1 -1
  74. package/dist/packages/pipeline/src/backends/github/createGithubJobs.js +1 -1
  75. package/dist/packages/pipeline/src/backends/github/createGithubJobs.js.map +1 -1
  76. package/dist/packages/pipeline/src/backends/github/ghcr.d.ts +29 -0
  77. package/dist/packages/pipeline/src/backends/github/ghcr.js +40 -0
  78. package/dist/packages/pipeline/src/backends/github/ghcr.js.map +1 -0
  79. package/dist/packages/pipeline/src/backends/github/index.d.ts +2 -0
  80. package/dist/packages/pipeline/src/backends/github/index.js +2 -0
  81. package/dist/packages/pipeline/src/backends/github/index.js.map +1 -1
  82. package/dist/packages/pipeline/src/backends/github/registryImage.d.ts +20 -0
  83. package/dist/packages/pipeline/src/backends/github/registryImage.js +49 -0
  84. package/dist/packages/pipeline/src/backends/github/registryImage.js.map +1 -0
  85. package/dist/packages/pipeline/src/backends/gitlab/GitlabBackend.js +2 -0
  86. package/dist/packages/pipeline/src/backends/gitlab/GitlabBackend.js.map +1 -1
  87. package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.d.ts +4 -1
  88. package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.js +21 -1
  89. package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.js.map +1 -1
  90. package/dist/packages/pipeline/src/customImages/jobImagesPlan.d.ts +15 -3
  91. package/dist/packages/pipeline/src/customImages/jobImagesPlan.js +14 -3
  92. package/dist/packages/pipeline/src/customImages/jobImagesPlan.js.map +1 -1
  93. package/dist/packages/pipeline/src/deploy/base/deploy.js +8 -2
  94. package/dist/packages/pipeline/src/deploy/base/deploy.js.map +1 -1
  95. package/dist/packages/pipeline/src/index.d.ts +1 -0
  96. package/dist/packages/pipeline/src/index.js +1 -0
  97. package/dist/packages/pipeline/src/index.js.map +1 -1
  98. package/dist/packages/pipeline/src/types/config.d.ts +44 -0
  99. package/dist/skills/catladder-cli/references/commands.md +13 -0
  100. package/dist/skills/catladder-config/SKILL.md +3 -1
  101. package/dist/skills/catladder-deploys/SKILL.md +7 -0
  102. package/dist/skills/catladder-migrate-ci-backend/SKILL.md +18 -2
  103. package/dist/skills/catladder-pipelines/SKILL.md +51 -1
  104. package/dist/skills/catladder-secrets/SKILL.md +16 -0
  105. package/dist/tsconfig.tsbuildinfo +1 -1
  106. package/package.json +1 -1
  107. package/src/apps/cli/commands/project/doctor/checkGithub.ts +1 -1
  108. package/src/apps/cli/commands/project/doctor/checkGithubImageCasing.ts +87 -0
  109. package/src/apps/cli/commands/project/doctor/index.ts +2 -0
  110. package/src/apps/cli/commands/project/setup/setupAccessTokens.ts +3 -2
  111. package/src/apps/cli/commands/project/setup/setupGithub.ts +1 -1
  112. package/src/apps/cli/commands/project/setup/setupKubernetes.ts +31 -20
  113. package/src/commands/index.ts +1 -0
  114. package/src/commands/project/commandConfigSecrets.ts +1 -1
  115. package/src/commands/project/commandMrPin.ts +184 -0
  116. package/src/commands/project/commandSecretsSyncGithub.ts +7 -154
  117. package/src/commands/project/secrets/commandSecretsPush.ts +2 -5
  118. package/src/commands/project/secrets/commandSecretsSet.ts +2 -2
  119. package/src/commands/project/secrets/document.ts +2 -127
  120. package/src/gcloud/serviceAccounts.ts +16 -7
  121. package/src/secrets/__tests__/writeSecrets.test.ts +147 -0
  122. package/src/secrets/github.ts +157 -0
  123. package/src/secrets/index.ts +2 -0
  124. package/src/secrets/writeSecrets.ts +138 -0
  125. package/src/utils/github.ts +2 -4
  126. package/src/utils/gitlab.ts +34 -7
  127. package/src/vault/BitwardenVault.ts +10 -1
  128. package/src/vault/GitlabVault.ts +9 -2
  129. package/src/vault/VaultManager.ts +3 -2
  130. package/src/vault/__tests__/BitwardenVault.test.ts +88 -0
  131. package/src/vault/types.ts +17 -1
package/package.json CHANGED
@@ -53,7 +53,7 @@
53
53
  }
54
54
  ],
55
55
  "license": "MIT",
56
- "version": "5.0.1",
56
+ "version": "5.1.1",
57
57
  "scripts": {
58
58
  "lint": "eslint \"src/**/*.ts\"",
59
59
  "lint:fix": "eslint \"src/**/*.ts\" --fix",
@@ -7,7 +7,7 @@ import {
7
7
  import {
8
8
  collectSecretTargets,
9
9
  getConfiguredGithubRepo,
10
- } from "../../../../../commands/project/commandSecretsSyncGithub";
10
+ } from "../../../../../secrets";
11
11
  import { mapWithConcurrency } from "../../../../../utils/concurrency";
12
12
  import {
13
13
  ghApiJson,
@@ -0,0 +1,87 @@
1
+ import { readdir, readFile } from "fs/promises";
2
+ import { join } from "path";
3
+ import type { Config } from "@catladder/pipeline";
4
+ import {
5
+ GHCR_REPOSITORY_EXPRESSION,
6
+ getEnabledPipelineTypes,
7
+ getPipelineGitRemote,
8
+ } from "@catladder/pipeline";
9
+ import { getConfiguredGithubRepo } from "../../../../../secrets";
10
+ import type { DoctorReport } from "./DoctorReport";
11
+
12
+ const WORKFLOWS_FOLDER = ".github/workflows";
13
+ const GENERATED_FILE_PREFIX = "catladder-";
14
+
15
+ /**
16
+ * the generated workflow files, or an empty list when none exist yet
17
+ */
18
+ const readGeneratedWorkflows = async (): Promise<string[]> => {
19
+ const entries = await readdir(WORKFLOWS_FOLDER).catch(() => []);
20
+ return Promise.all(
21
+ entries
22
+ .filter((file) => file.startsWith(GENERATED_FILE_PREFIX))
23
+ .map((file) => readFile(join(WORKFLOWS_FOLDER, file), "utf8")),
24
+ );
25
+ };
26
+
27
+ /**
28
+ * ghcr image paths must be lowercase, but `${{ github.repository }}`
29
+ * interpolates github's display casing — so a repository whose owner or
30
+ * name contains an uppercase character needs a lowercased literal in the
31
+ * generated workflows. Generation bakes that in, which means committed
32
+ * workflows generated by an older catladder (or hand-edited ones) still
33
+ * carry the expression and fail every docker job with
34
+ * `repository name must be lowercase`.
35
+ *
36
+ * Unlike the rest of the github checks this needs no `gh` login — only
37
+ * the git remote and the committed files.
38
+ */
39
+ export const checkGithubImageCasing = async (
40
+ report: DoctorReport,
41
+ config: Config,
42
+ ) => {
43
+ if (!getEnabledPipelineTypes(config).includes("github")) return;
44
+
45
+ const repo = await getConfiguredGithubRepo();
46
+ if (!repo || repo === repo.toLowerCase()) {
47
+ // an all-lowercase repository keeps the expression on purpose (it
48
+ // stays correct in forks), and an unresolvable remote is reported
49
+ // by checkGithub already
50
+ return;
51
+ }
52
+
53
+ report.section("github image names");
54
+
55
+ const workflows = await readGeneratedWorkflows();
56
+ if (workflows.length === 0) {
57
+ report.warn(
58
+ `no generated workflows in ${WORKFLOWS_FOLDER} to check`,
59
+ "run: catenv",
60
+ );
61
+ return;
62
+ }
63
+
64
+ const stale = workflows.filter((content) =>
65
+ content.includes(GHCR_REPOSITORY_EXPRESSION),
66
+ );
67
+ if (stale.length > 0) {
68
+ report.fail(
69
+ `'${repo}' is mixed-case, but ${stale.length}/${workflows.length} generated workflows still use ${GHCR_REPOSITORY_EXPRESSION} — ghcr rejects uppercase image paths, so every docker job fails with "repository name must be lowercase"`,
70
+ "run: catenv (and commit the regenerated workflows)",
71
+ );
72
+ return;
73
+ }
74
+
75
+ const uppercase = workflows.flatMap((content) =>
76
+ [...content.matchAll(/ghcr\.io\/\S*[A-Z]\S*/g)].map(([match]) => match),
77
+ );
78
+ if (uppercase.length > 0) {
79
+ report.fail(
80
+ `uppercase ghcr image paths in the generated workflows: ${[...new Set(uppercase)].join(", ")}`,
81
+ `run: catenv — if it persists, set pipelines.github.repository (the '${getPipelineGitRemote(config, "github")}' remote resolves to '${repo}')`,
82
+ );
83
+ return;
84
+ }
85
+
86
+ report.ok(`ghcr image paths lowercased for '${repo}'`);
87
+ };
@@ -7,6 +7,7 @@ import {
7
7
  import type { IO } from "../../../../../core/types";
8
8
  import { checkCloudRun } from "./checkCloudRun";
9
9
  import { checkGithub } from "./checkGithub";
10
+ import { checkGithubImageCasing } from "./checkGithubImageCasing";
10
11
  import { checkGitlab } from "./checkGitlab";
11
12
  import { checkStore } from "./checkStore";
12
13
  import { DoctorReport } from "./DoctorReport";
@@ -53,6 +54,7 @@ export const doctorProject = async (
53
54
  await checkGitlab(instance, report, contexts);
54
55
  }
55
56
  await checkCloudRun(report, contexts);
57
+ await checkGithubImageCasing(report, config);
56
58
  await checkGithub(report, config);
57
59
 
58
60
  report.summarize();
@@ -4,6 +4,7 @@ import {
4
4
  doGitlabRequest,
5
5
  doGitlabRequestAllPages,
6
6
  getProjectInfo,
7
+ GitlabNotFoundError,
7
8
  } from "../../../../../utils/gitlab";
8
9
 
9
10
  const TOKEN_NAME = "semantic-release";
@@ -55,10 +56,10 @@ export const setupAccessTokens = async (instance: IO) => {
55
56
  "PUT",
56
57
  );
57
58
  } catch (e) {
58
- if (e.message !== "not found") {
59
+ if (!(e instanceof GitlabNotFoundError)) {
59
60
  throw e;
60
61
  }
61
- // not found
62
+ // the variable does not exist yet: create it
62
63
  await doGitlabRequest(
63
64
  instance,
64
65
  `projects/${projectId}/variables`,
@@ -9,7 +9,7 @@ import { mkdtemp, readFile, rm } from "fs/promises";
9
9
  import { tmpdir } from "os";
10
10
  import { join } from "path";
11
11
  import { promisify } from "util";
12
- import { getConfiguredGithubRepo } from "../../../../../commands/project/commandSecretsSyncGithub";
12
+ import { getConfiguredGithubRepo } from "../../../../../secrets";
13
13
  import type { IO } from "../../../../../core/types";
14
14
  import {
15
15
  ghApiJson,
@@ -1,27 +1,26 @@
1
- import type { ComponentContext } from "@catladder/pipeline";
1
+ import type { ComponentContext, Config } from "@catladder/pipeline";
2
2
  import {
3
+ getEnabledPipelineTypes,
3
4
  getFullKubernetesClusterName,
4
5
  isOfDeployType,
5
6
  } from "@catladder/pipeline";
7
+ import { getProjectConfig } from "../../../../../config/getProjectConfig";
6
8
  import type { IO } from "../../../../../core/types";
7
9
  import { exec } from "child-process-promise";
10
+ import { writeSecretsAndMirror } from "../../../../../secrets";
8
11
  import { connectToCluster } from "../../../../../utils/cluster";
9
- import {
10
- doGitlabRequest,
11
- getProjectInfo,
12
- upsertAllVariables,
13
- } from "../../../../../utils/gitlab";
12
+ import { doGitlabRequest, getProjectInfo } from "../../../../../utils/gitlab";
14
13
  import ensureNamespace from "../utils/ensureNamespace";
15
14
 
16
- export const setupKubernetes = async (
17
- instance: IO,
18
- context: ComponentContext,
19
- ) => {
20
- const deployConfig = context.deploy?.config;
21
- if (!isOfDeployType(deployConfig, "kubernetes")) {
22
- throw new Error("cannot run setupKubernetes on non-kubernetes deployments");
15
+ /**
16
+ * the cluster pulls the images from the gitlab container registry, and
17
+ * a deploy token is how it authenticates there. Nothing to do when the
18
+ * project has no gitlab pipeline (the images live elsewhere then).
19
+ */
20
+ const ensureGitlabDeployToken = async (instance: IO, config: Config | null) => {
21
+ if (!config || !getEnabledPipelineTypes(config).includes("gitlab")) {
22
+ return;
23
23
  }
24
-
25
24
  const { id: projectId } = await getProjectInfo(instance);
26
25
  const deploy_tokens = await doGitlabRequest(
27
26
  instance,
@@ -48,6 +47,18 @@ export const setupKubernetes = async (
48
47
  "POST",
49
48
  );
50
49
  }
50
+ };
51
+
52
+ export const setupKubernetes = async (
53
+ instance: IO,
54
+ context: ComponentContext,
55
+ ) => {
56
+ const deployConfig = context.deploy?.config;
57
+ if (!isOfDeployType(deployConfig, "kubernetes")) {
58
+ throw new Error("cannot run setupKubernetes on non-kubernetes deployments");
59
+ }
60
+
61
+ await ensureGitlabDeployToken(instance, await getProjectConfig());
51
62
 
52
63
  const fullName = getFullKubernetesClusterName(deployConfig.cluster);
53
64
  instance.log(`cluster: ${fullName}`);
@@ -158,14 +169,14 @@ EOF
158
169
  instance.log("service accounts created / updated!");
159
170
 
160
171
  instance.log("");
161
- instance.log("pusing secrets to gitlab...");
172
+ instance.log("storing the cluster credentials...");
162
173
 
163
- await upsertAllVariables(
174
+ await writeSecretsAndMirror(
164
175
  instance,
165
- vars,
166
- context.env,
167
- context.name,
168
- false, // no backup
176
+ [{ env: context.env, componentName: context.name, secrets: vars }],
177
+ // the service account token is recreated on every run — the
178
+ // previous one is worth nothing
179
+ { backup: false },
169
180
  );
170
181
  instance.log("done!");
171
182
  };
@@ -29,6 +29,7 @@ export { commandDoctor } from "./project/commandDoctor";
29
29
  export { commandCiJobOpen, commandCiJobLog } from "./project/commandGitlabCi";
30
30
  export { commandProjectRestoreDb } from "./project/commandProjectRestoreDb";
31
31
  export { commandSecurityEvaluate } from "./project/commandSecurityEvaluate";
32
+ export { commandMrPin, commandMrUnpin } from "./project/commandMrPin";
32
33
 
33
34
  // Project k8s commands
34
35
  export { commandNamespace } from "./project/k8s/commandNamespace";
@@ -2,11 +2,11 @@ import { stripIndents } from "common-tags";
2
2
  import { defineCommand } from "../../core/defineCommand";
3
3
  import { editAsFile } from "../../utils/editAsFile";
4
4
  import { delay } from "../../utils/promise";
5
+ import { writeSecretsAndMirror } from "../../secrets";
5
6
  import {
6
7
  buildSecretsDocument,
7
8
  checkSecretsDocument,
8
9
  collectSecretsWrites,
9
- writeSecretsAndMirror,
10
10
  } from "./secrets/document";
11
11
  import {
12
12
  formatScope,
@@ -0,0 +1,184 @@
1
+ import { exec } from "child-process-promise";
2
+ import {
3
+ getAutoStopConfig,
4
+ getEnabledPipelineTypes,
5
+ } from "@catladder/pipeline";
6
+ import { getProjectConfig } from "../../config/getProjectConfig";
7
+ import { defineCommand } from "../../core/defineCommand";
8
+ import type { IO } from "../../core/types";
9
+ import { doGitlabRequest, getProjectInfo } from "../../utils/gitlab";
10
+
11
+ const PIN_LABEL_COLOR = "#428fdc";
12
+
13
+ type GitlabMr = {
14
+ iid: number;
15
+ title: string;
16
+ web_url: string;
17
+ labels: string[];
18
+ };
19
+
20
+ /**
21
+ * the configured pin label — errors when the project cannot pin
22
+ * (github-only, or pinning disabled)
23
+ */
24
+ const getPinLabel = async (): Promise<string> => {
25
+ const config = await getProjectConfig();
26
+ if (!config) {
27
+ throw new Error("no catladder config found");
28
+ }
29
+ if (!getEnabledPipelineTypes(config).includes("gitlab")) {
30
+ throw new Error(
31
+ "pinning review apps needs the gitlab pipeline — github has no auto-stop, review apps live until the pull request is closed",
32
+ );
33
+ }
34
+ const { pinLabel } = getAutoStopConfig(config);
35
+ if (pinLabel === false) {
36
+ throw new Error(
37
+ "review-app pinning is disabled in this project (autoStop.pinLabel: false)",
38
+ );
39
+ }
40
+ return pinLabel;
41
+ };
42
+
43
+ const findMr = async (
44
+ io: IO,
45
+ projectId: string,
46
+ mrIid: number | undefined,
47
+ ): Promise<GitlabMr> => {
48
+ if (mrIid) {
49
+ return doGitlabRequest(io, `projects/${projectId}/merge_requests/${mrIid}`);
50
+ }
51
+ const branch = (await exec("git rev-parse --abbrev-ref HEAD")).stdout.trim();
52
+ const mrs = await doGitlabRequest<GitlabMr[]>(
53
+ io,
54
+ `projects/${projectId}/merge_requests?state=opened&source_branch=${encodeURIComponent(branch)}`,
55
+ );
56
+ if (!mrs?.length) {
57
+ throw new Error(
58
+ `no open merge request found for branch '${branch}' — pass one with --mr <iid>`,
59
+ );
60
+ }
61
+ return mrs[0];
62
+ };
63
+
64
+ const ensurePinLabelExists = async (
65
+ io: IO,
66
+ projectId: string,
67
+ pinLabel: string,
68
+ ) => {
69
+ const existing = await doGitlabRequest<Array<{ name: string }>>(
70
+ io,
71
+ `projects/${projectId}/labels?search=${encodeURIComponent(pinLabel)}`,
72
+ );
73
+ if (existing?.some((label) => label.name === pinLabel)) {
74
+ return;
75
+ }
76
+ await doGitlabRequest(
77
+ io,
78
+ `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
+ },
85
+ "POST",
86
+ );
87
+ io.log(`created the '${pinLabel}' label in the project`);
88
+ };
89
+
90
+ export const commandMrPin = defineCommand({
91
+ name: "mr pin",
92
+ description:
93
+ "pin the review apps of a merge request (disable their auto-stop) by adding the pin label; merging or closing the MR still stops them",
94
+ group: "mr",
95
+ inputs: {
96
+ mr: {
97
+ type: "number",
98
+ message:
99
+ "merge request IID (defaults to the open MR of the current branch)",
100
+ positional: true,
101
+ required: false,
102
+ },
103
+ pipeline: {
104
+ type: "boolean",
105
+ message:
106
+ "trigger a merge-request pipeline so the pin takes effect immediately",
107
+ required: false,
108
+ },
109
+ },
110
+ execute: async (ctx) => {
111
+ const pinLabel = await getPinLabel();
112
+ const { id: projectId } = await getProjectInfo(ctx);
113
+ const mr = await findMr(ctx, projectId, await ctx.get("mr"));
114
+
115
+ await ensurePinLabelExists(ctx, projectId, pinLabel);
116
+
117
+ if (mr.labels?.includes(pinLabel)) {
118
+ ctx.log(`already pinned: mr!${mr.iid} carries the '${pinLabel}' label`);
119
+ } else {
120
+ await doGitlabRequest(
121
+ ctx,
122
+ `projects/${projectId}/merge_requests/${mr.iid}`,
123
+ { add_labels: pinLabel },
124
+ "PUT",
125
+ );
126
+ ctx.log(
127
+ `📌 pinned mr!${mr.iid} "${mr.title}" — label '${pinLabel}' added`,
128
+ );
129
+ }
130
+
131
+ // the label only affects pipelines created after it was set, so
132
+ // trigger one — otherwise the currently deployed review apps keep
133
+ // their old auto-stop timer until the next push
134
+ if ((await ctx.get("pipeline")) ?? true) {
135
+ const pipeline = await doGitlabRequest<{ web_url: string }>(
136
+ ctx,
137
+ `projects/${projectId}/merge_requests/${mr.iid}/pipelines`,
138
+ {},
139
+ "POST",
140
+ );
141
+ ctx.log(
142
+ `🚀 triggered a pipeline so the pin takes effect now: ${pipeline.web_url}`,
143
+ );
144
+ } else {
145
+ ctx.log("the pin takes effect with the next pipeline of the MR");
146
+ }
147
+ },
148
+ });
149
+
150
+ export const commandMrUnpin = defineCommand({
151
+ name: "mr unpin",
152
+ description:
153
+ "unpin the review apps of a merge request — the auto-stop timer re-arms with the next deploy",
154
+ group: "mr",
155
+ inputs: {
156
+ mr: {
157
+ type: "number",
158
+ message:
159
+ "merge request IID (defaults to the open MR of the current branch)",
160
+ positional: true,
161
+ required: false,
162
+ },
163
+ },
164
+ execute: async (ctx) => {
165
+ const pinLabel = await getPinLabel();
166
+ const { id: projectId } = await getProjectInfo(ctx);
167
+ const mr = await findMr(ctx, projectId, await ctx.get("mr"));
168
+
169
+ if (!mr.labels?.includes(pinLabel)) {
170
+ ctx.log(`mr!${mr.iid} is not pinned (no '${pinLabel}' label)`);
171
+ return;
172
+ }
173
+ await doGitlabRequest(
174
+ ctx,
175
+ `projects/${projectId}/merge_requests/${mr.iid}`,
176
+ { remove_labels: pinLabel },
177
+ "PUT",
178
+ );
179
+ ctx.log(`unpinned mr!${mr.iid} — label '${pinLabel}' removed`);
180
+ ctx.log(
181
+ "the deployed review apps stay pinned until the next deploy re-arms their auto-stop timer",
182
+ );
183
+ },
184
+ });
@@ -1,163 +1,16 @@
1
- import type { Config } from "@catladder/pipeline";
2
1
  import {
3
2
  getEnabledPipelineTypes,
4
3
  getPipelineGitRemote,
5
- getSecretVarName,
6
- GithubBackend,
7
4
  } from "@catladder/pipeline";
8
- import {
9
- getAllComponentsWithAllEnvsHierarchical,
10
- getEnvironment,
11
- getProjectConfig,
12
- } from "../../config/getProjectConfig";
5
+ import { getProjectConfig } from "../../config/getProjectConfig";
13
6
  import { defineCommand } from "../../core/defineCommand";
14
- import type { IO } from "../../core/types";
15
7
  import {
16
- ensureGithubEnvironment,
17
- getGithubRepoFromRemote,
18
- isGhAuthenticated,
19
- setGithubSecret,
20
- setGithubVariable,
21
- } from "../../utils/github";
22
-
23
- /**
24
- * all secrets declared in the config, with their values read from the
25
- * vault (github secrets are write-only and can never act as a source)
26
- */
27
- export const collectSecretsFromVault = async (io: IO, envFilter?: string[]) => {
28
- const envAndComponents = await getAllComponentsWithAllEnvsHierarchical();
29
-
30
- const kinds = new Map<string, "secret" | "variable">();
31
- for (const [componentName, envs] of Object.entries(envAndComponents)) {
32
- for (const env of envs) {
33
- if (envFilter && !envFilter.includes(env)) {
34
- continue;
35
- }
36
- const { secretEnvVarKeys, jobOnlyVars } = await getEnvironment(
37
- env,
38
- componentName,
39
- );
40
- const keys = [
41
- ...secretEnvVarKeys,
42
- ...jobOnlyVars.build.secretEnvVarKeys,
43
- ...jobOnlyVars.deploy.secretEnvVarKeys,
44
- ];
45
- keys.forEach(({ key, kind }) =>
46
- kinds.set(getSecretVarName(env, componentName, key), kind ?? "secret"),
47
- );
48
- }
49
- }
50
-
51
- const names = [...kinds.keys()];
52
- const values = await (await io.getVaultManager()).readSecrets(names, io);
53
- const secrets = names
54
- .filter((name) => values[name] !== undefined)
55
- .map((name) => ({
56
- name,
57
- value: values[name],
58
- kind: kinds.get(name) ?? ("secret" as const),
59
- }));
60
- const missing = names.filter((name) => values[name] === undefined);
61
- return { secrets, missing };
62
- };
63
-
64
- /** marker for repo-level (environment-less) targets */
65
- const REPO_LEVEL = "";
66
-
67
- /**
68
- * derives which github environment(s) each secret/variable must live in
69
- * by scanning the GENERATED workflows: a `${{ secrets.X }}` / `${{
70
- * vars.X }}` reference in a job's env targets that job's environment
71
- * (or the repo level when the job has none). Deriving from the real
72
- * output means the sync can never drift from what the workflows expect.
73
- *
74
- * Environment secrets are the answer to github's cap of 100 secrets
75
- * per REPO (a multi-env project easily exceeds it): the cap applies
76
- * per environment instead.
77
- */
78
- export const collectSecretTargets = async (
79
- config: Config,
80
- ): Promise<Map<string, Set<string>>> => {
81
- const workflows = await new GithubBackend().createWorkflows(config);
82
- const targets = new Map<string, Set<string>>();
83
- for (const workflow of Object.values(workflows)) {
84
- for (const job of Object.values(workflow.jobs)) {
85
- const environment =
86
- typeof job.environment === "object"
87
- ? job.environment.name
88
- : job.environment;
89
- for (const value of Object.values(job.env ?? {})) {
90
- if (typeof value !== "string") continue;
91
- for (const match of value.matchAll(
92
- /\$\{\{\s*(?:secrets|vars)\.([A-Za-z0-9_]+)\s*\}\}/g,
93
- )) {
94
- const name = match[1];
95
- if (!targets.has(name)) {
96
- targets.set(name, new Set());
97
- }
98
- targets.get(name)!.add(environment ?? REPO_LEVEL);
99
- }
100
- }
101
- }
102
- }
103
- return targets;
104
- };
105
-
106
- /**
107
- * pushes vault values to github via the gh cli: sensitive ones as
108
- * secrets (masked), "variable"-kind ones as variables (plain) — each
109
- * into the environment(s) whose jobs reference them
110
- */
111
- export const pushSecretsToGithub = async (
112
- io: IO,
113
- repo: string,
114
- secrets: { name: string; value: string; kind: "secret" | "variable" }[],
115
- targets: Map<string, Set<string>>,
116
- ) => {
117
- const environments = [
118
- ...new Set(
119
- [...targets.values()].flatMap((envs) =>
120
- [...envs].filter((env) => env !== REPO_LEVEL),
121
- ),
122
- ),
123
- ];
124
- for (const environment of environments) {
125
- await ensureGithubEnvironment(repo, environment);
126
- io.log(`🌍 environment ${environment}`);
127
- }
128
-
129
- for (const { name, value, kind } of secrets) {
130
- const envs = targets.get(name);
131
- if (!envs) {
132
- io.log(`⏭️ ${name} (not referenced by any generated workflow)`);
133
- continue;
134
- }
135
- for (const environment of envs) {
136
- const suffix = environment === REPO_LEVEL ? "" : ` → ${environment}`;
137
- if (kind === "variable") {
138
- await setGithubVariable(repo, name, value, environment || undefined);
139
- io.log(`📖 ${name} (variable)${suffix}`);
140
- } else {
141
- await setGithubSecret(repo, name, value, environment || undefined);
142
- io.log(`✅ ${name}${suffix}`);
143
- }
144
- }
145
- }
146
- };
147
-
148
- /**
149
- * resolves the github repository from the configured git remote
150
- * (`pipelines.github.gitRemote`, defaults to origin)
151
- */
152
- export const getConfiguredGithubRepo = async (): Promise<
153
- string | undefined
154
- > => {
155
- const config = await getProjectConfig();
156
- if (!config) {
157
- return undefined;
158
- }
159
- return getGithubRepoFromRemote(getPipelineGitRemote(config, "github"));
160
- };
8
+ collectSecretsFromVault,
9
+ collectSecretTargets,
10
+ getConfiguredGithubRepo,
11
+ pushSecretsToGithub,
12
+ } from "../../secrets";
13
+ import { isGhAuthenticated } from "../../utils/github";
161
14
 
162
15
  /**
163
16
  * copies the catladder secrets from the vault to github actions
@@ -2,12 +2,9 @@ import { readFile } from "fs-extra";
2
2
  import { parse } from "yaml";
3
3
  import { defineCommand } from "../../../core/defineCommand";
4
4
  import { readStdin } from "../../../utils/readStdin";
5
+ import { writeSecretsAndMirror } from "../../../secrets";
5
6
  import type { SecretsDocument } from "./document";
6
- import {
7
- checkSecretsDocument,
8
- collectSecretsWrites,
9
- writeSecretsAndMirror,
10
- } from "./document";
7
+ import { checkSecretsDocument, collectSecretsWrites } from "./document";
11
8
  import {
12
9
  getScopedSecretKeys,
13
10
  resolveSecretsScope,
@@ -2,8 +2,8 @@ import { readFile } from "fs-extra";
2
2
  import { defineCommand } from "../../../core/defineCommand";
3
3
  import type { IO } from "../../../core/types";
4
4
  import { readStdin } from "../../../utils/readStdin";
5
- import type { SecretsWrite } from "./document";
6
- import { writeSecretsAndMirror } from "./document";
5
+ import type { SecretsWrite } from "../../../secrets";
6
+ import { writeSecretsAndMirror } from "../../../secrets";
7
7
  import {
8
8
  getScopedSecretKeys,
9
9
  resolveSecretsScope,