@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.
Files changed (49) hide show
  1. package/README.md +43 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +44 -0
  4. package/dist/github-ci/index.d.ts +1 -1
  5. package/dist/github-ci/index.d.ts.map +1 -1
  6. package/dist/github-ci/index.js +7 -10
  7. package/dist/lib/secret-names.d.ts +35 -0
  8. package/dist/lib/secret-names.d.ts.map +1 -0
  9. package/dist/lib/secret-names.js +61 -0
  10. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  11. package/dist/monorepo/ci-workflow.js +15 -6
  12. package/dist/monorepo/publish-workflow.d.ts +10 -1
  13. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  14. package/dist/monorepo/publish-workflow.js +58 -20
  15. package/dist/secrets/commands.d.ts +45 -0
  16. package/dist/secrets/commands.d.ts.map +1 -0
  17. package/dist/secrets/commands.js +189 -0
  18. package/dist/secrets/index.d.ts +83 -0
  19. package/dist/secrets/index.d.ts.map +1 -0
  20. package/dist/secrets/index.js +149 -0
  21. package/dist/wrangler/deploy-stage.d.ts +13 -2
  22. package/dist/wrangler/deploy-stage.d.ts.map +1 -1
  23. package/dist/wrangler/deploy-stage.js +39 -54
  24. package/dist/wrangler/deployed-version.d.ts +44 -0
  25. package/dist/wrangler/deployed-version.d.ts.map +1 -0
  26. package/dist/wrangler/deployed-version.js +87 -0
  27. package/dist/wrangler/live-version.d.ts +146 -0
  28. package/dist/wrangler/live-version.d.ts.map +1 -0
  29. package/dist/wrangler/live-version.js +326 -0
  30. package/package.json +2 -2
  31. package/src/cli.ts +46 -1
  32. package/src/github-ci/index.test.ts +92 -8
  33. package/src/github-ci/index.ts +7 -10
  34. package/src/lib/secret-names.ts +70 -0
  35. package/src/monorepo/__tests__/ci-workflow.test.ts +27 -2
  36. package/src/monorepo/__tests__/publish-workflow.test.ts +33 -15
  37. package/src/monorepo/ci-workflow.ts +17 -6
  38. package/src/monorepo/managed-files.test.ts +1 -1
  39. package/src/monorepo/publish-workflow.ts +65 -19
  40. package/src/monorepo/secret-references.test.ts +1 -1
  41. package/src/release/__tests__/private-npm-status.test.ts +2 -2
  42. package/src/secrets/commands.ts +210 -0
  43. package/src/secrets/index.test.ts +92 -0
  44. package/src/secrets/index.ts +183 -0
  45. package/src/wrangler/deploy-stage.test.ts +190 -12
  46. package/src/wrangler/deploy-stage.ts +72 -45
  47. package/src/wrangler/deployed-version.test.ts +150 -0
  48. package/src/wrangler/deployed-version.ts +130 -0
  49. 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
- { kind: PublishWorkflowStepKind.VersionRelease, name: '🔢 Version release', id: 'version' },
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 (${versionMode})`,
231
- condition: 'version-mode-not-none',
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 (${versionMode})`,
242
- condition: 'version-mode-not-none',
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 (${versionMode})`,
248
- condition: 'version-mode-not-none',
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
- await context.callbacks.nxRunMany({ target: step.nxTarget, projects: version.projects });
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.version.outputs.projects')}"`,
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.version.outputs.projects')}"`,
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
- const condition = "steps.version.outputs.mode != 'none'";
699
- return [` - name: ${step.name}`, ` if: ${condition}`, ` run: ${run}`];
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('@axe.sc:registry=https://npm.example.net\n//npm.example.net/:_authToken=${SMOO_READ_TOKEN}\n'),
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: AxE commits
438
- // `//host/path:_authToken=${AXE_NPM_PUBLISH_TOKEN}` at the workspace root.
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
+ });