@smoothbricks/cli 0.11.17 → 0.11.19
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/README.md +45 -10
- package/dist/cli.js +10 -7
- package/dist/github-ci/index.d.ts.map +1 -1
- package/dist/github-ci/index.js +6 -20
- package/dist/lib/deploy-tags.d.ts +17 -3
- package/dist/lib/deploy-tags.d.ts.map +1 -1
- package/dist/lib/deploy-tags.js +23 -4
- package/dist/monorepo/ci-workflow.d.ts +56 -0
- package/dist/monorepo/ci-workflow.d.ts.map +1 -1
- package/dist/monorepo/ci-workflow.js +199 -2
- package/dist/monorepo/managed-files.d.ts +28 -2
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +74 -22
- package/dist/release/github-release.d.ts +10 -0
- package/dist/release/github-release.d.ts.map +1 -1
- package/dist/release/github-release.js +11 -5
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +4 -5
- package/dist/secrets/commands.d.ts +52 -13
- package/dist/secrets/commands.d.ts.map +1 -1
- package/dist/secrets/commands.js +220 -34
- package/dist/secrets/index.d.ts +21 -1
- package/dist/secrets/index.d.ts.map +1 -1
- package/dist/secrets/index.js +110 -4
- package/dist/secrets/repository.d.ts +60 -0
- package/dist/secrets/repository.d.ts.map +1 -0
- package/dist/secrets/repository.js +145 -0
- package/dist/wrangler/cloudflare.d.ts +7 -0
- package/dist/wrangler/cloudflare.d.ts.map +1 -1
- package/dist/wrangler/cloudflare.js +10 -0
- package/dist/wrangler/deploy-stage.d.ts.map +1 -1
- package/dist/wrangler/deploy-stage.js +50 -24
- package/dist/wrangler/stage-secrets.d.ts +57 -0
- package/dist/wrangler/stage-secrets.d.ts.map +1 -0
- package/dist/wrangler/stage-secrets.js +178 -0
- package/managed/raw/tooling/direnv/devenv.smoo.nix +9 -1
- package/managed/raw/tooling/git-hooks/pre-push.sh +30 -29
- package/package.json +2 -2
- package/src/cli.ts +33 -10
- package/src/github-ci/index.test.ts +6 -7
- package/src/github-ci/index.ts +11 -19
- package/src/lib/deploy-tags.ts +22 -4
- package/src/monorepo/__tests__/ci-workflow.test.ts +161 -0
- package/src/monorepo/ci-workflow.ts +277 -2
- package/src/monorepo/managed-files.test.ts +96 -37
- package/src/monorepo/managed-files.ts +94 -23
- package/src/monorepo/package-policy.test.ts +1 -1
- package/src/release/__tests__/github-release.test.ts +8 -4
- package/src/release/github-release.ts +13 -5
- package/src/release/index.ts +4 -4
- package/src/secrets/commands.test.ts +28 -0
- package/src/secrets/commands.ts +237 -35
- package/src/secrets/index.test.ts +118 -1
- package/src/secrets/index.ts +108 -4
- package/src/secrets/repository.test.ts +98 -0
- package/src/secrets/repository.ts +164 -0
- package/src/wrangler/cloudflare.ts +18 -0
- package/src/wrangler/deploy-stage.test.ts +258 -11
- package/src/wrangler/deploy-stage.ts +70 -38
- package/src/wrangler/stage-secrets.ts +146 -0
package/src/secrets/commands.ts
CHANGED
|
@@ -5,11 +5,20 @@
|
|
|
5
5
|
* world-readable) and never printed. Reading one from the terminal disables
|
|
6
6
|
* echo, so a pasted key does not end up in a scrollback buffer or a screen
|
|
7
7
|
* share.
|
|
8
|
+
*
|
|
9
|
+
* A value lives at repository scope or inside a named GitHub Environment, and
|
|
10
|
+
* a job bound to an environment resolves `secrets.X` from that environment
|
|
11
|
+
* first and from the repository second — which is how one name carries test
|
|
12
|
+
* credentials on a preview stage and live ones in production. Every scope the
|
|
13
|
+
* workflows bind is read before anything is called missing: reading only the
|
|
14
|
+
* repository reports values that do exist as absent, and sends an operator
|
|
15
|
+
* hunting for a secret that is already set.
|
|
8
16
|
*/
|
|
9
17
|
|
|
10
18
|
import { spawn, spawnSync } from 'node:child_process';
|
|
11
19
|
import { getWorkspacePackages } from '../lib/workspace.js';
|
|
12
20
|
import {
|
|
21
|
+
environmentsMissing,
|
|
13
22
|
fetchLocalSecret,
|
|
14
23
|
localSecretCommandNames,
|
|
15
24
|
reconcileSecrets,
|
|
@@ -18,15 +27,64 @@ import {
|
|
|
18
27
|
unsatisfiedSecrets,
|
|
19
28
|
unwiredSecrets,
|
|
20
29
|
workerSecretNames,
|
|
30
|
+
workflowEnvironments,
|
|
21
31
|
workflowSecretNames,
|
|
22
32
|
} from './index.js';
|
|
33
|
+
import { resolveRepository } from './repository.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Every secret this checkout declares that the repository does not hold, in
|
|
37
|
+
* the order an operator should fix them: the ones a workflow already promises
|
|
38
|
+
* break a deploy today, the rest break the next one.
|
|
39
|
+
*/
|
|
40
|
+
export function secretsNeedingValues(rows: readonly SecretRow[]): SecretRow[] {
|
|
41
|
+
const missing = rows.filter((row) => !row.onRepository);
|
|
42
|
+
return [...missing.filter((row) => row.suppliedByWorkflow), ...missing.filter((row) => !row.suppliedByWorkflow)];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Every secret this checkout declares that the named environment does not
|
|
47
|
+
* hold. A name the repository already holds is still offered: an environment
|
|
48
|
+
* value overrides the repository's for a job bound to it, and overriding one
|
|
49
|
+
* stage's credentials is the reason to write at environment scope at all. A
|
|
50
|
+
* repository secret no source declares is not offered — that is a stale value
|
|
51
|
+
* to review, not one to copy into an environment.
|
|
52
|
+
*/
|
|
53
|
+
export function secretsMissingInEnvironment(rows: readonly SecretRow[], environment: string): SecretRow[] {
|
|
54
|
+
const missing = rows.filter(
|
|
55
|
+
(row) =>
|
|
56
|
+
!row.heldByEnvironment.includes(environment) &&
|
|
57
|
+
(row.declaredByWorkers.length > 0 || row.suppliedByWorkflow || row.fetchableLocally),
|
|
58
|
+
);
|
|
59
|
+
return [...missing.filter((row) => row.suppliedByWorkflow), ...missing.filter((row) => !row.suppliedByWorkflow)];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Why a secret is wanted, so a prompt states what will break without it. */
|
|
63
|
+
export function describeNeed(row: SecretRow): string {
|
|
64
|
+
const parts: string[] = [];
|
|
65
|
+
if (row.declaredByWorkers.length > 0) parts.push(`declared by ${row.declaredByWorkers.join(', ')}`);
|
|
66
|
+
if (row.suppliedByWorkflow) parts.push('passed by a managed workflow');
|
|
67
|
+
if (row.fetchableLocally) parts.push('fetchable locally via smoo.secrets');
|
|
68
|
+
if (row.repositorySecret !== row.name) parts.push(`read as ${row.name}`);
|
|
69
|
+
// Naming the environments that already hold it stops an operator from
|
|
70
|
+
// answering a per-stage prompt with a repository-wide value.
|
|
71
|
+
if (row.heldByEnvironment.length > 0) parts.push(`held by ${row.heldByEnvironment.join(', ')}`);
|
|
72
|
+
return parts.length > 0 ? ` - ${parts.join('; ')}` : '';
|
|
73
|
+
}
|
|
23
74
|
|
|
24
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Secret names GitHub currently holds at the given scope, or a refusal naming
|
|
77
|
+
* what failed. With no environment that is repository scope; with one it is
|
|
78
|
+
* that environment's own secrets, which are the values a bound job reads
|
|
79
|
+
* first.
|
|
80
|
+
*/
|
|
25
81
|
export function readRepositorySecrets(
|
|
26
82
|
repo: string | undefined,
|
|
83
|
+
environment?: string,
|
|
27
84
|
): { ok: true; names: string[] } | { ok: false; reason: string } {
|
|
28
85
|
const args = ['secret', 'list', '--json', 'name'];
|
|
29
86
|
if (repo) args.push('--repo', repo);
|
|
87
|
+
if (environment) args.push('--env', environment);
|
|
30
88
|
const result = spawnSync('gh', args, { encoding: 'utf8' });
|
|
31
89
|
if (result.status !== 0) {
|
|
32
90
|
return {
|
|
@@ -49,14 +107,16 @@ export function readRepositorySecrets(
|
|
|
49
107
|
return { ok: true, names: names.sort((left, right) => left.localeCompare(right)) };
|
|
50
108
|
}
|
|
51
109
|
|
|
52
|
-
/** Write one secret, value over stdin so it never reaches argv. */
|
|
110
|
+
/** Write one secret at the given scope, value over stdin so it never reaches argv. */
|
|
53
111
|
export async function writeRepositorySecret(
|
|
54
112
|
name: string,
|
|
55
113
|
value: string,
|
|
56
114
|
repo: string | undefined,
|
|
115
|
+
environment?: string,
|
|
57
116
|
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
58
117
|
const args = ['secret', 'set', name];
|
|
59
118
|
if (repo) args.push('--repo', repo);
|
|
119
|
+
if (environment) args.push('--env', environment);
|
|
60
120
|
const child = spawn('gh', args, { stdio: ['pipe', 'inherit', 'inherit'] });
|
|
61
121
|
child.stdin.end(value);
|
|
62
122
|
const status = await new Promise<number | null>((resolvePromise) => {
|
|
@@ -67,7 +127,11 @@ export async function writeRepositorySecret(
|
|
|
67
127
|
: { ok: false, reason: `gh ${args.join(' ')} exited ${status ?? 'without status'}` };
|
|
68
128
|
}
|
|
69
129
|
|
|
70
|
-
function collectSources(
|
|
130
|
+
function collectSources(
|
|
131
|
+
root: string,
|
|
132
|
+
repositorySecrets: readonly string[],
|
|
133
|
+
environmentSecrets: Readonly<Record<string, readonly string[]>> = {},
|
|
134
|
+
) {
|
|
71
135
|
const workspaceDirs = getWorkspacePackages(root).map((pkg) => pkg.path);
|
|
72
136
|
const workerSecrets = workerSecretNames(root, workspaceDirs);
|
|
73
137
|
const workflowSecrets = workflowSecretNames(root);
|
|
@@ -81,13 +145,20 @@ function collectSources(root: string, repositorySecrets: readonly string[]) {
|
|
|
81
145
|
secretNames: secretNameMapping(root, envNames),
|
|
82
146
|
localCommands,
|
|
83
147
|
repositorySecrets,
|
|
148
|
+
environmentSecrets,
|
|
84
149
|
};
|
|
85
150
|
}
|
|
86
151
|
|
|
87
|
-
|
|
152
|
+
/** Every scope holding a value for this name, repository first. */
|
|
153
|
+
function heldBy(row: SecretRow): string {
|
|
154
|
+
const scopes = row.onRepository ? ['repository', ...row.heldByEnvironment] : row.heldByEnvironment;
|
|
155
|
+
return scopes.length > 0 ? scopes.join(', ') : 'ABSENT';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function describe(row: SecretRow, scopeWidth: number): string {
|
|
88
159
|
const where = row.declaredByWorkers.length > 0 ? row.declaredByWorkers.join(', ') : '—';
|
|
89
160
|
return [
|
|
90
|
-
row.
|
|
161
|
+
heldBy(row).padEnd(scopeWidth),
|
|
91
162
|
row.name.padEnd(32),
|
|
92
163
|
row.suppliedByWorkflow ? 'workflow' : ' ',
|
|
93
164
|
row.fetchableLocally ? 'local' : ' ',
|
|
@@ -96,22 +167,51 @@ function describe(row: SecretRow): string {
|
|
|
96
167
|
}
|
|
97
168
|
|
|
98
169
|
/**
|
|
99
|
-
* Print every known secret
|
|
100
|
-
* promises a value
|
|
101
|
-
* that fails a deploy, and it fails talking about the
|
|
102
|
-
* missing secret.
|
|
170
|
+
* Print every known secret and the scopes holding it. Exit code 1 when a
|
|
171
|
+
* workflow promises a value no scope a bound job reads can supply — that
|
|
172
|
+
* combination is the one that fails a deploy, and it fails talking about the
|
|
173
|
+
* value rather than the missing secret.
|
|
103
174
|
*/
|
|
104
|
-
export function secretsStatus(root: string, options: { repo?: string }): number {
|
|
105
|
-
const
|
|
175
|
+
export function secretsStatus(root: string, options: { repo?: string; env?: string }): number {
|
|
176
|
+
const resolved = resolveRepository(root, options.repo);
|
|
177
|
+
if (!resolved.ok) {
|
|
178
|
+
console.error(resolved.reason);
|
|
179
|
+
return 1;
|
|
180
|
+
}
|
|
181
|
+
const { repo, source } = resolved.choice;
|
|
182
|
+
const repositorySecrets = readRepositorySecrets(repo);
|
|
106
183
|
if (!repositorySecrets.ok) {
|
|
107
184
|
console.error(repositorySecrets.reason);
|
|
108
185
|
return 1;
|
|
109
186
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
187
|
+
// The workflows decide which environments a job reads; an explicitly asked
|
|
188
|
+
// for one is shown too, so an operator can inspect a scope before a workflow
|
|
189
|
+
// binds it.
|
|
190
|
+
const boundEnvironments = workflowEnvironments(root);
|
|
191
|
+
const asked = options.env !== undefined && !boundEnvironments.includes(options.env) ? [options.env] : [];
|
|
192
|
+
const environmentSecrets: Record<string, string[]> = {};
|
|
193
|
+
const unreadable: { environment: string; reason: string }[] = [];
|
|
194
|
+
for (const environment of [...boundEnvironments, ...asked]) {
|
|
195
|
+
const held = readRepositorySecrets(repo, environment);
|
|
196
|
+
if (held.ok) environmentSecrets[environment] = held.names;
|
|
197
|
+
else unreadable.push({ environment, reason: held.reason });
|
|
198
|
+
}
|
|
199
|
+
// An environment that cannot be read is reported, never assumed empty:
|
|
200
|
+
// guessing produces a refusal about a value that may well be set.
|
|
201
|
+
const readable = boundEnvironments.filter((environment) => environment in environmentSecrets);
|
|
202
|
+
const rows = reconcileSecrets(collectSources(root, repositorySecrets.names, environmentSecrets));
|
|
203
|
+
|
|
204
|
+
console.log(`repository ${repo} (${source}), holding ${repositorySecrets.names.length} secrets`);
|
|
205
|
+
for (const [environment, names] of Object.entries(environmentSecrets)) {
|
|
206
|
+
console.log(`environment ${environment}, holding ${names.length} secrets`);
|
|
207
|
+
}
|
|
208
|
+
for (const { environment, reason } of unreadable) {
|
|
209
|
+
console.log(`environment ${environment} could not be read, so nothing below claims what it holds: ${reason}`);
|
|
210
|
+
}
|
|
211
|
+
const scopeWidth = Math.max('held by'.length, ...rows.map((row) => heldBy(row).length));
|
|
212
|
+
console.log(`${'held by'.padEnd(scopeWidth)} ${'name'.padEnd(32)} workflow local declared by`);
|
|
213
|
+
for (const row of rows) console.log(describe(row, scopeWidth));
|
|
113
214
|
|
|
114
|
-
const unsatisfied = unsatisfiedSecrets(rows);
|
|
115
215
|
const unwired = unwiredSecrets(rows);
|
|
116
216
|
if (unwired.length > 0) {
|
|
117
217
|
console.log('');
|
|
@@ -122,17 +222,38 @@ export function secretsStatus(root: string, options: { repo?: string }): number
|
|
|
122
222
|
);
|
|
123
223
|
}
|
|
124
224
|
}
|
|
125
|
-
|
|
225
|
+
// A value an environment holds is not a value to duplicate at repository
|
|
226
|
+
// scope: offering it invites two sources of truth for one credential, and
|
|
227
|
+
// the table above already shows where it lives. Only a name no scope holds
|
|
228
|
+
// is a name to set here.
|
|
229
|
+
const needed = secretsNeedingValues(rows).filter((row) => !row.onRepository && row.heldByEnvironment.length === 0);
|
|
230
|
+
if (needed.length > 0) {
|
|
126
231
|
console.log('');
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
232
|
+
console.log(`${needed.length} secret(s) have no value in any scope on ${repo}:`);
|
|
233
|
+
for (const row of needed) console.log(` ${row.repositorySecret}${describeNeed(row)}`);
|
|
234
|
+
console.log('');
|
|
235
|
+
console.log(`set them all, one prompt each: smoo secrets set -R ${repo}`);
|
|
236
|
+
console.log(`set one: smoo secrets set ${needed[0]?.repositorySecret ?? 'NAME'} -R ${repo}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const unsatisfied = unsatisfiedSecrets(rows, readable);
|
|
240
|
+
if (unsatisfied.length === 0) return 0;
|
|
241
|
+
console.log('');
|
|
242
|
+
for (const row of unsatisfied) {
|
|
243
|
+
const missing = environmentsMissing(row, readable);
|
|
244
|
+
const scopes = missing.length > 0 ? missing.join(', ') : 'the repository';
|
|
245
|
+
console.error(`missing: ${row.name} — a workflow passes secrets.${row.name} and no value exists in ${scopes}.`);
|
|
246
|
+
if (missing.length === 0) {
|
|
247
|
+
console.error(` smoo secrets set ${row.repositorySecret} -R ${repo}`);
|
|
248
|
+
continue;
|
|
132
249
|
}
|
|
133
|
-
|
|
250
|
+
for (const environment of missing) {
|
|
251
|
+
const named = environment.includes(' ') ? `'${environment}'` : environment;
|
|
252
|
+
console.error(` smoo secrets set ${row.repositorySecret} -R ${repo} --env ${named}`);
|
|
253
|
+
}
|
|
254
|
+
console.error(` or one value for every environment: smoo secrets set ${row.repositorySecret} -R ${repo}`);
|
|
134
255
|
}
|
|
135
|
-
return
|
|
256
|
+
return 1;
|
|
136
257
|
}
|
|
137
258
|
|
|
138
259
|
/** Read a value with echo disabled when stdin is a terminal; otherwise read piped input. */
|
|
@@ -163,33 +284,114 @@ async function readSecretValue(prompt: string): Promise<string> {
|
|
|
163
284
|
return value;
|
|
164
285
|
}
|
|
165
286
|
|
|
166
|
-
/**
|
|
167
|
-
|
|
168
|
-
|
|
287
|
+
/**
|
|
288
|
+
* Set one secret, or - with no name - walk every secret this checkout declares
|
|
289
|
+
* and the target scope does not hold, prompting once each. The walk is the
|
|
290
|
+
* point of the command: an operator should never have to transcribe names out
|
|
291
|
+
* of a status table to know what to paste.
|
|
292
|
+
*/
|
|
293
|
+
export async function secretsSet(
|
|
294
|
+
root: string,
|
|
295
|
+
name: string | undefined,
|
|
296
|
+
options: { repo?: string; env?: string },
|
|
297
|
+
): Promise<number> {
|
|
298
|
+
const resolved = resolveRepository(root, options.repo);
|
|
299
|
+
if (!resolved.ok) {
|
|
300
|
+
console.error(resolved.reason);
|
|
301
|
+
return 1;
|
|
302
|
+
}
|
|
303
|
+
const { repo, source } = resolved.choice;
|
|
304
|
+
const environment = options.env;
|
|
305
|
+
if (name !== undefined) return setOne(name, name, repo, environment);
|
|
306
|
+
|
|
307
|
+
const held = readRepositorySecrets(repo);
|
|
308
|
+
if (!held.ok) {
|
|
309
|
+
console.error(held.reason);
|
|
310
|
+
return 1;
|
|
311
|
+
}
|
|
312
|
+
const environmentSecrets: Record<string, string[]> = {};
|
|
313
|
+
if (environment !== undefined) {
|
|
314
|
+
const inEnvironment = readRepositorySecrets(repo, environment);
|
|
315
|
+
if (!inEnvironment.ok) {
|
|
316
|
+
console.error(inEnvironment.reason);
|
|
317
|
+
return 1;
|
|
318
|
+
}
|
|
319
|
+
environmentSecrets[environment] = inEnvironment.names;
|
|
320
|
+
}
|
|
321
|
+
const rows = reconcileSecrets(collectSources(root, held.names, environmentSecrets));
|
|
322
|
+
const needed =
|
|
323
|
+
environment === undefined ? secretsNeedingValues(rows) : secretsMissingInEnvironment(rows, environment);
|
|
324
|
+
const target =
|
|
325
|
+
environment === undefined ? `${repo} (${source})` : `environment ${environment} on ${repo} (${source})`;
|
|
326
|
+
if (needed.length === 0) {
|
|
327
|
+
console.log(`${target} already holds every secret this checkout declares.`);
|
|
328
|
+
return 0;
|
|
329
|
+
}
|
|
330
|
+
console.log(`${needed.length} secret(s) to set on ${target}. Empty input skips one.`);
|
|
331
|
+
let failed = 0;
|
|
332
|
+
let set = 0;
|
|
333
|
+
for (const row of needed) {
|
|
334
|
+
console.log('');
|
|
335
|
+
console.log(`${row.repositorySecret}${describeNeed(row)}`);
|
|
336
|
+
const value = await readSecretValue(' paste value (not echoed, empty to skip): ');
|
|
337
|
+
if (value.length === 0) {
|
|
338
|
+
console.log(`skip ${row.repositorySecret}`);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const written = await writeRepositorySecret(row.repositorySecret, value, repo, environment);
|
|
342
|
+
if (!written.ok) {
|
|
343
|
+
console.error(`fail ${row.repositorySecret}: ${written.reason}`);
|
|
344
|
+
failed += 1;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
console.log(`set ${row.repositorySecret}`);
|
|
348
|
+
set += 1;
|
|
349
|
+
}
|
|
350
|
+
console.log('');
|
|
351
|
+
console.log(`${set} set, ${needed.length - set - failed} skipped, ${failed} failed.`);
|
|
352
|
+
return failed === 0 ? 0 : 1;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function setOne(
|
|
356
|
+
envName: string,
|
|
357
|
+
repositorySecret: string,
|
|
358
|
+
repo: string,
|
|
359
|
+
environment: string | undefined,
|
|
360
|
+
): Promise<number> {
|
|
361
|
+
const scope = environment === undefined ? '' : ` in ${environment}`;
|
|
362
|
+
const value = await readSecretValue(`Value for ${repositorySecret}${scope} (not echoed): `);
|
|
169
363
|
if (value.length === 0) {
|
|
170
|
-
console.error(`${
|
|
364
|
+
console.error(`${repositorySecret}: refusing to set an empty value.`);
|
|
171
365
|
return 1;
|
|
172
366
|
}
|
|
173
|
-
const written = await writeRepositorySecret(
|
|
367
|
+
const written = await writeRepositorySecret(repositorySecret, value, repo, environment);
|
|
174
368
|
if (!written.ok) {
|
|
175
369
|
console.error(written.reason);
|
|
176
370
|
return 1;
|
|
177
371
|
}
|
|
178
|
-
console.log(`set ${
|
|
372
|
+
console.log(`set ${repositorySecret}${scope}${envName === repositorySecret ? '' : ` (reads as ${envName})`}`);
|
|
179
373
|
return 0;
|
|
180
374
|
}
|
|
181
375
|
|
|
182
376
|
/**
|
|
183
|
-
* Push every locally fetchable secret to the repository
|
|
184
|
-
* `smoo.secrets` declares a command for: everything else has no
|
|
185
|
-
* and must be pasted with `smoo secrets set`.
|
|
377
|
+
* Push every locally fetchable secret to the repository, or to one environment.
|
|
378
|
+
* Only names `smoo.secrets` declares a command for: everything else has no
|
|
379
|
+
* source here and must be pasted with `smoo secrets set`.
|
|
186
380
|
*/
|
|
187
|
-
export async function secretsSync(root: string, options: { repo?: string }): Promise<number> {
|
|
381
|
+
export async function secretsSync(root: string, options: { repo?: string; env?: string }): Promise<number> {
|
|
382
|
+
const resolved = resolveRepository(root, options.repo);
|
|
383
|
+
if (!resolved.ok) {
|
|
384
|
+
console.error(resolved.reason);
|
|
385
|
+
return 1;
|
|
386
|
+
}
|
|
387
|
+
const { repo } = resolved.choice;
|
|
388
|
+
const environment = options.env;
|
|
188
389
|
const names = localSecretCommandNames(root);
|
|
189
390
|
if (names.length === 0) {
|
|
190
391
|
console.error('smoo.secrets declares no fetch commands; nothing to sync.');
|
|
191
392
|
return 1;
|
|
192
393
|
}
|
|
394
|
+
const scope = environment === undefined ? '' : ` in ${environment}`;
|
|
193
395
|
let failed = 0;
|
|
194
396
|
for (const name of names) {
|
|
195
397
|
const fetched = fetchLocalSecret(root, name);
|
|
@@ -198,13 +400,13 @@ export async function secretsSync(root: string, options: { repo?: string }): Pro
|
|
|
198
400
|
failed += 1;
|
|
199
401
|
continue;
|
|
200
402
|
}
|
|
201
|
-
const written = await writeRepositorySecret(name, fetched.value,
|
|
403
|
+
const written = await writeRepositorySecret(name, fetched.value, repo, environment);
|
|
202
404
|
if (!written.ok) {
|
|
203
405
|
console.error(`fail ${name}: ${written.reason}`);
|
|
204
406
|
failed += 1;
|
|
205
407
|
continue;
|
|
206
408
|
}
|
|
207
|
-
console.log(`set ${name}`);
|
|
409
|
+
console.log(`set ${name}${scope}`);
|
|
208
410
|
}
|
|
209
411
|
return failed === 0 ? 0 : 1;
|
|
210
412
|
}
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
2
5
|
import { repositorySecretMapping } from '../lib/secret-names.js';
|
|
3
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
environmentsMissing,
|
|
8
|
+
reconcileSecrets,
|
|
9
|
+
type SecretRow,
|
|
10
|
+
unsatisfiedSecrets,
|
|
11
|
+
unwiredSecrets,
|
|
12
|
+
workflowEnvironments,
|
|
13
|
+
} from './index.js';
|
|
4
14
|
|
|
5
15
|
const sources = {
|
|
6
16
|
workerSecrets: {
|
|
@@ -53,6 +63,7 @@ describe('secret reconciliation', () => {
|
|
|
53
63
|
suppliedByWorkflow: false,
|
|
54
64
|
fetchableLocally: false,
|
|
55
65
|
onRepository: true,
|
|
66
|
+
heldByEnvironment: [],
|
|
56
67
|
});
|
|
57
68
|
});
|
|
58
69
|
|
|
@@ -85,8 +96,114 @@ describe('secret reconciliation', () => {
|
|
|
85
96
|
suppliedByWorkflow: true,
|
|
86
97
|
fetchableLocally: false,
|
|
87
98
|
onRepository: true,
|
|
99
|
+
heldByEnvironment: [],
|
|
88
100
|
},
|
|
89
101
|
]);
|
|
90
102
|
expect(unsatisfiedSecrets(rows)).toEqual([]);
|
|
91
103
|
});
|
|
92
104
|
});
|
|
105
|
+
|
|
106
|
+
describe('environment-scoped values', () => {
|
|
107
|
+
// Two jobs bind an environment each: what `secrets.X` resolves to in them is
|
|
108
|
+
// the environment's value, and only then the repository's.
|
|
109
|
+
const bound = ['preview', 'production'];
|
|
110
|
+
const rowFor = (rows: readonly SecretRow[], name: string): SecretRow => {
|
|
111
|
+
const row = rows.find((candidate) => candidate.name === name);
|
|
112
|
+
if (!row) throw new Error(`no row for ${name}`);
|
|
113
|
+
return row;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
it('does not call a name missing when every environment a job binds holds it', () => {
|
|
117
|
+
// The false refusal this fixes: the values are set, in the scope the job
|
|
118
|
+
// actually reads, and reading repository scope alone reported them absent.
|
|
119
|
+
const rows = reconcileSecrets({
|
|
120
|
+
...sources,
|
|
121
|
+
environmentSecrets: {
|
|
122
|
+
preview: ['STRIPE_PUBLISHABLE_KEY', 'STRIPE_SECRET_KEY'],
|
|
123
|
+
production: ['STRIPE_PUBLISHABLE_KEY', 'STRIPE_SECRET_KEY'],
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(unsatisfiedSecrets(rows, bound)).toEqual([]);
|
|
128
|
+
expect(rowFor(rows, 'STRIPE_SECRET_KEY').heldByEnvironment).toEqual(['preview', 'production']);
|
|
129
|
+
expect(rowFor(rows, 'STRIPE_SECRET_KEY').onRepository).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('lets a repository value satisfy every bound environment, because a job falls back to it', () => {
|
|
133
|
+
const rows = reconcileSecrets({
|
|
134
|
+
...sources,
|
|
135
|
+
repositorySecrets: [...sources.repositorySecrets, 'STRIPE_PUBLISHABLE_KEY', 'STRIPE_SECRET_KEY'],
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(unsatisfiedSecrets(rows, bound)).toEqual([]);
|
|
139
|
+
expect(environmentsMissing(rowFor(rows, 'STRIPE_SECRET_KEY'), bound)).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('names exactly the scopes that lack a value, so each gets its own command', () => {
|
|
143
|
+
const rows = reconcileSecrets({ ...sources, environmentSecrets: { preview: ['STRIPE_SECRET_KEY'] } });
|
|
144
|
+
|
|
145
|
+
expect(unsatisfiedSecrets(rows, bound).map((row) => row.name)).toEqual([
|
|
146
|
+
'STRIPE_PUBLISHABLE_KEY',
|
|
147
|
+
'STRIPE_SECRET_KEY',
|
|
148
|
+
]);
|
|
149
|
+
expect(environmentsMissing(rowFor(rows, 'STRIPE_SECRET_KEY'), bound)).toEqual(['production']);
|
|
150
|
+
expect(environmentsMissing(rowFor(rows, 'STRIPE_PUBLISHABLE_KEY'), bound)).toEqual(['preview', 'production']);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('shows a value only an environment holds, even when nothing declares the name', () => {
|
|
154
|
+
const rows = reconcileSecrets({ ...sources, environmentSecrets: { production: ['RETIRED_TOKEN'] } });
|
|
155
|
+
|
|
156
|
+
expect(rowFor(rows, 'RETIRED_TOKEN').heldByEnvironment).toEqual(['production']);
|
|
157
|
+
expect(rowFor(rows, 'RETIRED_TOKEN').onRepository).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('workflow environment bindings', () => {
|
|
162
|
+
it('reads the scopes a workflow binds, and nothing that only looks like one', async () => {
|
|
163
|
+
const root = await mkdtemp(join(tmpdir(), 'smoo-secrets-environments-'));
|
|
164
|
+
try {
|
|
165
|
+
await mkdir(join(root, '.github', 'workflows'), { recursive: true });
|
|
166
|
+
await writeFile(
|
|
167
|
+
join(root, '.github', 'workflows', 'ci.yml'),
|
|
168
|
+
`name: CI
|
|
169
|
+
on: push
|
|
170
|
+
jobs:
|
|
171
|
+
main:
|
|
172
|
+
name: Validate
|
|
173
|
+
runs-on: ubuntu-latest
|
|
174
|
+
environment: staging
|
|
175
|
+
steps:
|
|
176
|
+
- name: Build
|
|
177
|
+
run: echo build
|
|
178
|
+
review:
|
|
179
|
+
runs-on: ubuntu-latest
|
|
180
|
+
environment: "review env"
|
|
181
|
+
steps:
|
|
182
|
+
- name: Deploy
|
|
183
|
+
run: echo deploy
|
|
184
|
+
preview:
|
|
185
|
+
runs-on: ubuntu-latest
|
|
186
|
+
environment: \${{ github.event.inputs.stage }}
|
|
187
|
+
steps:
|
|
188
|
+
- name: Deploy
|
|
189
|
+
run: echo deploy
|
|
190
|
+
production:
|
|
191
|
+
runs-on: ubuntu-latest
|
|
192
|
+
environment:
|
|
193
|
+
name: production
|
|
194
|
+
url: \${{ steps.deploy.outputs.url }}
|
|
195
|
+
steps:
|
|
196
|
+
- name: Deploy
|
|
197
|
+
run: echo deploy
|
|
198
|
+
`,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// The workflow's own name, each job's, and each step's are not scopes: a
|
|
202
|
+
// secret cannot be set in "Validate", and offering it would be a command
|
|
203
|
+
// that fails. The computed one names no fixed scope either.
|
|
204
|
+
expect(workflowEnvironments(root)).toEqual(['production', 'review env', 'staging']);
|
|
205
|
+
} finally {
|
|
206
|
+
await rm(root, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
});
|