@smoothbricks/cli 0.11.16 → 0.11.17
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 +43 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +44 -0
- package/dist/github-ci/index.d.ts +1 -1
- package/dist/github-ci/index.d.ts.map +1 -1
- package/dist/github-ci/index.js +7 -10
- package/dist/lib/secret-names.d.ts +35 -0
- package/dist/lib/secret-names.d.ts.map +1 -0
- package/dist/lib/secret-names.js +61 -0
- package/dist/monorepo/ci-workflow.d.ts.map +1 -1
- package/dist/monorepo/ci-workflow.js +15 -6
- package/dist/monorepo/publish-workflow.d.ts +10 -1
- package/dist/monorepo/publish-workflow.d.ts.map +1 -1
- package/dist/monorepo/publish-workflow.js +58 -20
- package/dist/secrets/commands.d.ts +45 -0
- package/dist/secrets/commands.d.ts.map +1 -0
- package/dist/secrets/commands.js +189 -0
- package/dist/secrets/index.d.ts +83 -0
- package/dist/secrets/index.d.ts.map +1 -0
- package/dist/secrets/index.js +149 -0
- package/dist/wrangler/deploy-stage.d.ts +13 -2
- package/dist/wrangler/deploy-stage.d.ts.map +1 -1
- package/dist/wrangler/deploy-stage.js +39 -54
- package/dist/wrangler/deployed-version.d.ts +44 -0
- package/dist/wrangler/deployed-version.d.ts.map +1 -0
- package/dist/wrangler/deployed-version.js +87 -0
- package/dist/wrangler/live-version.d.ts +146 -0
- package/dist/wrangler/live-version.d.ts.map +1 -0
- package/dist/wrangler/live-version.js +326 -0
- package/package.json +2 -2
- package/src/cli.ts +46 -1
- package/src/github-ci/index.test.ts +92 -8
- package/src/github-ci/index.ts +7 -10
- package/src/lib/secret-names.ts +70 -0
- package/src/monorepo/__tests__/ci-workflow.test.ts +27 -2
- package/src/monorepo/__tests__/publish-workflow.test.ts +33 -15
- package/src/monorepo/ci-workflow.ts +17 -6
- package/src/monorepo/managed-files.test.ts +1 -1
- package/src/monorepo/publish-workflow.ts +65 -19
- package/src/monorepo/secret-references.test.ts +1 -1
- package/src/release/__tests__/private-npm-status.test.ts +2 -2
- package/src/secrets/commands.ts +210 -0
- package/src/secrets/index.test.ts +92 -0
- package/src/secrets/index.ts +183 -0
- package/src/wrangler/deploy-stage.test.ts +190 -12
- package/src/wrangler/deploy-stage.ts +72 -45
- package/src/wrangler/deployed-version.test.ts +150 -0
- package/src/wrangler/deployed-version.ts +130 -0
- package/src/wrangler/live-version.ts +363 -0
|
@@ -49,6 +49,7 @@ const synchronizedPrettier = makeModuleSynchronized<typeof PrettierModule>(impor
|
|
|
49
49
|
|
|
50
50
|
export type PublishWorkflowBump = 'auto' | 'patch' | 'minor' | 'major' | 'prerelease';
|
|
51
51
|
export type PublishWorkflowCondition =
|
|
52
|
+
| 'plan-mode-not-none'
|
|
52
53
|
| 'version-mode-not-none'
|
|
53
54
|
| 'deploy-production'
|
|
54
55
|
| 'deploy-production-standalone'
|
|
@@ -65,6 +66,7 @@ export enum PublishWorkflowStepKind {
|
|
|
65
66
|
ConfigureReleaseAuthor = 'configure-release-author',
|
|
66
67
|
BuildNxVersionActions = 'build-nx-version-actions',
|
|
67
68
|
RepairPendingReleases = 'repair-pending-releases',
|
|
69
|
+
PlanRelease = 'plan-release',
|
|
68
70
|
VersionRelease = 'version-release',
|
|
69
71
|
CheckManagedMonorepoFiles = 'check-managed-monorepo-files',
|
|
70
72
|
Build = 'build',
|
|
@@ -159,6 +161,12 @@ export interface PublishWorkflowCallbacks {
|
|
|
159
161
|
buildNxVersionActions(): Promise<void>;
|
|
160
162
|
repairPendingReleases(input: { dryRun: boolean }): Promise<void>;
|
|
161
163
|
versionRelease(input: { bump: PublishWorkflowBump; dryRun: boolean }): Promise<PublishWorkflowVersionOutputs>;
|
|
164
|
+
/**
|
|
165
|
+
* The selection the bump WILL make, computed without writing anything, so the
|
|
166
|
+
* gates that run before the version commit know what to run over. Defaults to
|
|
167
|
+
* a dry-run `versionRelease` when a caller supplies none.
|
|
168
|
+
*/
|
|
169
|
+
planRelease?(input: { bump: PublishWorkflowBump }): Promise<PublishWorkflowVersionOutputs>;
|
|
162
170
|
checkManagedMonorepoFiles(): Promise<void>;
|
|
163
171
|
nxRunMany(input: { target: PublishWorkflowNxTarget; projects: string[] }): Promise<void>;
|
|
164
172
|
uploadTraceDbs(): Promise<void>;
|
|
@@ -183,6 +191,9 @@ type PublishWorkflowStepInput = Omit<PublishWorkflowStep, 'number'>;
|
|
|
183
191
|
|
|
184
192
|
export function definePublishWorkflow(options: PublishWorkflowDefinitionOptions = {}): PublishWorkflowDefinition {
|
|
185
193
|
const versionMode = githubExpression('steps.version.outputs.mode');
|
|
194
|
+
// Steps that run before the bump must label themselves from the plan: the
|
|
195
|
+
// version step's outputs do not exist yet, so the interpolation renders empty.
|
|
196
|
+
const planMode = githubExpression('steps.plan.outputs.mode');
|
|
186
197
|
if (options.release === false) {
|
|
187
198
|
return { steps: defineDeployOnlyWorkflowSteps(options) };
|
|
188
199
|
}
|
|
@@ -224,30 +235,37 @@ export function definePublishWorkflow(options: PublishWorkflowDefinitionOptions
|
|
|
224
235
|
kind: PublishWorkflowStepKind.RepairPendingReleases,
|
|
225
236
|
name: '🧯 Repair pending releases',
|
|
226
237
|
},
|
|
227
|
-
|
|
238
|
+
// Plan first, gate second, bump third. Lint and test hash the source, so
|
|
239
|
+
// running them BEFORE the version commit means their task hashes match
|
|
240
|
+
// the ones ci already computed for the same source — the publish reuses
|
|
241
|
+
// that cache instead of re-running 189 tasks. Build stays after the bump
|
|
242
|
+
// on purpose: an artifact built from pre-bump sources would ship the
|
|
243
|
+
// previous version string.
|
|
244
|
+
{ kind: PublishWorkflowStepKind.PlanRelease, name: '🧭 Plan release', id: 'plan' },
|
|
228
245
|
{
|
|
229
246
|
kind: PublishWorkflowStepKind.CheckManagedMonorepoFiles,
|
|
230
|
-
name: `✅ Check managed monorepo files (${
|
|
231
|
-
condition: '
|
|
232
|
-
},
|
|
233
|
-
{
|
|
234
|
-
kind: PublishWorkflowStepKind.Build,
|
|
235
|
-
name: `🔨 Build (${versionMode})`,
|
|
236
|
-
condition: 'version-mode-not-none',
|
|
237
|
-
nxTarget: 'build',
|
|
247
|
+
name: `✅ Check managed monorepo files (${planMode})`,
|
|
248
|
+
condition: 'plan-mode-not-none',
|
|
238
249
|
},
|
|
239
250
|
{
|
|
240
251
|
kind: PublishWorkflowStepKind.Lint,
|
|
241
|
-
name: `🔍 Lint (${
|
|
242
|
-
condition: '
|
|
252
|
+
name: `🔍 Lint (${planMode})`,
|
|
253
|
+
condition: 'plan-mode-not-none',
|
|
243
254
|
nxTarget: 'lint',
|
|
244
255
|
},
|
|
245
256
|
{
|
|
246
257
|
kind: PublishWorkflowStepKind.UnitTests,
|
|
247
|
-
name: `🧪 Unit Tests (${
|
|
248
|
-
condition: '
|
|
258
|
+
name: `🧪 Unit Tests (${planMode})`,
|
|
259
|
+
condition: 'plan-mode-not-none',
|
|
249
260
|
nxTarget: 'test',
|
|
250
261
|
},
|
|
262
|
+
{ kind: PublishWorkflowStepKind.VersionRelease, name: '🔢 Version release', id: 'version' },
|
|
263
|
+
{
|
|
264
|
+
kind: PublishWorkflowStepKind.Build,
|
|
265
|
+
name: `🔨 Build (${versionMode})`,
|
|
266
|
+
condition: 'version-mode-not-none',
|
|
267
|
+
nxTarget: 'build',
|
|
268
|
+
},
|
|
251
269
|
{ kind: PublishWorkflowStepKind.UploadTraceDbs, name: '📎 Upload trace DBs', condition: 'failure' },
|
|
252
270
|
{
|
|
253
271
|
kind: PublishWorkflowStepKind.ValidateMonorepoConfig,
|
|
@@ -302,10 +320,11 @@ export async function runPublishWorkflow(
|
|
|
302
320
|
): Promise<PublishWorkflowRunResult> {
|
|
303
321
|
let setupOutputs: PublishWorkflowSetupOutputs = { nixCacheHit: '', devenvCacheHit: '' };
|
|
304
322
|
let version: PublishWorkflowVersionOutputs = { mode: 'none', projects: [] };
|
|
323
|
+
let plan: PublishWorkflowVersionOutputs = { mode: 'none', projects: [] };
|
|
305
324
|
let failed = false;
|
|
306
325
|
let failure: unknown;
|
|
307
326
|
for (const step of workflow.steps) {
|
|
308
|
-
if (!shouldRunStep(step, version, failed, context.inputs)) {
|
|
327
|
+
if (!shouldRunStep(step, version, failed, context.inputs, plan)) {
|
|
309
328
|
continue;
|
|
310
329
|
}
|
|
311
330
|
try {
|
|
@@ -331,6 +350,11 @@ export async function runPublishWorkflow(
|
|
|
331
350
|
dryRun: context.inputs.dryRun,
|
|
332
351
|
});
|
|
333
352
|
break;
|
|
353
|
+
case PublishWorkflowStepKind.PlanRelease:
|
|
354
|
+
plan = context.callbacks.planRelease
|
|
355
|
+
? await context.callbacks.planRelease({ bump: context.inputs.bump })
|
|
356
|
+
: await context.callbacks.versionRelease({ bump: context.inputs.bump, dryRun: true });
|
|
357
|
+
break;
|
|
334
358
|
case PublishWorkflowStepKind.VersionRelease:
|
|
335
359
|
version = await context.callbacks.versionRelease(context.inputs);
|
|
336
360
|
break;
|
|
@@ -343,7 +367,12 @@ export async function runPublishWorkflow(
|
|
|
343
367
|
if (!step.nxTarget) {
|
|
344
368
|
throw new Error(`Workflow step ${step.kind} is missing an Nx target.`);
|
|
345
369
|
}
|
|
346
|
-
|
|
370
|
+
// Lint and test run before the bump exists, so they select from the
|
|
371
|
+
// plan; build runs after it and selects from the written version.
|
|
372
|
+
await context.callbacks.nxRunMany({
|
|
373
|
+
target: step.nxTarget,
|
|
374
|
+
projects: step.kind === PublishWorkflowStepKind.Build ? version.projects : plan.projects,
|
|
375
|
+
});
|
|
347
376
|
break;
|
|
348
377
|
case PublishWorkflowStepKind.UploadTraceDbs:
|
|
349
378
|
await context.callbacks.uploadTraceDbs();
|
|
@@ -383,7 +412,11 @@ function shouldRunStep(
|
|
|
383
412
|
version: PublishWorkflowVersionOutputs,
|
|
384
413
|
failed: boolean,
|
|
385
414
|
inputs: PublishWorkflowInputs,
|
|
415
|
+
plan: PublishWorkflowVersionOutputs = version,
|
|
386
416
|
): boolean {
|
|
417
|
+
if (step.condition === 'plan-mode-not-none') {
|
|
418
|
+
return plan.mode !== 'none';
|
|
419
|
+
}
|
|
387
420
|
if (step.condition === 'version-mode-not-none') {
|
|
388
421
|
return version.mode !== 'none';
|
|
389
422
|
}
|
|
@@ -581,6 +614,16 @@ function yamlLinesForStep(step: PublishWorkflowStep, options: PublishWorkflowDef
|
|
|
581
614
|
...privateNpmPublisherStepEnv(options),
|
|
582
615
|
` run: smoo release repair-pending --dry-run "${githubExpression('inputs.dry_run')}"`,
|
|
583
616
|
];
|
|
617
|
+
case PublishWorkflowStepKind.PlanRelease:
|
|
618
|
+
// The same selection the bump will make, computed without writing: the
|
|
619
|
+
// gates need `mode` and `projects` before any version commit exists.
|
|
620
|
+
return [
|
|
621
|
+
` - name: ${step.name}`,
|
|
622
|
+
' id: plan',
|
|
623
|
+
' run:',
|
|
624
|
+
` smoo release version --bump "${githubExpression('inputs.bump')}" --projects "${githubExpression('inputs.projects')}" --dry-run "true" --github-output`,
|
|
625
|
+
' "$GITHUB_OUTPUT"',
|
|
626
|
+
];
|
|
584
627
|
case PublishWorkflowStepKind.VersionRelease:
|
|
585
628
|
return [
|
|
586
629
|
` - name: ${step.name}`,
|
|
@@ -601,12 +644,12 @@ function yamlLinesForStep(step: PublishWorkflowStep, options: PublishWorkflowDef
|
|
|
601
644
|
case PublishWorkflowStepKind.Lint:
|
|
602
645
|
return conditionalRunStep(
|
|
603
646
|
step,
|
|
604
|
-
`smoo github-ci nx-run-many --targets lint --projects "${githubExpression('steps.
|
|
647
|
+
`smoo github-ci nx-run-many --targets lint --projects "${githubExpression('steps.plan.outputs.projects')}"`,
|
|
605
648
|
);
|
|
606
649
|
case PublishWorkflowStepKind.UnitTests:
|
|
607
650
|
return conditionalRunStep(
|
|
608
651
|
step,
|
|
609
|
-
`smoo github-ci nx-run-many --targets test --projects "${githubExpression('steps.
|
|
652
|
+
`smoo github-ci nx-run-many --targets test --projects "${githubExpression('steps.plan.outputs.projects')}"`,
|
|
610
653
|
);
|
|
611
654
|
case PublishWorkflowStepKind.UploadTraceDbs:
|
|
612
655
|
return artifactStepLines(
|
|
@@ -695,8 +738,11 @@ function deployProductionStep(step: PublishWorkflowStep, options: PublishWorkflo
|
|
|
695
738
|
}
|
|
696
739
|
|
|
697
740
|
function conditionalRunStep(step: PublishWorkflowStep, run: string): string[] {
|
|
698
|
-
|
|
699
|
-
|
|
741
|
+
// A step gated on the plan must read the PLAN's mode: it runs before the
|
|
742
|
+
// version step exists, and `steps.version.outputs.mode` is empty there, which
|
|
743
|
+
// silently skips the gate rather than running it.
|
|
744
|
+
const source = step.condition === 'plan-mode-not-none' ? 'plan' : 'version';
|
|
745
|
+
return [` - name: ${step.name}`, ` if: steps.${source}.outputs.mode != 'none'`, ` run: ${run}`];
|
|
700
746
|
}
|
|
701
747
|
|
|
702
748
|
function siblingSourceCheckoutStepLines(options: PublishWorkflowDefinitionOptions): string[] {
|
|
@@ -206,7 +206,7 @@ describe('registryAuthEnvNames', () => {
|
|
|
206
206
|
|
|
207
207
|
it('collects ${VAR} references from .npmrc text', async () => {
|
|
208
208
|
expect(
|
|
209
|
-
await names('@
|
|
209
|
+
await names('@acme:registry=https://npm.example.net\n//npm.example.net/:_authToken=${SMOO_READ_TOKEN}\n'),
|
|
210
210
|
).toEqual(['SMOO_READ_TOKEN']);
|
|
211
211
|
});
|
|
212
212
|
|
|
@@ -434,8 +434,8 @@ describe('private npm published-version status', () => {
|
|
|
434
434
|
});
|
|
435
435
|
|
|
436
436
|
it('reads through a repository .npmrc whose own auth line names an unset env', async () => {
|
|
437
|
-
// The shape that cost a day:
|
|
438
|
-
// `//host/path:_authToken=${
|
|
437
|
+
// The shape that cost a day: a repository commits
|
|
438
|
+
// `//host/path:_authToken=${NPM_PUBLISH_TOKEN}` at the workspace root.
|
|
439
439
|
// npm ranks that project file ABOVE the userconfig this CLI writes, so with
|
|
440
440
|
// the publish env unset it sent the unexpanded value and the registry
|
|
441
441
|
// answered 401 — with a perfectly good read credential in hand.
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `smoo secrets` — the operator side of the reconciliation in ./index.ts.
|
|
3
|
+
*
|
|
4
|
+
* Values are handed to `gh` over stdin, never through argv (a process list is
|
|
5
|
+
* world-readable) and never printed. Reading one from the terminal disables
|
|
6
|
+
* echo, so a pasted key does not end up in a scrollback buffer or a screen
|
|
7
|
+
* share.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
11
|
+
import { getWorkspacePackages } from '../lib/workspace.js';
|
|
12
|
+
import {
|
|
13
|
+
fetchLocalSecret,
|
|
14
|
+
localSecretCommandNames,
|
|
15
|
+
reconcileSecrets,
|
|
16
|
+
type SecretRow,
|
|
17
|
+
secretNameMapping,
|
|
18
|
+
unsatisfiedSecrets,
|
|
19
|
+
unwiredSecrets,
|
|
20
|
+
workerSecretNames,
|
|
21
|
+
workflowSecretNames,
|
|
22
|
+
} from './index.js';
|
|
23
|
+
|
|
24
|
+
/** Repository secret names GitHub currently holds, or a refusal naming what failed. */
|
|
25
|
+
export function readRepositorySecrets(
|
|
26
|
+
repo: string | undefined,
|
|
27
|
+
): { ok: true; names: string[] } | { ok: false; reason: string } {
|
|
28
|
+
const args = ['secret', 'list', '--json', 'name'];
|
|
29
|
+
if (repo) args.push('--repo', repo);
|
|
30
|
+
const result = spawnSync('gh', args, { encoding: 'utf8' });
|
|
31
|
+
if (result.status !== 0) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
reason: `gh ${args.join(' ')} exited ${result.status ?? 'without status'}: ${result.stderr.trim()}`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const parsed: unknown = JSON.parse(result.stdout);
|
|
38
|
+
if (!Array.isArray(parsed)) {
|
|
39
|
+
return { ok: false, reason: 'gh secret list did not return a list' };
|
|
40
|
+
}
|
|
41
|
+
const names: string[] = [];
|
|
42
|
+
for (const row of parsed) {
|
|
43
|
+
if (typeof row !== 'object' || row === null || !('name' in row)) continue;
|
|
44
|
+
// `in` narrows the property to unknown, so the typeof check below is the
|
|
45
|
+
// only thing that admits it — no assertion about gh's output shape.
|
|
46
|
+
const name: unknown = row.name;
|
|
47
|
+
if (typeof name === 'string') names.push(name);
|
|
48
|
+
}
|
|
49
|
+
return { ok: true, names: names.sort((left, right) => left.localeCompare(right)) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Write one secret, value over stdin so it never reaches argv. */
|
|
53
|
+
export async function writeRepositorySecret(
|
|
54
|
+
name: string,
|
|
55
|
+
value: string,
|
|
56
|
+
repo: string | undefined,
|
|
57
|
+
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
58
|
+
const args = ['secret', 'set', name];
|
|
59
|
+
if (repo) args.push('--repo', repo);
|
|
60
|
+
const child = spawn('gh', args, { stdio: ['pipe', 'inherit', 'inherit'] });
|
|
61
|
+
child.stdin.end(value);
|
|
62
|
+
const status = await new Promise<number | null>((resolvePromise) => {
|
|
63
|
+
child.on('close', (code) => resolvePromise(code));
|
|
64
|
+
});
|
|
65
|
+
return status === 0
|
|
66
|
+
? { ok: true }
|
|
67
|
+
: { ok: false, reason: `gh ${args.join(' ')} exited ${status ?? 'without status'}` };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function collectSources(root: string, repositorySecrets: readonly string[]) {
|
|
71
|
+
const workspaceDirs = getWorkspacePackages(root).map((pkg) => pkg.path);
|
|
72
|
+
const workerSecrets = workerSecretNames(root, workspaceDirs);
|
|
73
|
+
const workflowSecrets = workflowSecretNames(root);
|
|
74
|
+
const localCommands = localSecretCommandNames(root);
|
|
75
|
+
const envNames = [
|
|
76
|
+
...new Set([...Object.values(workerSecrets).flatMap((names) => [...names]), ...workflowSecrets, ...localCommands]),
|
|
77
|
+
];
|
|
78
|
+
return {
|
|
79
|
+
workerSecrets,
|
|
80
|
+
workflowSecrets,
|
|
81
|
+
secretNames: secretNameMapping(root, envNames),
|
|
82
|
+
localCommands,
|
|
83
|
+
repositorySecrets,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function describe(row: SecretRow): string {
|
|
88
|
+
const where = row.declaredByWorkers.length > 0 ? row.declaredByWorkers.join(', ') : '—';
|
|
89
|
+
return [
|
|
90
|
+
row.onRepository ? 'set ' : 'ABSENT',
|
|
91
|
+
row.name.padEnd(32),
|
|
92
|
+
row.suppliedByWorkflow ? 'workflow' : ' ',
|
|
93
|
+
row.fetchableLocally ? 'local' : ' ',
|
|
94
|
+
where,
|
|
95
|
+
].join(' ');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Print every known secret with its sources. Exit code 1 when a workflow
|
|
100
|
+
* promises a value the repository does not hold — that combination is the one
|
|
101
|
+
* that fails a deploy, and it fails talking about the value rather than the
|
|
102
|
+
* missing secret.
|
|
103
|
+
*/
|
|
104
|
+
export function secretsStatus(root: string, options: { repo?: string }): number {
|
|
105
|
+
const repositorySecrets = readRepositorySecrets(options.repo);
|
|
106
|
+
if (!repositorySecrets.ok) {
|
|
107
|
+
console.error(repositorySecrets.reason);
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
const rows = reconcileSecrets(collectSources(root, repositorySecrets.names));
|
|
111
|
+
console.log('state name workflow local declared by');
|
|
112
|
+
for (const row of rows) console.log(describe(row));
|
|
113
|
+
|
|
114
|
+
const unsatisfied = unsatisfiedSecrets(rows);
|
|
115
|
+
const unwired = unwiredSecrets(rows);
|
|
116
|
+
if (unwired.length > 0) {
|
|
117
|
+
console.log('');
|
|
118
|
+
for (const row of unwired) {
|
|
119
|
+
console.log(
|
|
120
|
+
`note: ${row.name} is declared by ${row.declaredByWorkers.join(', ')} but no managed workflow passes it; ` +
|
|
121
|
+
'declare it in smoo.github.deploySecrets to have CI supply it.',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (unsatisfied.length > 0) {
|
|
126
|
+
console.log('');
|
|
127
|
+
for (const row of unsatisfied) {
|
|
128
|
+
console.error(`missing: ${row.name} — a workflow passes secrets.${row.name} and the repository has no value.`);
|
|
129
|
+
console.error(
|
|
130
|
+
` set it with: smoo secrets set ${row.name}${options.repo ? ` --repo ${options.repo}` : ''}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return 1;
|
|
134
|
+
}
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Read a value with echo disabled when stdin is a terminal; otherwise read piped input. */
|
|
139
|
+
async function readSecretValue(prompt: string): Promise<string> {
|
|
140
|
+
if (!process.stdin.isTTY) {
|
|
141
|
+
const chunks: Buffer[] = [];
|
|
142
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
|
143
|
+
return Buffer.concat(chunks).toString('utf8').replace(/\n$/, '');
|
|
144
|
+
}
|
|
145
|
+
process.stderr.write(prompt);
|
|
146
|
+
process.stdin.setRawMode(true);
|
|
147
|
+
let value = '';
|
|
148
|
+
try {
|
|
149
|
+
for await (const chunk of process.stdin) {
|
|
150
|
+
const text = Buffer.from(chunk).toString('utf8');
|
|
151
|
+
if (text === '\r' || text === '\n' || text === '\u0004') break;
|
|
152
|
+
if (text === '\u0003') throw new Error('cancelled');
|
|
153
|
+
if (text === '\u007f') {
|
|
154
|
+
value = value.slice(0, -1);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
value += text;
|
|
158
|
+
}
|
|
159
|
+
} finally {
|
|
160
|
+
process.stdin.setRawMode(false);
|
|
161
|
+
process.stderr.write('\n');
|
|
162
|
+
}
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Set one secret from a pasted value. */
|
|
167
|
+
export async function secretsSet(name: string, options: { repo?: string }): Promise<number> {
|
|
168
|
+
const value = await readSecretValue(`Value for ${name} (not echoed): `);
|
|
169
|
+
if (value.length === 0) {
|
|
170
|
+
console.error(`${name}: refusing to set an empty value.`);
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
const written = await writeRepositorySecret(name, value, options.repo);
|
|
174
|
+
if (!written.ok) {
|
|
175
|
+
console.error(written.reason);
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
178
|
+
console.log(`set ${name}`);
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Push every locally fetchable secret to the repository. Only names
|
|
184
|
+
* `smoo.secrets` declares a command for: everything else has no source here
|
|
185
|
+
* and must be pasted with `smoo secrets set`.
|
|
186
|
+
*/
|
|
187
|
+
export async function secretsSync(root: string, options: { repo?: string }): Promise<number> {
|
|
188
|
+
const names = localSecretCommandNames(root);
|
|
189
|
+
if (names.length === 0) {
|
|
190
|
+
console.error('smoo.secrets declares no fetch commands; nothing to sync.');
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
193
|
+
let failed = 0;
|
|
194
|
+
for (const name of names) {
|
|
195
|
+
const fetched = fetchLocalSecret(root, name);
|
|
196
|
+
if (!fetched.ok) {
|
|
197
|
+
console.error(`skip ${name}: ${fetched.reason}`);
|
|
198
|
+
failed += 1;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const written = await writeRepositorySecret(name, fetched.value, options.repo);
|
|
202
|
+
if (!written.ok) {
|
|
203
|
+
console.error(`fail ${name}: ${written.reason}`);
|
|
204
|
+
failed += 1;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
console.log(`set ${name}`);
|
|
208
|
+
}
|
|
209
|
+
return failed === 0 ? 0 : 1;
|
|
210
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { repositorySecretMapping } from '../lib/secret-names.js';
|
|
3
|
+
import { reconcileSecrets, unsatisfiedSecrets, unwiredSecrets } from './index.js';
|
|
4
|
+
|
|
5
|
+
const sources = {
|
|
6
|
+
workerSecrets: {
|
|
7
|
+
'targets/billing': ['STRIPE_PUBLISHABLE_KEY', 'STRIPE_SECRET_KEY'],
|
|
8
|
+
'targets/mail': ['MAIL_CAPTURE_CONTROL_TOKEN'],
|
|
9
|
+
},
|
|
10
|
+
workflowSecrets: ['MAIL_CAPTURE_CONTROL_TOKEN', 'STRIPE_PUBLISHABLE_KEY', 'STRIPE_SECRET_KEY'],
|
|
11
|
+
localCommands: ['NPM_READ_TOKEN'],
|
|
12
|
+
secretNames: {
|
|
13
|
+
STRIPE_PUBLISHABLE_KEY: 'STRIPE_PUBLISHABLE_KEY',
|
|
14
|
+
STRIPE_SECRET_KEY: 'STRIPE_SECRET_KEY',
|
|
15
|
+
MAIL_CAPTURE_CONTROL_TOKEN: 'MAIL_CAPTURE_CONTROL_TOKEN',
|
|
16
|
+
NPM_READ_TOKEN: 'NPM_READ_TOKEN',
|
|
17
|
+
},
|
|
18
|
+
repositorySecrets: ['MAIL_CAPTURE_CONTROL_TOKEN', 'NPM_READ_TOKEN'],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('secret reconciliation', () => {
|
|
22
|
+
it('names the secret a workflow passes and the repository does not hold', () => {
|
|
23
|
+
// A preview stage refuses with `publishable_key_mismatch` when `ci.yml`
|
|
24
|
+
// passes `secrets.STRIPE_PUBLISHABLE_KEY` and the repository holds no such
|
|
25
|
+
// secret: the empty value reaches the payment library's validator, which
|
|
26
|
+
// talks about the key instead of the missing declaration.
|
|
27
|
+
const unsatisfied = unsatisfiedSecrets(reconcileSecrets(sources)).map((row) => row.name);
|
|
28
|
+
|
|
29
|
+
expect(unsatisfied).toEqual(['STRIPE_PUBLISHABLE_KEY', 'STRIPE_SECRET_KEY']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('keeps "declared but no workflow passes it" separate from "no value"', () => {
|
|
33
|
+
// The remedies differ: one is `smoo secrets set`, the other is a
|
|
34
|
+
// smoo.github.deploySecrets entry. Reporting them as one list sends an
|
|
35
|
+
// operator to the wrong fix.
|
|
36
|
+
const rows = reconcileSecrets({
|
|
37
|
+
...sources,
|
|
38
|
+
workflowSecrets: ['MAIL_CAPTURE_CONTROL_TOKEN'],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(unwiredSecrets(rows).map((row) => row.name)).toEqual(['STRIPE_PUBLISHABLE_KEY', 'STRIPE_SECRET_KEY']);
|
|
42
|
+
expect(unsatisfiedSecrets(rows)).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('reports a repository secret no source declares, so a stale one is visible', () => {
|
|
46
|
+
const rows = reconcileSecrets({ ...sources, repositorySecrets: [...sources.repositorySecrets, 'RETIRED_TOKEN'] });
|
|
47
|
+
const retired = rows.find((row) => row.name === 'RETIRED_TOKEN');
|
|
48
|
+
|
|
49
|
+
expect(retired).toEqual({
|
|
50
|
+
name: 'RETIRED_TOKEN',
|
|
51
|
+
repositorySecret: 'RETIRED_TOKEN',
|
|
52
|
+
declaredByWorkers: [],
|
|
53
|
+
suppliedByWorkflow: false,
|
|
54
|
+
fetchableLocally: false,
|
|
55
|
+
onRepository: true,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('attributes a shared secret to every worker that declares it', () => {
|
|
60
|
+
const rows = reconcileSecrets({
|
|
61
|
+
...sources,
|
|
62
|
+
workerSecrets: { 'targets/a': ['SHARED'], 'targets/b': ['SHARED'] },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(rows.find((row) => row.name === 'SHARED')?.declaredByWorkers).toEqual(['targets/a', 'targets/b']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('reads a reserved env name from its owner-prefixed secret, with no declaration', () => {
|
|
69
|
+
// GitHub rejects `gh secret set GITHUB_*`, so the value cannot live under
|
|
70
|
+
// its own name. Operators write the owner-prefixed name by hand in the
|
|
71
|
+
// workflow; the convention derives exactly that from the repository owner.
|
|
72
|
+
const rows = reconcileSecrets({
|
|
73
|
+
workerSecrets: { 'targets/backend': ['GITHUB_CLIENT_SECRET'] },
|
|
74
|
+
workflowSecrets: ['GITHUB_CLIENT_SECRET'],
|
|
75
|
+
secretNames: repositorySecretMapping(['GITHUB_CLIENT_SECRET'], 'acme'),
|
|
76
|
+
localCommands: [],
|
|
77
|
+
repositorySecrets: ['ACME_GITHUB_CLIENT_SECRET'],
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(rows).toEqual([
|
|
81
|
+
{
|
|
82
|
+
name: 'GITHUB_CLIENT_SECRET',
|
|
83
|
+
repositorySecret: 'ACME_GITHUB_CLIENT_SECRET',
|
|
84
|
+
declaredByWorkers: ['targets/backend'],
|
|
85
|
+
suppliedByWorkflow: true,
|
|
86
|
+
fetchableLocally: false,
|
|
87
|
+
onRepository: true,
|
|
88
|
+
},
|
|
89
|
+
]);
|
|
90
|
+
expect(unsatisfiedSecrets(rows)).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
});
|