@catladder/cli 0.0.0-next-6ea918c7 → 0.0.0-next-e3ccf7d4

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/package.json CHANGED
@@ -53,7 +53,7 @@
53
53
  }
54
54
  ],
55
55
  "license": "MIT",
56
- "version": "0.0.0-next-6ea918c7",
56
+ "version": "0.0.0-next-e3ccf7d4",
57
57
  "scripts": {
58
58
  "lint": "eslint \"src/**/*.ts\"",
59
59
  "lint:fix": "eslint \"src/**/*.ts\" --fix",
@@ -17,6 +17,7 @@ import {
17
17
  parseChoice,
18
18
  } from "../../../../config/getProjectConfig";
19
19
  import {
20
+ collectSecretTargets,
20
21
  getConfiguredGithubRepo,
21
22
  pushSecretsToGithub,
22
23
  } from "../../../../commands/project/commandSecretsSyncGithub";
@@ -292,7 +293,12 @@ const mirrorSecretsToCiBackends = async (
292
293
  return;
293
294
  }
294
295
  instance.log(`mirroring ${secrets.length} secrets to github ('${repo}')...`);
295
- await pushSecretsToGithub(instance, repo, secrets);
296
+ await pushSecretsToGithub(
297
+ instance,
298
+ repo,
299
+ secrets,
300
+ await collectSecretTargets(config),
301
+ );
296
302
  };
297
303
 
298
304
  export const projectConfigSecrets = async (io: IO, envComponent?: string) => {
@@ -1,7 +1,9 @@
1
+ import type { Config } from "@catladder/pipeline";
1
2
  import {
2
3
  getEnabledPipelineTypes,
3
4
  getPipelineGitRemote,
4
5
  getSecretVarName,
6
+ GithubBackend,
5
7
  } from "@catladder/pipeline";
6
8
  import {
7
9
  getAllComponentsWithAllEnvsHierarchical,
@@ -11,6 +13,7 @@ import {
11
13
  import { defineCommand } from "../../core/defineCommand";
12
14
  import type { IO } from "../../core/types";
13
15
  import {
16
+ ensureGithubEnvironment,
14
17
  getGithubRepoFromRemote,
15
18
  isGhAuthenticated,
16
19
  setGithubSecret,
@@ -58,22 +61,86 @@ export const collectSecretsFromVault = async (io: IO, envFilter?: string[]) => {
58
61
  return { secrets, missing };
59
62
  };
60
63
 
64
+ /** marker for repo-level (environment-less) targets */
65
+ const REPO_LEVEL = "";
66
+
61
67
  /**
62
- * pushes vault values to github via the gh cli: sensitive ones as repo
63
- * secrets (masked), "variable"-kind ones as repo variables (plain)
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
64
110
  */
65
111
  export const pushSecretsToGithub = async (
66
112
  io: IO,
67
113
  repo: string,
68
114
  secrets: { name: string; value: string; kind: "secret" | "variable" }[],
115
+ targets: Map<string, Set<string>>,
69
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
+
70
129
  for (const { name, value, kind } of secrets) {
71
- if (kind === "variable") {
72
- await setGithubVariable(repo, name, value);
73
- io.log(`📖 ${name} (variable)`);
74
- } else {
75
- await setGithubSecret(repo, name, value);
76
- io.log(`✅ ${name}`);
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
+ }
77
144
  }
78
145
  }
79
146
  };
@@ -163,7 +230,12 @@ export const commandSecretsSyncGithub = defineCommand({
163
230
  throw new Error("abort");
164
231
  }
165
232
 
166
- await pushSecretsToGithub(ctx, repo, secrets);
233
+ await pushSecretsToGithub(
234
+ ctx,
235
+ repo,
236
+ secrets,
237
+ await collectSecretTargets(config),
238
+ );
167
239
  ctx.log("");
168
240
  ctx.log(
169
241
  "done! 😻 the github pipeline can use them as ${{ secrets.<NAME> }}",
@@ -39,11 +39,21 @@ const ghSetViaStdin = (
39
39
  repo: string,
40
40
  name: string,
41
41
  value: string,
42
+ environment?: string,
42
43
  ): Promise<void> =>
43
44
  new Promise<void>((resolve, reject) => {
44
- const child = spawn("gh", [kind, "set", name, "--repo", repo], {
45
- stdio: ["pipe", "ignore", "pipe"],
46
- });
45
+ const child = spawn(
46
+ "gh",
47
+ [
48
+ kind,
49
+ "set",
50
+ name,
51
+ "--repo",
52
+ repo,
53
+ ...(environment ? ["--env", environment] : []),
54
+ ],
55
+ { stdio: ["pipe", "ignore", "pipe"] },
56
+ );
47
57
  let stderr = "";
48
58
  child.stderr?.on("data", (data) => (stderr += data));
49
59
  child.on("error", reject);
@@ -63,7 +73,8 @@ export const setGithubSecret = async (
63
73
  repo: string,
64
74
  name: string,
65
75
  value: string,
66
- ): Promise<void> => ghSetViaStdin("secret", repo, name, value);
76
+ environment?: string,
77
+ ): Promise<void> => ghSetViaStdin("secret", repo, name, value, environment);
67
78
 
68
79
  /**
69
80
  * sets a github actions repository variable (plain, unmasked) via the
@@ -73,4 +84,38 @@ export const setGithubVariable = async (
73
84
  repo: string,
74
85
  name: string,
75
86
  value: string,
76
- ): Promise<void> => ghSetViaStdin("variable", repo, name, value);
87
+ environment?: string,
88
+ ): Promise<void> => ghSetViaStdin("variable", repo, name, value, environment);
89
+
90
+ /**
91
+ * creates the github environment when it does not exist (idempotent —
92
+ * PUT with an empty body leaves existing settings untouched)
93
+ */
94
+ export const ensureGithubEnvironment = async (
95
+ repo: string,
96
+ environment: string,
97
+ ): Promise<void> =>
98
+ new Promise<void>((resolve, reject) => {
99
+ const child = spawn(
100
+ "gh",
101
+ [
102
+ "api",
103
+ "-X",
104
+ "PUT",
105
+ `repos/${repo}/environments/${encodeURIComponent(environment)}`,
106
+ ],
107
+ { stdio: ["ignore", "ignore", "pipe"] },
108
+ );
109
+ let stderr = "";
110
+ child.stderr?.on("data", (data) => (stderr += data));
111
+ child.on("error", reject);
112
+ child.on("close", (code) =>
113
+ code === 0
114
+ ? resolve()
115
+ : reject(
116
+ new Error(
117
+ `creating github environment ${environment} failed: ${stderr.trim()}`,
118
+ ),
119
+ ),
120
+ );
121
+ });