@catladder/cli 5.0.0 → 5.1.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.
Files changed (115) 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/setup/setupAccessTokens.js +2 -2
  4. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupAccessTokens.js.map +1 -1
  5. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js +2 -2
  6. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupGithub.js.map +1 -1
  7. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupKubernetes.js +23 -7
  8. package/dist/apps/cli/src/apps/cli/commands/project/setup/setupKubernetes.js.map +1 -1
  9. package/dist/apps/cli/src/commands/index.d.ts +1 -0
  10. package/dist/apps/cli/src/commands/index.js +4 -1
  11. package/dist/apps/cli/src/commands/index.js.map +1 -1
  12. package/dist/apps/cli/src/commands/project/commandConfigSecrets.js +2 -1
  13. package/dist/apps/cli/src/commands/project/commandConfigSecrets.js.map +1 -1
  14. package/dist/apps/cli/src/commands/project/commandMrPin.d.ts +21 -0
  15. package/dist/apps/cli/src/commands/project/commandMrPin.js +119 -0
  16. package/dist/apps/cli/src/commands/project/commandMrPin.js.map +1 -0
  17. package/dist/apps/cli/src/commands/project/commandSecretsSyncGithub.d.ts +1 -42
  18. package/dist/apps/cli/src/commands/project/commandSecretsSyncGithub.js +5 -124
  19. package/dist/apps/cli/src/commands/project/commandSecretsSyncGithub.js.map +1 -1
  20. package/dist/apps/cli/src/commands/project/secrets/commandSecretsPush.js +2 -1
  21. package/dist/apps/cli/src/commands/project/secrets/commandSecretsPush.js.map +1 -1
  22. package/dist/apps/cli/src/commands/project/secrets/commandSecretsSet.js +2 -2
  23. package/dist/apps/cli/src/commands/project/secrets/commandSecretsSet.js.map +1 -1
  24. package/dist/apps/cli/src/commands/project/secrets/document.d.ts +1 -12
  25. package/dist/apps/cli/src/commands/project/secrets/document.js +1 -79
  26. package/dist/apps/cli/src/commands/project/secrets/document.js.map +1 -1
  27. package/dist/apps/cli/src/gcloud/serviceAccounts.js +15 -4
  28. package/dist/apps/cli/src/gcloud/serviceAccounts.js.map +1 -1
  29. package/dist/apps/cli/src/release/changesetsReleaseJob.js +3 -0
  30. package/dist/apps/cli/src/release/changesetsReleaseJob.js.map +1 -1
  31. package/dist/apps/cli/src/release/releaseEntry.d.ts +12 -0
  32. package/dist/apps/cli/src/release/releaseEntry.js +71 -0
  33. package/dist/apps/cli/src/release/releaseEntry.js.map +1 -0
  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/gitlab.d.ts +10 -0
  44. package/dist/apps/cli/src/utils/gitlab.js +30 -5
  45. package/dist/apps/cli/src/utils/gitlab.js.map +1 -1
  46. package/dist/apps/cli/src/vault/BitwardenVault.d.ts +6 -0
  47. package/dist/apps/cli/src/vault/BitwardenVault.js +11 -1
  48. package/dist/apps/cli/src/vault/BitwardenVault.js.map +1 -1
  49. package/dist/apps/cli/src/vault/GitlabVault.d.ts +2 -2
  50. package/dist/apps/cli/src/vault/GitlabVault.js +3 -2
  51. package/dist/apps/cli/src/vault/GitlabVault.js.map +1 -1
  52. package/dist/apps/cli/src/vault/VaultManager.d.ts +2 -2
  53. package/dist/apps/cli/src/vault/VaultManager.js +2 -2
  54. package/dist/apps/cli/src/vault/VaultManager.js.map +1 -1
  55. package/dist/apps/cli/src/vault/types.d.ts +16 -2
  56. package/dist/bundles/catci/index.js +4 -4
  57. package/dist/bundles/catenv/index.js +115 -13
  58. package/dist/bundles/cli/index.js +749 -431
  59. package/dist/bundles/runner-images/semantic-release/Dockerfile +1 -0
  60. package/dist/bundles/runner-images/semantic-release/scripts/semanticRelease +14 -4
  61. package/dist/bundles/skills/catladder-cli/references/commands.md +13 -0
  62. package/dist/bundles/skills/catladder-deploys/SKILL.md +7 -0
  63. package/dist/bundles/skills/catladder-migrate-ci-backend/SKILL.md +7 -2
  64. package/dist/bundles/skills/catladder-pipelines/SKILL.md +27 -1
  65. package/dist/bundles/skills/catladder-releases/SKILL.md +16 -2
  66. package/dist/bundles/skills/catladder-secrets/SKILL.md +16 -0
  67. package/dist/packages/pipeline/src/autoStop.d.ts +22 -0
  68. package/dist/packages/pipeline/src/autoStop.js +30 -0
  69. package/dist/packages/pipeline/src/autoStop.js.map +1 -0
  70. package/dist/packages/pipeline/src/backends/gitlab/GitlabBackend.js +2 -0
  71. package/dist/packages/pipeline/src/backends/gitlab/GitlabBackend.js.map +1 -1
  72. package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.d.ts +4 -1
  73. package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.js +21 -1
  74. package/dist/packages/pipeline/src/backends/gitlab/createGitlabPipeline.js.map +1 -1
  75. package/dist/packages/pipeline/src/deploy/base/deploy.js +8 -2
  76. package/dist/packages/pipeline/src/deploy/base/deploy.js.map +1 -1
  77. package/dist/packages/pipeline/src/index.d.ts +1 -0
  78. package/dist/packages/pipeline/src/index.js +1 -0
  79. package/dist/packages/pipeline/src/index.js.map +1 -1
  80. package/dist/packages/pipeline/src/types/config.d.ts +32 -0
  81. package/dist/runner-images/semantic-release/Dockerfile +1 -0
  82. package/dist/runner-images/semantic-release/scripts/semanticRelease +14 -4
  83. package/dist/skills/catladder-cli/references/commands.md +13 -0
  84. package/dist/skills/catladder-deploys/SKILL.md +7 -0
  85. package/dist/skills/catladder-migrate-ci-backend/SKILL.md +7 -2
  86. package/dist/skills/catladder-pipelines/SKILL.md +27 -1
  87. package/dist/skills/catladder-releases/SKILL.md +16 -2
  88. package/dist/skills/catladder-secrets/SKILL.md +16 -0
  89. package/dist/tsconfig.tsbuildinfo +1 -1
  90. package/package.json +1 -1
  91. package/src/apps/cli/commands/project/doctor/checkGithub.ts +1 -1
  92. package/src/apps/cli/commands/project/setup/setupAccessTokens.ts +3 -2
  93. package/src/apps/cli/commands/project/setup/setupGithub.ts +1 -1
  94. package/src/apps/cli/commands/project/setup/setupKubernetes.ts +31 -20
  95. package/src/commands/index.ts +1 -0
  96. package/src/commands/project/commandConfigSecrets.ts +1 -1
  97. package/src/commands/project/commandMrPin.ts +184 -0
  98. package/src/commands/project/commandSecretsSyncGithub.ts +7 -154
  99. package/src/commands/project/secrets/commandSecretsPush.ts +2 -5
  100. package/src/commands/project/secrets/commandSecretsSet.ts +2 -2
  101. package/src/commands/project/secrets/document.ts +2 -127
  102. package/src/gcloud/serviceAccounts.ts +16 -7
  103. package/src/release/__tests__/releaseEntry.test.ts +88 -0
  104. package/src/release/changesetsReleaseJob.ts +3 -0
  105. package/src/release/releaseEntry.ts +86 -0
  106. package/src/secrets/__tests__/writeSecrets.test.ts +147 -0
  107. package/src/secrets/github.ts +157 -0
  108. package/src/secrets/index.ts +2 -0
  109. package/src/secrets/writeSecrets.ts +138 -0
  110. package/src/utils/gitlab.ts +34 -7
  111. package/src/vault/BitwardenVault.ts +10 -1
  112. package/src/vault/GitlabVault.ts +9 -2
  113. package/src/vault/VaultManager.ts +3 -2
  114. package/src/vault/__tests__/BitwardenVault.test.ts +88 -0
  115. package/src/vault/types.ts +17 -1
@@ -2,7 +2,7 @@ import type { ComponentContext } from "@catladder/pipeline";
2
2
 
3
3
  import { exec } from "child-process-promise";
4
4
  import type { IO } from "../core/types";
5
- import { upsertAllVariables } from "../utils/gitlab";
5
+ import { writeSecretsAndMirror } from "../secrets";
6
6
  import { getGcloudServiceAccountNames } from "./serviceAccountNames";
7
7
 
8
8
  export const accountExists = async (fullIdentifier: string) => {
@@ -76,13 +76,22 @@ export const upsertGcloudServiceAccountAndSaveSecret = async (
76
76
  instance.log("upserting service account " + account.name + "...");
77
77
  const key = await upsertGcloudServiceAccount(context, account);
78
78
 
79
- await upsertAllVariables(
79
+ // through the vault (and from there to every enabled ci backend) —
80
+ // the key is a secret like any other, the deploy jobs read it under
81
+ // its declared name. Writing it to gitlab directly would break every
82
+ // project that has no gitlab.
83
+ await writeSecretsAndMirror(
80
84
  instance,
81
- {
82
- [secretName]: key,
83
- },
84
- context.env,
85
- context.name,
85
+ [
86
+ {
87
+ env: context.env,
88
+ componentName: context.name,
89
+ secrets: { [secretName]: key },
90
+ },
91
+ ],
92
+ // a fresh key is created on every run, the previous one is deleted
93
+ // at google — a backup copy of it would only be a dead secret
94
+ { backup: false },
86
95
  );
87
96
  instance.log("done!");
88
97
  };
@@ -0,0 +1,88 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createReleaseEntry } from "../releaseEntry";
3
+
4
+ const gitlabEnv = {
5
+ GITHUB_ACTIONS: undefined,
6
+ GL_TOKEN: "glpat-token",
7
+ CI_API_V4_URL: "https://git.example.com/api/v4",
8
+ CI_PROJECT_ID: "42",
9
+ };
10
+
11
+ const githubEnv = {
12
+ GITHUB_ACTIONS: "true",
13
+ GITHUB_TOKEN: "gh-token",
14
+ GITHUB_REPOSITORY: "panter/catladder",
15
+ };
16
+
17
+ const stubEnv = (env: Record<string, string | undefined>) => {
18
+ Object.entries(env).forEach(([key, value]) => vi.stubEnv(key, value as any));
19
+ };
20
+
21
+ const stubFetch = (response: Partial<Response> = {}) => {
22
+ const fetchMock = vi.fn().mockResolvedValue({
23
+ ok: true,
24
+ status: 201,
25
+ text: async () => "",
26
+ ...response,
27
+ });
28
+ vi.stubGlobal("fetch", fetchMock);
29
+ return fetchMock;
30
+ };
31
+
32
+ afterEach(() => {
33
+ vi.unstubAllEnvs();
34
+ vi.unstubAllGlobals();
35
+ });
36
+
37
+ describe("createReleaseEntry", () => {
38
+ it("creates the gitlab release for the pushed tag", async () => {
39
+ stubEnv(gitlabEnv);
40
+ const fetchMock = stubFetch();
41
+
42
+ await createReleaseEntry("v1.2.0", "- Add the export endpoint");
43
+
44
+ expect(fetchMock).toHaveBeenCalledTimes(1);
45
+ const [url, init] = fetchMock.mock.calls[0];
46
+ expect(url).toBe("https://git.example.com/api/v4/projects/42/releases");
47
+ expect(init.method).toBe("POST");
48
+ expect(init.headers["PRIVATE-TOKEN"]).toBe("glpat-token");
49
+ expect(JSON.parse(init.body)).toEqual({
50
+ tag_name: "v1.2.0",
51
+ description: "- Add the export endpoint",
52
+ });
53
+ });
54
+
55
+ it("creates the github release for the pushed tag", async () => {
56
+ stubEnv(githubEnv);
57
+ const fetchMock = stubFetch();
58
+
59
+ await createReleaseEntry("v1.2.0", "- Add the export endpoint");
60
+
61
+ const [url, init] = fetchMock.mock.calls[0];
62
+ expect(url).toBe("https://api.github.com/repos/panter/catladder/releases");
63
+ expect(init.headers.authorization).toBe("Bearer gh-token");
64
+ expect(JSON.parse(init.body)).toEqual({
65
+ tag_name: "v1.2.0",
66
+ name: "v1.2.0",
67
+ body: "- Add the export endpoint",
68
+ });
69
+ });
70
+
71
+ it("does not throw when the api call fails — the tag is already pushed", async () => {
72
+ stubEnv(gitlabEnv);
73
+ stubFetch({ ok: false, status: 403, text: async () => "forbidden" });
74
+
75
+ await expect(
76
+ createReleaseEntry("v1.2.0", "- notes"),
77
+ ).resolves.toBeUndefined();
78
+ });
79
+
80
+ it("skips without api context instead of failing the release", async () => {
81
+ stubEnv({ ...gitlabEnv, GL_TOKEN: undefined });
82
+ const fetchMock = stubFetch();
83
+
84
+ await createReleaseEntry("v1.2.0", "- notes");
85
+
86
+ expect(fetchMock).not.toHaveBeenCalled();
87
+ });
88
+ });
@@ -23,6 +23,7 @@ import {
23
23
  git,
24
24
  gitWithEnv,
25
25
  } from "./releaseGit";
26
+ import { createReleaseEntry } from "./releaseEntry";
26
27
  import { appendStepSummary } from "./stepSummary";
27
28
 
28
29
  const CHANGESET_DIR = ".changeset";
@@ -276,6 +277,8 @@ export const changesetsReleaseJob = async () => {
276
277
  await pushCommitAndTag(tag);
277
278
  console.log(`released ${tag}`);
278
279
  appendStepSummary(`🚀 **Released ${tag}**\n\n${entries}`);
280
+ // the tag alone creates no entry on the releases page of the git host
281
+ await createReleaseEntry(tag, entries);
279
282
 
280
283
  if (process.env.GITHUB_ACTIONS === "true") {
281
284
  await dispatchTaggedReleaseWorkflow(tag);
@@ -0,0 +1,86 @@
1
+ /**
2
+ * the release entry on the git host — gitlab's `/-/releases`, github's
3
+ * `/releases`. Pushing a tag does NOT create one: on the
4
+ * semantic-release path `@semantic-release/gitlab` used to do it, the
5
+ * changesets path creates it here (on both backends).
6
+ *
7
+ * Never fatal: when this runs, the release commit and tag are already
8
+ * pushed and the taggedRelease pipeline is on its way — a failing api
9
+ * call must not turn a done release into a red job (and a rerun would
10
+ * fail on the existing tag).
11
+ */
12
+
13
+ const post = async (
14
+ url: string,
15
+ headers: Record<string, string>,
16
+ body: unknown,
17
+ ) => {
18
+ const response = await fetch(url, {
19
+ method: "POST",
20
+ headers: { "content-type": "application/json", ...headers },
21
+ body: JSON.stringify(body),
22
+ });
23
+ if (!response.ok) {
24
+ throw new Error(
25
+ `POST ${url} failed: ${response.status} ${await response.text()}`,
26
+ );
27
+ }
28
+ };
29
+
30
+ /**
31
+ * GL_TOKEN is the project access token the release push already used,
32
+ * so its api scope is available here too
33
+ */
34
+ const createGitlabReleaseEntry = async (tag: string, notes: string) => {
35
+ const token = process.env.GL_TOKEN;
36
+ const apiUrl = process.env.CI_API_V4_URL;
37
+ const projectId = process.env.CI_PROJECT_ID;
38
+ if (!token || !apiUrl || !projectId) {
39
+ console.warn(
40
+ "no GL_TOKEN / gitlab api context — skipping the release entry (the tag is pushed)",
41
+ );
42
+ return;
43
+ }
44
+ await post(
45
+ `${apiUrl}/projects/${projectId}/releases`,
46
+ { "PRIVATE-TOKEN": token },
47
+ // name defaults to the tag on gitlab
48
+ { tag_name: tag, description: notes },
49
+ );
50
+ console.log(`created the gitlab release entry for ${tag}`);
51
+ };
52
+
53
+ /** the workflow token, granted `contents: write` by the release job */
54
+ const createGithubReleaseEntry = async (tag: string, notes: string) => {
55
+ const token = process.env.GITHUB_TOKEN;
56
+ const apiUrl = process.env.GITHUB_API_URL ?? "https://api.github.com";
57
+ const repository = process.env.GITHUB_REPOSITORY;
58
+ if (!token || !repository) {
59
+ console.warn(
60
+ "no GITHUB_TOKEN / repository context — skipping the release entry (the tag is pushed)",
61
+ );
62
+ return;
63
+ }
64
+ await post(
65
+ `${apiUrl}/repos/${repository}/releases`,
66
+ { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
67
+ { tag_name: tag, name: tag, body: notes },
68
+ );
69
+ console.log(`created the github release entry for ${tag}`);
70
+ };
71
+
72
+ export const createReleaseEntry = async (tag: string, notes: string) => {
73
+ try {
74
+ if (process.env.GITHUB_ACTIONS === "true") {
75
+ await createGithubReleaseEntry(tag, notes);
76
+ } else {
77
+ await createGitlabReleaseEntry(tag, notes);
78
+ }
79
+ } catch (error) {
80
+ console.warn(
81
+ `⚠️ could not create the release entry for ${tag}: ${error}\n` +
82
+ "the release itself is done (commit, tag and changelog are pushed) — " +
83
+ "create the entry by hand if you need it",
84
+ );
85
+ }
86
+ };
@@ -0,0 +1,147 @@
1
+ import type { Config } from "@catladder/pipeline";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import type { IO } from "../../core/types";
4
+ import { writeSecretsAndMirror } from "../writeSecrets";
5
+
6
+ const { getProjectConfig, getEnvironment } = vi.hoisted(() => ({
7
+ getProjectConfig: vi.fn(),
8
+ getEnvironment: vi.fn(),
9
+ }));
10
+ const { upsertAllVariables } = vi.hoisted(() => ({
11
+ upsertAllVariables: vi.fn(),
12
+ }));
13
+ const { isGhAuthenticated } = vi.hoisted(() => ({
14
+ isGhAuthenticated: vi.fn(),
15
+ }));
16
+ const { collectSecretTargets, getConfiguredGithubRepo, pushSecretsToGithub } =
17
+ vi.hoisted(() => ({
18
+ collectSecretTargets: vi.fn(),
19
+ getConfiguredGithubRepo: vi.fn(),
20
+ pushSecretsToGithub: vi.fn(),
21
+ }));
22
+
23
+ vi.mock("../../config/getProjectConfig", () => ({
24
+ getProjectConfig,
25
+ getEnvironment,
26
+ }));
27
+ vi.mock("../../utils/gitlab", () => ({ upsertAllVariables }));
28
+ vi.mock("../../utils/github", () => ({ isGhAuthenticated }));
29
+ vi.mock("../github", () => ({
30
+ collectSecretTargets,
31
+ getConfiguredGithubRepo,
32
+ pushSecretsToGithub,
33
+ }));
34
+
35
+ const vaultWriteSecrets = vi.fn();
36
+ const io = {
37
+ log: vi.fn(),
38
+ getVaultManager: async () => ({ writeSecrets: vaultWriteSecrets }),
39
+ } as unknown as IO;
40
+
41
+ const config = (partial: Partial<Config>): Config =>
42
+ ({
43
+ customerName: "pan",
44
+ appName: "app",
45
+ components: {},
46
+ ...partial,
47
+ }) as Config;
48
+
49
+ const writes = [
50
+ { env: "dev", componentName: "www", secrets: { API_KEY: "sesame" } },
51
+ ];
52
+
53
+ beforeEach(() => {
54
+ vi.clearAllMocks();
55
+ getEnvironment.mockResolvedValue({
56
+ secretEnvVarKeys: [{ key: "API_KEY" }],
57
+ jobOnlyVars: {
58
+ build: { secretEnvVarKeys: [] },
59
+ deploy: { secretEnvVarKeys: [] },
60
+ },
61
+ });
62
+ isGhAuthenticated.mockResolvedValue(true);
63
+ getConfiguredGithubRepo.mockResolvedValue("panter/app");
64
+ collectSecretTargets.mockResolvedValue(
65
+ new Map([["CL_dev_www_API_KEY", new Set(["dev"])]]),
66
+ );
67
+ });
68
+
69
+ describe("writeSecretsAndMirror", () => {
70
+ it("never touches the gitlab api on a project without a gitlab pipeline", async () => {
71
+ getProjectConfig.mockResolvedValue(
72
+ config({
73
+ pipelines: { github: true },
74
+ secrets: { vault: { type: "bitwarden" } },
75
+ }),
76
+ );
77
+
78
+ await writeSecretsAndMirror(io, writes);
79
+
80
+ expect(vaultWriteSecrets).toHaveBeenCalledWith(
81
+ "dev",
82
+ "www",
83
+ { API_KEY: "sesame" },
84
+ io,
85
+ {},
86
+ );
87
+ expect(upsertAllVariables).not.toHaveBeenCalled();
88
+ expect(pushSecretsToGithub).toHaveBeenCalledWith(
89
+ io,
90
+ "panter/app",
91
+ [{ name: "CL_dev_www_API_KEY", value: "sesame", kind: "secret" }],
92
+ expect.any(Map),
93
+ );
94
+ });
95
+
96
+ it("does not mirror to gitlab when gitlab IS the vault (already written)", async () => {
97
+ getProjectConfig.mockResolvedValue(config({ pipelines: { gitlab: true } }));
98
+
99
+ await writeSecretsAndMirror(io, writes);
100
+
101
+ expect(vaultWriteSecrets).toHaveBeenCalledTimes(1);
102
+ expect(upsertAllVariables).not.toHaveBeenCalled();
103
+ expect(pushSecretsToGithub).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it("mirrors to gitlab when it is only a backend, and forwards the backup option", async () => {
107
+ getProjectConfig.mockResolvedValue(
108
+ config({
109
+ pipelines: { gitlab: true, github: true },
110
+ secrets: { vault: "bitwarden" },
111
+ }),
112
+ );
113
+
114
+ await writeSecretsAndMirror(io, writes, { backup: false });
115
+
116
+ expect(vaultWriteSecrets).toHaveBeenCalledWith(
117
+ "dev",
118
+ "www",
119
+ { API_KEY: "sesame" },
120
+ io,
121
+ { backup: false },
122
+ );
123
+ expect(upsertAllVariables).toHaveBeenCalledWith(
124
+ io,
125
+ { API_KEY: "sesame" },
126
+ "dev",
127
+ "www",
128
+ false,
129
+ );
130
+ expect(pushSecretsToGithub).toHaveBeenCalled();
131
+ });
132
+
133
+ it("keeps the secrets in the vault when the github mirror cannot run", async () => {
134
+ getProjectConfig.mockResolvedValue(
135
+ config({
136
+ pipelines: { github: true },
137
+ secrets: { vault: "bitwarden" },
138
+ }),
139
+ );
140
+ isGhAuthenticated.mockResolvedValue(false);
141
+
142
+ await writeSecretsAndMirror(io, writes);
143
+
144
+ expect(vaultWriteSecrets).toHaveBeenCalledTimes(1);
145
+ expect(pushSecretsToGithub).not.toHaveBeenCalled();
146
+ });
147
+ });
@@ -0,0 +1,157 @@
1
+ import type { Config } from "@catladder/pipeline";
2
+ import {
3
+ getPipelineGitRemote,
4
+ getSecretVarName,
5
+ GithubBackend,
6
+ } from "@catladder/pipeline";
7
+ import {
8
+ getAllComponentsWithAllEnvsHierarchical,
9
+ getEnvironment,
10
+ getProjectConfig,
11
+ } from "../config/getProjectConfig";
12
+ import type { IO } from "../core/types";
13
+ import {
14
+ ensureGithubEnvironment,
15
+ getGithubRepoFromRemote,
16
+ setGithubSecret,
17
+ setGithubVariable,
18
+ } from "../utils/github";
19
+
20
+ /**
21
+ * all secrets declared in the config, with their values read from the
22
+ * vault (github secrets are write-only and can never act as a source)
23
+ */
24
+ export const collectSecretsFromVault = async (io: IO, envFilter?: string[]) => {
25
+ const envAndComponents = await getAllComponentsWithAllEnvsHierarchical();
26
+
27
+ const kinds = new Map<string, "secret" | "variable">();
28
+ for (const [componentName, envs] of Object.entries(envAndComponents)) {
29
+ for (const env of envs) {
30
+ if (envFilter && !envFilter.includes(env)) {
31
+ continue;
32
+ }
33
+ const { secretEnvVarKeys, jobOnlyVars } = await getEnvironment(
34
+ env,
35
+ componentName,
36
+ );
37
+ const keys = [
38
+ ...secretEnvVarKeys,
39
+ ...jobOnlyVars.build.secretEnvVarKeys,
40
+ ...jobOnlyVars.deploy.secretEnvVarKeys,
41
+ ];
42
+ keys.forEach(({ key, kind }) =>
43
+ kinds.set(getSecretVarName(env, componentName, key), kind ?? "secret"),
44
+ );
45
+ }
46
+ }
47
+
48
+ const names = [...kinds.keys()];
49
+ const values = await (await io.getVaultManager()).readSecrets(names, io);
50
+ const secrets = names
51
+ .filter((name) => values[name] !== undefined)
52
+ .map((name) => ({
53
+ name,
54
+ value: values[name],
55
+ kind: kinds.get(name) ?? ("secret" as const),
56
+ }));
57
+ const missing = names.filter((name) => values[name] === undefined);
58
+ return { secrets, missing };
59
+ };
60
+
61
+ /** marker for repo-level (environment-less) targets */
62
+ const REPO_LEVEL = "";
63
+
64
+ /**
65
+ * derives which github environment(s) each secret/variable must live in
66
+ * by scanning the GENERATED workflows: a `${{ secrets.X }}` / `${{
67
+ * vars.X }}` reference in a job's env targets that job's environment
68
+ * (or the repo level when the job has none). Deriving from the real
69
+ * output means the sync can never drift from what the workflows expect.
70
+ *
71
+ * Environment secrets are the answer to github's cap of 100 secrets
72
+ * per REPO (a multi-env project easily exceeds it): the cap applies
73
+ * per environment instead.
74
+ */
75
+ export const collectSecretTargets = async (
76
+ config: Config,
77
+ ): Promise<Map<string, Set<string>>> => {
78
+ const workflows = await new GithubBackend().createWorkflows(config);
79
+ const targets = new Map<string, Set<string>>();
80
+ for (const workflow of Object.values(workflows)) {
81
+ for (const job of Object.values(workflow.jobs)) {
82
+ const environment =
83
+ typeof job.environment === "object"
84
+ ? job.environment.name
85
+ : job.environment;
86
+ for (const value of Object.values(job.env ?? {})) {
87
+ if (typeof value !== "string") continue;
88
+ for (const match of value.matchAll(
89
+ /\$\{\{\s*(?:secrets|vars)\.([A-Za-z0-9_]+)\s*\}\}/g,
90
+ )) {
91
+ const name = match[1];
92
+ if (!targets.has(name)) {
93
+ targets.set(name, new Set());
94
+ }
95
+ targets.get(name)!.add(environment ?? REPO_LEVEL);
96
+ }
97
+ }
98
+ }
99
+ }
100
+ return targets;
101
+ };
102
+
103
+ /**
104
+ * pushes vault values to github via the gh cli: sensitive ones as
105
+ * secrets (masked), "variable"-kind ones as variables (plain) — each
106
+ * into the environment(s) whose jobs reference them
107
+ */
108
+ export const pushSecretsToGithub = async (
109
+ io: IO,
110
+ repo: string,
111
+ secrets: { name: string; value: string; kind: "secret" | "variable" }[],
112
+ targets: Map<string, Set<string>>,
113
+ ) => {
114
+ const environments = [
115
+ ...new Set(
116
+ [...targets.values()].flatMap((envs) =>
117
+ [...envs].filter((env) => env !== REPO_LEVEL),
118
+ ),
119
+ ),
120
+ ];
121
+ for (const environment of environments) {
122
+ await ensureGithubEnvironment(repo, environment);
123
+ io.log(`🌍 environment ${environment}`);
124
+ }
125
+
126
+ for (const { name, value, kind } of secrets) {
127
+ const envs = targets.get(name);
128
+ if (!envs) {
129
+ io.log(`⏭️ ${name} (not referenced by any generated workflow)`);
130
+ continue;
131
+ }
132
+ for (const environment of envs) {
133
+ const suffix = environment === REPO_LEVEL ? "" : ` → ${environment}`;
134
+ if (kind === "variable") {
135
+ await setGithubVariable(repo, name, value, environment || undefined);
136
+ io.log(`📖 ${name} (variable)${suffix}`);
137
+ } else {
138
+ await setGithubSecret(repo, name, value, environment || undefined);
139
+ io.log(`✅ ${name}${suffix}`);
140
+ }
141
+ }
142
+ }
143
+ };
144
+
145
+ /**
146
+ * resolves the github repository from the configured git remote
147
+ * (`pipelines.github.gitRemote`, defaults to origin)
148
+ */
149
+ export const getConfiguredGithubRepo = async (): Promise<
150
+ string | undefined
151
+ > => {
152
+ const config = await getProjectConfig();
153
+ if (!config) {
154
+ return undefined;
155
+ }
156
+ return getGithubRepoFromRemote(getPipelineGitRemote(config, "github"));
157
+ };
@@ -0,0 +1,2 @@
1
+ export * from "./github";
2
+ export * from "./writeSecrets";
@@ -0,0 +1,138 @@
1
+ import type { Config } from "@catladder/pipeline";
2
+ import {
3
+ getEnabledPipelineTypes,
4
+ getSecretVarName,
5
+ getVaultConfig,
6
+ } from "@catladder/pipeline";
7
+ import { getEnvironment, getProjectConfig } from "../config/getProjectConfig";
8
+ import type { IO } from "../core/types";
9
+ import { isGhAuthenticated } from "../utils/github";
10
+ import { upsertAllVariables } from "../utils/gitlab";
11
+ import type { WriteSecretsOptions } from "../vault/types";
12
+ import {
13
+ collectSecretTargets,
14
+ getConfiguredGithubRepo,
15
+ pushSecretsToGithub,
16
+ } from "./github";
17
+
18
+ /** secrets of one env of one component, ready to be written */
19
+ export type SecretsWrite = {
20
+ env: string;
21
+ componentName: string;
22
+ secrets: Record<string, unknown>;
23
+ };
24
+
25
+ /**
26
+ * the one door for writing secret values: they go into the vault (the
27
+ * source of truth) and are mirrored to every enabled CI backend —
28
+ * which vault and which backends exist is invisible to the caller.
29
+ *
30
+ * Everything that produces a secret value must come through here: the
31
+ * secrets commands, but also the credentials `project setup`
32
+ * provisions (gcloud service account keys, kubernetes tokens).
33
+ */
34
+ export const writeSecretsAndMirror = async (
35
+ io: IO,
36
+ writes: SecretsWrite[],
37
+ options: WriteSecretsOptions = {},
38
+ ): Promise<void> => {
39
+ const config = await getProjectConfig();
40
+ if (!config) {
41
+ throw new Error("no catladder config found");
42
+ }
43
+
44
+ io.log("");
45
+ io.log("writing secrets to the vault, please wait...");
46
+ io.log("");
47
+ for (const { env, componentName, secrets } of writes) {
48
+ io.log("writing " + env + ":" + componentName + "...\n");
49
+ await (
50
+ await io.getVaultManager()
51
+ ).writeSecrets(env, componentName, secrets, io, options);
52
+ io.log("");
53
+ io.log("✅ " + env + ":" + componentName);
54
+ io.log("--------------------------------\n");
55
+ }
56
+
57
+ await mirrorSecretsToCiBackends(io, config, writes, options);
58
+ };
59
+
60
+ /**
61
+ * the vault is the source of truth; every enabled CI backend receives
62
+ * a mirrored copy of the written secrets (gitlab variables, github
63
+ * secrets), so all CI systems stay in sync
64
+ */
65
+ const mirrorSecretsToCiBackends = async (
66
+ io: IO,
67
+ config: Config,
68
+ writes: SecretsWrite[],
69
+ options: WriteSecretsOptions,
70
+ ) => {
71
+ const enabled = getEnabledPipelineTypes(config);
72
+
73
+ // gitlab mirror (unless gitlab is the vault itself, then it is
74
+ // already written)
75
+ if (getVaultConfig(config).type !== "gitlab" && enabled.includes("gitlab")) {
76
+ for (const { env, componentName, secrets } of writes) {
77
+ await upsertAllVariables(
78
+ io,
79
+ secrets,
80
+ env,
81
+ componentName,
82
+ options.backup ?? true,
83
+ );
84
+ }
85
+ io.log("✅ mirrored to gitlab");
86
+ }
87
+
88
+ if (!enabled.includes("github")) {
89
+ return;
90
+ }
91
+ const kinds = new Map<string, "secret" | "variable">();
92
+ for (const { env, componentName } of writes) {
93
+ const { secretEnvVarKeys, jobOnlyVars } = await getEnvironment(
94
+ env,
95
+ componentName,
96
+ );
97
+ [
98
+ ...secretEnvVarKeys,
99
+ ...jobOnlyVars.build.secretEnvVarKeys,
100
+ ...jobOnlyVars.deploy.secretEnvVarKeys,
101
+ ].forEach(({ key, kind }) =>
102
+ kinds.set(getSecretVarName(env, componentName, key), kind ?? "secret"),
103
+ );
104
+ }
105
+ const secrets = writes.flatMap(({ env, componentName, secrets }) =>
106
+ Object.entries(secrets).map(([key, value]) => {
107
+ const name = getSecretVarName(env, componentName, key);
108
+ return {
109
+ name,
110
+ value: typeof value === "string" ? value : JSON.stringify(value ?? ""),
111
+ kind: kinds.get(name) ?? ("secret" as const),
112
+ };
113
+ }),
114
+ );
115
+ if (secrets.length === 0) {
116
+ return;
117
+ }
118
+ if (!(await isGhAuthenticated())) {
119
+ io.log(
120
+ "⚠️ github pipeline is enabled, but the github cli (gh) is not authenticated — run `project secrets-sync-github` later to mirror the secrets",
121
+ );
122
+ return;
123
+ }
124
+ const repo = await getConfiguredGithubRepo();
125
+ if (!repo) {
126
+ io.log(
127
+ "⚠️ github pipeline is enabled, but no github repository was found — run `project secrets-sync-github` later to mirror the secrets",
128
+ );
129
+ return;
130
+ }
131
+ io.log(`mirroring ${secrets.length} secrets to github ('${repo}')...`);
132
+ await pushSecretsToGithub(
133
+ io,
134
+ repo,
135
+ secrets,
136
+ await collectSecretTargets(config),
137
+ );
138
+ };