@smoothbricks/cli 0.11.16 → 0.11.18

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 (79) hide show
  1. package/README.md +65 -4
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +47 -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 +56 -0
  11. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  12. package/dist/monorepo/ci-workflow.js +214 -8
  13. package/dist/monorepo/managed-files.d.ts +23 -0
  14. package/dist/monorepo/managed-files.d.ts.map +1 -1
  15. package/dist/monorepo/managed-files.js +39 -1
  16. package/dist/monorepo/publish-workflow.d.ts +10 -1
  17. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  18. package/dist/monorepo/publish-workflow.js +58 -20
  19. package/dist/release/github-release.d.ts +10 -0
  20. package/dist/release/github-release.d.ts.map +1 -1
  21. package/dist/release/github-release.js +11 -5
  22. package/dist/release/index.d.ts.map +1 -1
  23. package/dist/release/index.js +4 -5
  24. package/dist/secrets/commands.d.ts +84 -0
  25. package/dist/secrets/commands.d.ts.map +1 -0
  26. package/dist/secrets/commands.js +375 -0
  27. package/dist/secrets/index.d.ts +103 -0
  28. package/dist/secrets/index.d.ts.map +1 -0
  29. package/dist/secrets/index.js +255 -0
  30. package/dist/secrets/repository.d.ts +60 -0
  31. package/dist/secrets/repository.d.ts.map +1 -0
  32. package/dist/secrets/repository.js +145 -0
  33. package/dist/wrangler/cloudflare.d.ts +7 -0
  34. package/dist/wrangler/cloudflare.d.ts.map +1 -1
  35. package/dist/wrangler/cloudflare.js +10 -0
  36. package/dist/wrangler/deploy-stage.d.ts +13 -2
  37. package/dist/wrangler/deploy-stage.d.ts.map +1 -1
  38. package/dist/wrangler/deploy-stage.js +89 -78
  39. package/dist/wrangler/deployed-version.d.ts +44 -0
  40. package/dist/wrangler/deployed-version.d.ts.map +1 -0
  41. package/dist/wrangler/deployed-version.js +87 -0
  42. package/dist/wrangler/live-version.d.ts +146 -0
  43. package/dist/wrangler/live-version.d.ts.map +1 -0
  44. package/dist/wrangler/live-version.js +326 -0
  45. package/dist/wrangler/stage-secrets.d.ts +57 -0
  46. package/dist/wrangler/stage-secrets.d.ts.map +1 -0
  47. package/dist/wrangler/stage-secrets.js +178 -0
  48. package/managed/raw/tooling/direnv/devenv.smoo.nix +9 -1
  49. package/managed/raw/tooling/git-hooks/pre-push.sh +30 -29
  50. package/package.json +2 -2
  51. package/src/cli.ts +69 -1
  52. package/src/github-ci/index.test.ts +92 -8
  53. package/src/github-ci/index.ts +7 -10
  54. package/src/lib/secret-names.ts +70 -0
  55. package/src/monorepo/__tests__/ci-workflow.test.ts +188 -2
  56. package/src/monorepo/__tests__/publish-workflow.test.ts +33 -15
  57. package/src/monorepo/ci-workflow.ts +294 -8
  58. package/src/monorepo/managed-files.test.ts +28 -1
  59. package/src/monorepo/managed-files.ts +56 -2
  60. package/src/monorepo/package-policy.test.ts +1 -1
  61. package/src/monorepo/publish-workflow.ts +65 -19
  62. package/src/monorepo/secret-references.test.ts +1 -1
  63. package/src/release/__tests__/github-release.test.ts +8 -4
  64. package/src/release/__tests__/private-npm-status.test.ts +2 -2
  65. package/src/release/github-release.ts +13 -5
  66. package/src/release/index.ts +4 -4
  67. package/src/secrets/commands.test.ts +28 -0
  68. package/src/secrets/commands.ts +412 -0
  69. package/src/secrets/index.test.ts +209 -0
  70. package/src/secrets/index.ts +287 -0
  71. package/src/secrets/repository.test.ts +98 -0
  72. package/src/secrets/repository.ts +164 -0
  73. package/src/wrangler/cloudflare.ts +18 -0
  74. package/src/wrangler/deploy-stage.test.ts +448 -23
  75. package/src/wrangler/deploy-stage.ts +142 -83
  76. package/src/wrangler/deployed-version.test.ts +150 -0
  77. package/src/wrangler/deployed-version.ts +130 -0
  78. package/src/wrangler/live-version.ts +363 -0
  79. package/src/wrangler/stage-secrets.ts +146 -0
@@ -1,17 +1,18 @@
1
1
  import { afterEach, describe, expect, it } from 'bun:test';
2
- import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { existsSync, mkdtempSync, readFileSync, statSync } from 'node:fs';
3
3
  import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import type {
7
- CloudflareClient,
8
- CloudflareZone,
9
- D1DatabaseRecord,
10
- DnsRecord,
11
- R2Bucket,
12
- WorkerDomain,
13
- WorkerRoute,
14
- WorkerScript,
6
+ import {
7
+ CloudflareApiError,
8
+ type CloudflareClient,
9
+ type CloudflareZone,
10
+ type D1DatabaseRecord,
11
+ type DnsRecord,
12
+ type R2Bucket,
13
+ type WorkerDomain,
14
+ type WorkerRoute,
15
+ type WorkerScript,
15
16
  } from './cloudflare.js';
16
17
  import {
17
18
  childEnvironment,
@@ -22,6 +23,12 @@ import {
22
23
  type ProcessRunOptions,
23
24
  writeTemporaryConfigForTest,
24
25
  } from './deploy-stage.js';
26
+ import {
27
+ type FetchLike,
28
+ findVersionIdByTag,
29
+ LIVE_VERSION_CACHE_TTL_MS,
30
+ readCachedLiveVersion,
31
+ } from './live-version.js';
25
32
  import type { LiveKvNamespace } from './stage.js';
26
33
 
27
34
  const HASH = '16577780061662788004';
@@ -58,19 +65,49 @@ class FakeRunner implements ProcessRunner {
58
65
  secretsJson: string | undefined;
59
66
  /** Runs before the fake reports success, so a test can read a temporary file the command was handed. */
60
67
  onCall: ((args: string[], cwd: string) => Promise<void>) | undefined;
68
+ /**
69
+ * How many `deployments status` reads still answer with the OLD version after a traffic shift.
70
+ * This is the whole point of the fake: Cloudflare accepts the shift before it serves it.
71
+ */
72
+ propagationPolls = 0;
73
+
74
+ private pendingVersionId: string | null = null;
61
75
 
62
76
  constructor(
63
- private readonly versions: unknown = [],
64
- private readonly deployment: unknown = {},
77
+ private versions: unknown = [],
78
+ private deployment: unknown = {},
65
79
  ) {}
66
80
 
81
+ /** The versions payload as a growable list, so an upload can add the version it just created. */
82
+ private versionList(): unknown[] {
83
+ const list: unknown[] = Array.isArray(this.versions) ? this.versions : [this.versions];
84
+ this.versions = list;
85
+ return list;
86
+ }
87
+
88
+ private shiftTrafficTo(versionId: string): void {
89
+ this.pendingVersionId = versionId;
90
+ }
91
+
92
+ private readDeployment(): unknown {
93
+ if (this.pendingVersionId !== null) {
94
+ if (this.propagationPolls > 0) {
95
+ this.propagationPolls -= 1;
96
+ } else {
97
+ this.deployment = { versions: [{ version_id: this.pendingVersionId, percentage: 100 }] };
98
+ this.pendingVersionId = null;
99
+ }
100
+ }
101
+ return this.deployment;
102
+ }
103
+
67
104
  async run(command: string, args: string[], options: ProcessRunOptions): Promise<ProcessResult> {
68
105
  this.calls.push({ command, args, cwd: options.cwd, env: childEnvironment(options) });
69
106
  if (args[0] === 'versions' && args[1] === 'list') {
70
107
  return success(this.versions);
71
108
  }
72
109
  if (args[0] === 'deployments' && args[1] === 'status') {
73
- return success(this.deployment);
110
+ return success(this.readDeployment());
74
111
  }
75
112
  const configIndex = args.indexOf('--config');
76
113
  if (configIndex >= 0) {
@@ -84,6 +121,21 @@ class FakeRunner implements ProcessRunner {
84
121
  this.secretsMode = statSync(secretsPath).mode & 0o777;
85
122
  this.secretsJson = readFileSync(secretsPath, 'utf8');
86
123
  }
124
+ if (args[0] === 'versions' && args[1] === 'deploy') {
125
+ // Resolved with the same reader the deploy uses, so the fake cannot agree with a lookup
126
+ // production would miss.
127
+ const tag = args[args.indexOf('--version-tag') + 1];
128
+ const existing = tag ? findVersionIdByTag(this.versions, tag) : null;
129
+ if (existing) this.shiftTrafficTo(existing);
130
+ }
131
+ if (args[0] === 'deploy') {
132
+ const tagIndex = args.indexOf('--tag');
133
+ if (tagIndex >= 0) {
134
+ const versionId = `uploaded-${args[tagIndex + 1]}`;
135
+ this.versionList().push({ id: versionId, annotations: { 'workers/tag': args[tagIndex + 1] } });
136
+ this.shiftTrafficTo(versionId);
137
+ }
138
+ }
87
139
  await this.onCall?.(args, options.cwd);
88
140
  return success({});
89
141
  }
@@ -93,6 +145,10 @@ class FakeCloudflare implements CloudflareClient {
93
145
  namespaces: LiveKvNamespace[] = [];
94
146
  buckets: R2Bucket[] = [];
95
147
  scripts: WorkerScript[] = [{ id: 'fixture-worker-pr123' }];
148
+ /** Secret names each Worker already holds, by Worker name. */
149
+ secrets: Record<string, string[]> = {};
150
+ /** Every Worker whose secrets were queried, in order, so a test can prove one was never asked. */
151
+ secretQueries: string[] = [];
96
152
  domains: WorkerDomain[] = [];
97
153
  zones: CloudflareZone[] = [];
98
154
  routes: Record<string, WorkerRoute[]> = {};
@@ -132,6 +188,20 @@ class FakeCloudflare implements CloudflareClient {
132
188
  async listWorkerScripts(): Promise<WorkerScript[]> {
133
189
  return this.scripts;
134
190
  }
191
+ async listWorkerSecrets(workerName: string): Promise<string[]> {
192
+ this.secretQueries.push(workerName);
193
+ // Cloudflare answers 404 for a Worker that is not there. Wrangler used to hand that back as an
194
+ // empty list; it no longer does, and code that reads the failure as "no secrets" inverts the
195
+ // one check a first deployment depends on.
196
+ if (!this.scripts.some((script) => script.id === workerName)) {
197
+ throw new CloudflareApiError(
198
+ `Cloudflare API /workers/scripts/${workerName}/secrets failed: not found`,
199
+ 404,
200
+ [10007],
201
+ );
202
+ }
203
+ return this.secrets[workerName] ?? [];
204
+ }
135
205
  async deleteWorkerScript(name: string): Promise<void> {
136
206
  this.mutations.push(`delete-worker:${name}`);
137
207
  }
@@ -179,8 +249,8 @@ class FakeCloudflare implements CloudflareClient {
179
249
  }
180
250
  }
181
251
 
182
- describe('deploy-stage remote version fallback', () => {
183
- it('returns a remote cache hit for the active tagged 100% version', async () => {
252
+ describe('deploy-stage against live state', () => {
253
+ it('uploads nothing and shifts no traffic when the live version already is the task hash', async () => {
184
254
  const root = await fixtureRoot();
185
255
  const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
186
256
  versions: [{ version_id: 'version-1', percentage: 100 }],
@@ -189,14 +259,17 @@ describe('deploy-stage remote version fallback', () => {
189
259
  const result = await deployStage(root, { stage: 'pr123' }, dependencies(runner, new FakeCloudflare()));
190
260
 
191
261
  expect(result.action).toBe('remote-cache-hit');
262
+ // Two reads and nothing else. This is what makes a redundant deploy — the one a cross-project
263
+ // `dependsOn` edge adds — provably free instead of assumed free.
192
264
  expect(runner.calls.map((call) => call.args.slice(0, 2))).toEqual([
193
265
  ['versions', 'list'],
194
266
  ['deployments', 'status'],
195
267
  ]);
196
268
  });
197
269
 
198
- it('activates an existing tagged version that is not current', async () => {
270
+ it('redeploys after a rollback, when the live version is no longer the task hash', async () => {
199
271
  const root = await fixtureRoot();
272
+ // The build for this hash is still uploaded; someone rolled traffic back to the older version.
200
273
  const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
201
274
  versions: [{ version_id: 'version-2', percentage: 100 }],
202
275
  });
@@ -204,11 +277,108 @@ describe('deploy-stage remote version fallback', () => {
204
277
  const result = await deployStage(root, { stage: 'pr123' }, dependencies(runner, new FakeCloudflare()));
205
278
 
206
279
  expect(result.action).toBe('activated');
207
- expect(runner.calls.at(-1)?.args.slice(0, 3)).toEqual(['versions', 'deploy', '--version-tag']);
208
- expect(runner.configPathSeen).toBeDefined();
280
+ const shiftIndex = runner.calls.findIndex((call) => call.args[0] === 'versions' && call.args[1] === 'deploy');
281
+ expect(runner.calls[shiftIndex]?.args.slice(0, 4)).toEqual(['versions', 'deploy', '--version-tag', `nx-${HASH}`]);
282
+ // And the step does not return until it has read live state again after that shift.
283
+ expect(runner.calls.slice(shiftIndex + 1).some((call) => call.args[0] === 'deployments')).toBe(true);
209
284
  expect(existsSync(requiredTestValue(runner.configPathSeen, 'config path'))).toBe(false);
210
285
  });
211
286
 
287
+ it('waits out propagation before reporting the deploy done', async () => {
288
+ const root = await fixtureRoot();
289
+ const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
290
+ versions: [{ version_id: 'version-2', percentage: 100 }],
291
+ });
292
+ runner.propagationPolls = 3;
293
+
294
+ const result = await deployStage(root, { stage: 'pr123' }, dependencies(runner, new FakeCloudflare()));
295
+
296
+ expect(result.action).toBe('activated');
297
+ const statusReads = runner.calls.filter((call) => call.args[0] === 'deployments').length;
298
+ expect(statusReads).toBe(5);
299
+ });
300
+
301
+ it('fails with what it expected and what it saw when the version never becomes live', async () => {
302
+ const root = await fixtureRoot();
303
+ const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
304
+ versions: [{ version_id: 'version-2', percentage: 100 }],
305
+ });
306
+ runner.propagationPolls = Number.POSITIVE_INFINITY;
307
+
308
+ await expect(deployStage(root, { stage: 'pr123' }, dependencies(runner, new FakeCloudflare()))).rejects.toThrow(
309
+ `fixture-worker-pr123: deployed version nx-${HASH} was not serving traffic after 10s; live is an untagged version (version-2)`,
310
+ );
311
+ });
312
+
313
+ it('leaves the cache holding the tag it made live, so the next query needs no API call', async () => {
314
+ const root = await fixtureRoot();
315
+ const cacheDirectory = await mkdtemp(join(tmpdir(), 'smoo-live-version-'));
316
+ roots.push(cacheDirectory);
317
+ const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
318
+ versions: [{ version_id: 'version-2', percentage: 100 }],
319
+ });
320
+
321
+ await deployStage(
322
+ root,
323
+ { stage: 'pr123' },
324
+ { ...dependencies(runner, new FakeCloudflare()), liveVersionCacheDirectory: cacheDirectory },
325
+ );
326
+
327
+ const cached = await readCachedLiveVersion(
328
+ cacheDirectory,
329
+ { accountId: 'account-1', workerName: 'fixture-worker-pr123', stage: 'pr123' },
330
+ LIVE_VERSION_CACHE_TTL_MS,
331
+ );
332
+ expect(cached).toMatchObject({ versionTag: `nx-${HASH}`, versionId: 'version-1' });
333
+ });
334
+
335
+ it('is not done until a declared version endpoint answers with the new tag', async () => {
336
+ const root = await fixtureRoot();
337
+ const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
338
+ versions: [{ version_id: 'version-2', percentage: 100 }],
339
+ });
340
+ const bodies = [`nx-${'0'.repeat(20)}`, `nx-${'0'.repeat(20)}`, `nx-${HASH}`];
341
+ const requested: string[] = [];
342
+ const stubFetch: FetchLike = async (input) => {
343
+ requested.push(String(input));
344
+ return new Response(bodies.shift() ?? `nx-${HASH}`);
345
+ };
346
+
347
+ const result = await deployStage(
348
+ root,
349
+ { stage: 'pr123', versionEndpoint: 'https://fixture.example.test/__version' },
350
+ {
351
+ ...dependencies(runner, new FakeCloudflare()),
352
+ wait: { ...fakeWait(), fetch: stubFetch },
353
+ },
354
+ );
355
+
356
+ expect(result.action).toBe('activated');
357
+ expect(requested).toEqual([
358
+ 'https://fixture.example.test/__version',
359
+ 'https://fixture.example.test/__version',
360
+ 'https://fixture.example.test/__version',
361
+ ]);
362
+ });
363
+
364
+ it('fails naming the endpoint answer when the edge never reports the new tag', async () => {
365
+ const root = await fixtureRoot();
366
+ const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
367
+ versions: [{ version_id: 'version-2', percentage: 100 }],
368
+ });
369
+ const stubFetch: FetchLike = async () => new Response('nx-previous');
370
+
371
+ await expect(
372
+ deployStage(
373
+ root,
374
+ { stage: 'pr123', versionEndpoint: 'https://fixture.example.test/__version' },
375
+ { ...dependencies(runner, new FakeCloudflare()), wait: { ...fakeWait(), fetch: stubFetch } },
376
+ ),
377
+ ).rejects.toThrow(
378
+ `fixture-worker-pr123: https://fixture.example.test/__version did not report version nx-${HASH} after 10s; it reported nx-previous`,
379
+ );
380
+ });
381
+
212
382
  it('uploads a missing tag with a temporary config and secure secrets file, then removes both', async () => {
213
383
  const root = await fixtureRoot();
214
384
  await writeFile(join(root, '.dev.vars.example'), 'FIXTURE_SECRET=""\nFIXTURE_TOKEN=""\n');
@@ -231,9 +401,12 @@ describe('deploy-stage remote version fallback', () => {
231
401
  );
232
402
 
233
403
  expect(result.action).toBe('deployed');
234
- expect(runner.calls.at(-1)?.args[0]).toBe('deploy');
235
- expect(runner.calls.at(-1)?.args).toContain('--tag');
236
- expect(runner.calls.at(-1)?.args).toContain(`nx-${HASH}`);
404
+ const upload = requiredTestValue(
405
+ runner.calls.find((call) => call.args[0] === 'deploy'),
406
+ 'upload call',
407
+ );
408
+ expect(upload.args).toContain('--tag');
409
+ expect(upload.args).toContain(`nx-${HASH}`);
237
410
  expect(runner.secretsMode).toBe(0o600);
238
411
  expect(JSON.parse(requiredTestValue(runner.secretsJson, 'secrets JSON'))).toEqual({
239
412
  FIXTURE_SECRET: 'shared-secret',
@@ -243,11 +416,13 @@ describe('deploy-stage remote version fallback', () => {
243
416
  expect(existsSync(requiredTestValue(runner.secretsPathSeen, 'secrets path'))).toBe(false);
244
417
  });
245
418
 
246
- it('passes only present manifest values for fixed stages and preserves absent remote secrets', async () => {
419
+ it('sends only the values it has and leaves a secret the Worker already holds untouched', async () => {
247
420
  const root = await fixtureRoot();
248
421
  await writeFile(join(root, '.dev.vars.example'), 'FIXTURE_SECRET=""\nFIXTURE_TOKEN=""\n');
249
422
  const runner = new FakeRunner([], {});
250
423
  const cloudflare = new FakeCloudflare();
424
+ cloudflare.scripts = [{ id: 'fixture-worker-staging' }];
425
+ cloudflare.secrets['fixture-worker-staging'] = ['FIXTURE_TOKEN'];
251
426
 
252
427
  const result = await deployStage(
253
428
  root,
@@ -263,6 +438,8 @@ describe('deploy-stage remote version fallback', () => {
263
438
  },
264
439
  );
265
440
 
441
+ // `--secrets-file` applies additively, so naming only what this shell has leaves FIXTURE_TOKEN
442
+ // exactly as the Worker holds it. That silence is only safe because the gate proved it is held.
266
443
  expect(result.action).toBe('deployed');
267
444
  expect(JSON.parse(requiredTestValue(runner.secretsJson, 'secrets JSON'))).toEqual({
268
445
  FIXTURE_SECRET: 'shared-secret',
@@ -313,6 +490,230 @@ describe('deploy-stage remote version fallback', () => {
313
490
  });
314
491
  });
315
492
 
493
+ /**
494
+ * `smoo.wrangler.secretStages` answers two questions from one declaration, and both are load
495
+ * bearing: which secrets a stage must have before it may deploy, and which secrets it is allowed
496
+ * to receive at all. The second direction is the security-relevant one — a capability CI exports
497
+ * for preview stages must not ride a production deploy just because the variable is set.
498
+ */
499
+ describe('stage-scoped deployment secrets', () => {
500
+ const STAGED_FIXTURE = `${FIXTURE}
501
+ [env.production]
502
+ name = "fixture-worker-production"
503
+ workers_dev = false
504
+
505
+ [env.production.vars]
506
+ ENVIRONMENT = "production"
507
+ `;
508
+
509
+ async function scopedRoot(secrets: string[], secretStages?: Record<string, string[]>): Promise<string> {
510
+ const root = await fixtureRoot(STAGED_FIXTURE);
511
+ await writeFile(join(root, '.dev.vars.example'), secrets.map((name) => `${name}=""\n`).join(''));
512
+ if (secretStages) {
513
+ await writeFile(
514
+ join(root, 'package.json'),
515
+ `${JSON.stringify({ name: '@acme/api', smoo: { wrangler: { secretStages } } }, null, 2)}\n`,
516
+ );
517
+ }
518
+ return root;
519
+ }
520
+
521
+ function environment(values: Record<string, string> = {}): NodeJS.ProcessEnv {
522
+ return { CLOUDFLARE_ACCOUNT_ID: 'account-1', CLOUDFLARE_API_TOKEN: 'token', ...values };
523
+ }
524
+
525
+ it('refuses a first deploy whose stage-required secret is exported nowhere, naming every one', async () => {
526
+ const root = await scopedRoot(['SESSION_SECRET', 'OAUTH_STATE_KEY'], {
527
+ OAUTH_STATE_KEY: ['staging', 'production'],
528
+ });
529
+ const runner = new FakeRunner([], {});
530
+ const cloudflare = new FakeCloudflare();
531
+
532
+ await expect(
533
+ deployStage(root, { stage: 'production' }, { runner, cloudflare, processEnv: environment() }),
534
+ ).rejects.toThrow(
535
+ /Refusing to deploy fixture-worker-production to production[\s\S]*SESSION_SECRET[\s\S]*OAUTH_STATE_KEY/,
536
+ );
537
+ expect(cloudflare.mutations).toEqual([]);
538
+ expect(runner.calls).toEqual([]);
539
+ });
540
+
541
+ it('treats a Worker that is not there as holding nothing, never as needing nothing', async () => {
542
+ const root = await scopedRoot(['OAUTH_STATE_KEY']);
543
+ const cloudflare = new FakeCloudflare();
544
+
545
+ // The double answers 404 for an absent Worker, exactly as Cloudflare does. Reading that error
546
+ // as "no secrets" would let this deploy through; asking at all would surface the 404 instead
547
+ // of the refusal. Neither happens: the script listing already said the Worker holds nothing.
548
+ await expect(
549
+ deployStage(
550
+ root,
551
+ { stage: 'production' },
552
+ { runner: new FakeRunner([], {}), cloudflare, processEnv: environment() },
553
+ ),
554
+ ).rejects.toThrow(/Refusing to deploy fixture-worker-production to production[\s\S]*OAUTH_STATE_KEY/);
555
+ expect(cloudflare.secretQueries).toEqual([]);
556
+ expect(cloudflare.mutations).toEqual([]);
557
+ });
558
+
559
+ it('requires an unscoped secret of every stage, including a pull-request stage', async () => {
560
+ const root = await scopedRoot(['SESSION_SECRET'], { OTHER_SECRET: ['staging'] });
561
+ await writeFile(join(root, '.dev.vars.example'), 'SESSION_SECRET=""\nOTHER_SECRET=""\n');
562
+ const cloudflare = new FakeCloudflare();
563
+
564
+ await expect(
565
+ deployStage(root, { stage: 'pr77' }, dependencies(new FakeRunner([], {}), cloudflare)),
566
+ ).rejects.toThrow(/fixture-worker-pr77[\s\S]*SESSION_SECRET — unscoped, so every stage requires it/);
567
+ expect(cloudflare.mutations).toEqual([]);
568
+ });
569
+
570
+ it('does not require a preview-scoped secret of production', async () => {
571
+ const root = await scopedRoot(['E2E_CONTROL_TOKEN'], { E2E_CONTROL_TOKEN: ['staging', 'preview'] });
572
+ const runner = new FakeRunner([], {});
573
+ const cloudflare = new FakeCloudflare();
574
+
575
+ const result = await deployStage(root, { stage: 'production' }, { runner, cloudflare, processEnv: environment() });
576
+
577
+ expect(result).toMatchObject({ action: 'deployed', workerName: 'fixture-worker-production' });
578
+ expect(runner.secretsPathSeen).toBeUndefined();
579
+ });
580
+
581
+ it('withholds a preview-scoped secret from production even when the deploying shell exports it', async () => {
582
+ const root = await scopedRoot(['API_TOKEN', 'E2E_CONTROL_TOKEN'], { E2E_CONTROL_TOKEN: ['staging', 'preview'] });
583
+ const runner = new FakeRunner([], {});
584
+ const cloudflare = new FakeCloudflare();
585
+ // The real environment, because that is what a child process inherits and what the shell of a
586
+ // laptop deploy actually looks like when CI has exported a preview capability.
587
+ process.env.E2E_CONTROL_TOKEN = 'teardown-value';
588
+
589
+ try {
590
+ const result = await deployStage(
591
+ root,
592
+ { stage: 'production' },
593
+ {
594
+ runner,
595
+ cloudflare,
596
+ processEnv: environment({ API_TOKEN: 'api-value', E2E_CONTROL_TOKEN: 'teardown-value' }),
597
+ },
598
+ );
599
+
600
+ expect(result.action).toBe('deployed');
601
+ expect(JSON.parse(requiredTestValue(runner.secretsJson, 'secrets JSON'))).toEqual({ API_TOKEN: 'api-value' });
602
+ // Not in the payload, and not readable by the deploy either: the capability is absent from
603
+ // this stage at the process boundary, not merely left out of a file.
604
+ for (const call of runner.calls) {
605
+ expect(call.env.E2E_CONTROL_TOKEN).toBeUndefined();
606
+ }
607
+ expect(runner.calls.length).toBeGreaterThan(0);
608
+ } finally {
609
+ delete process.env.E2E_CONTROL_TOKEN;
610
+ }
611
+ });
612
+
613
+ it('sends a preview-scoped secret to the stages it is scoped to', async () => {
614
+ const scopes = { E2E_CONTROL_TOKEN: ['staging', 'preview'] };
615
+ const values = { API_TOKEN: 'api-value', E2E_CONTROL_TOKEN: 'teardown-value' };
616
+
617
+ for (const stage of ['staging', 'pr77'] as const) {
618
+ const root = await scopedRoot(['API_TOKEN', 'E2E_CONTROL_TOKEN'], scopes);
619
+ const runner = new FakeRunner([], {});
620
+
621
+ await deployStage(root, { stage }, { runner, cloudflare: new FakeCloudflare(), processEnv: environment(values) });
622
+
623
+ expect(JSON.parse(requiredTestValue(runner.secretsJson, `secrets JSON for ${stage}`))).toEqual(values);
624
+ }
625
+ });
626
+
627
+ it('treats an empty scope as no stage at all, not as every stage', async () => {
628
+ const root = await scopedRoot(['API_TOKEN', 'LOCAL_ONLY_KEY'], { LOCAL_ONLY_KEY: [] });
629
+ const runner = new FakeRunner([], {});
630
+
631
+ // `[]` and "absent from the map" are opposite declarations. A check written as
632
+ // `!scope?.length` would collapse them and ship a local-development value to every stage.
633
+ const result = await deployStage(
634
+ root,
635
+ { stage: 'production' },
636
+ {
637
+ runner,
638
+ cloudflare: new FakeCloudflare(),
639
+ processEnv: environment({ API_TOKEN: 'api-value', LOCAL_ONLY_KEY: 'laptop-value' }),
640
+ },
641
+ );
642
+
643
+ expect(result.action).toBe('deployed');
644
+ expect(JSON.parse(requiredTestValue(runner.secretsJson, 'secrets JSON'))).toEqual({ API_TOKEN: 'api-value' });
645
+ });
646
+
647
+ it('refuses when the Worker holds a secret this stage is scoped out of, since a deploy cannot remove it', async () => {
648
+ const root = await scopedRoot(['E2E_CONTROL_TOKEN'], { E2E_CONTROL_TOKEN: ['staging', 'preview'] });
649
+ const cloudflare = new FakeCloudflare();
650
+ cloudflare.scripts = [{ id: 'fixture-worker-production' }];
651
+ cloudflare.secrets['fixture-worker-production'] = ['E2E_CONTROL_TOKEN'];
652
+
653
+ await expect(
654
+ deployStage(
655
+ root,
656
+ { stage: 'production' },
657
+ { runner: new FakeRunner([], {}), cloudflare, processEnv: environment() },
658
+ ),
659
+ ).rejects.toThrow(/keeps out of production[\s\S]*E2E_CONTROL_TOKEN — scoped to staging, preview/);
660
+ expect(cloudflare.mutations).toEqual([]);
661
+ });
662
+
663
+ it('refuses a scope written against a name no secret has, rather than scoping nothing', async () => {
664
+ const root = await scopedRoot(['E2E_CONTROL_TOKEN'], { E2E_CONTROL_TOEKN: ['staging'] });
665
+
666
+ await expect(
667
+ deployStage(
668
+ root,
669
+ { stage: 'production' },
670
+ { runner: new FakeRunner([], {}), cloudflare: new FakeCloudflare(), processEnv: environment() },
671
+ ),
672
+ ).rejects.toThrow(/secretStages scopes E2E_CONTROL_TOEKN, which .dev.vars.example does not declare/);
673
+ });
674
+
675
+ it('names a malformed scope declaration by its path in the manifest', async () => {
676
+ const root = await scopedRoot(['E2E_CONTROL_TOKEN'], { E2E_CONTROL_TOKEN: ['pr7'] });
677
+
678
+ await expect(
679
+ deployStage(
680
+ root,
681
+ { stage: 'production' },
682
+ { runner: new FakeRunner([], {}), cloudflare: new FakeCloudflare(), processEnv: environment() },
683
+ ),
684
+ ).rejects.toThrow(
685
+ /package\.json declares an invalid smoo\.wrangler block: smoo\.wrangler\.secretStages\.E2E_CONTROL_TOKEN\[0\]/,
686
+ );
687
+ });
688
+
689
+ it('keeps every secret value out of the refusal and off every command line', async () => {
690
+ const root = await scopedRoot(['API_TOKEN', 'SESSION_SECRET']);
691
+ const runner = new FakeRunner([], {});
692
+ const cloudflare = new FakeCloudflare();
693
+
694
+ const refused = await deployStage(
695
+ root,
696
+ { stage: 'production' },
697
+ { runner, cloudflare, processEnv: environment({ API_TOKEN: 'api-value' }) },
698
+ ).catch((error: unknown) => (error instanceof Error ? error.message : String(error)));
699
+
700
+ expect(refused).toContain('SESSION_SECRET');
701
+ expect(refused).not.toContain('api-value');
702
+
703
+ // And the same on the path that does deploy: values reach wrangler through a 0600 file only.
704
+ cloudflare.scripts = [{ id: 'fixture-worker-production' }];
705
+ cloudflare.secrets['fixture-worker-production'] = ['SESSION_SECRET'];
706
+ await deployStage(
707
+ root,
708
+ { stage: 'production' },
709
+ { runner, cloudflare, processEnv: environment({ API_TOKEN: 'api-value' }) },
710
+ );
711
+
712
+ expect(runner.calls.flatMap((call) => call.args).join('\u0000')).not.toContain('api-value');
713
+ expect(JSON.parse(requiredTestValue(runner.secretsJson, 'secrets JSON'))).toEqual({ API_TOKEN: 'api-value' });
714
+ });
715
+ });
716
+
316
717
  describe('cleanup-pr exact stage matching', () => {
317
718
  it('rejects an invalid PR before touching the client', async () => {
318
719
  const cloudflare = new FakeCloudflare();
@@ -615,6 +1016,10 @@ describe('deployStage with a flat JSON config', () => {
615
1016
  ['deployments', 'status'],
616
1017
  ['d1', 'migrations'],
617
1018
  ['versions', 'deploy'],
1019
+ // The traffic shift is not the end of the deploy: the step re-reads live state and only
1020
+ // returns once the tag it activated is the one being served.
1021
+ ['deployments', 'status'],
1022
+ ['versions', 'list'],
618
1023
  ]);
619
1024
  });
620
1025
 
@@ -679,7 +1084,7 @@ describe('deployStage with a flat JSON config', () => {
679
1084
 
680
1085
  await expect(
681
1086
  deployStage(root, { stage: 'pr7', config: configPath }, dependencies(new FakeRunner(), cloudflare)),
682
- ).rejects.toThrow(/requires process environment values/);
1087
+ ).rejects.toThrow(/Refusing to deploy fixture-website-preview-pr7 to pr7[\s\S]*FIXTURE_SECRET/);
683
1088
  expect(cloudflare.mutations).toEqual([]);
684
1089
  });
685
1090
 
@@ -736,10 +1141,30 @@ async function fixtureRoot(toml = FIXTURE): Promise<string> {
736
1141
  return root;
737
1142
  }
738
1143
 
1144
+ /**
1145
+ * A wait whose clock only advances when the code under test sleeps: real bounds, no real seconds,
1146
+ * and a deterministic poll count a test can assert on.
1147
+ */
1148
+ function fakeWait(): { budgetMs: number; intervalMs: number; now: () => number; sleep: (ms: number) => Promise<void> } {
1149
+ let clock = 0;
1150
+ return {
1151
+ budgetMs: 10_000,
1152
+ intervalMs: 1_000,
1153
+ now: () => clock,
1154
+ sleep: async (ms: number) => {
1155
+ clock += ms;
1156
+ },
1157
+ };
1158
+ }
1159
+
739
1160
  function dependencies(runner: ProcessRunner, cloudflare: CloudflareClient) {
1161
+ const cacheDirectory = mkdtempSync(join(tmpdir(), 'smoo-live-version-'));
1162
+ roots.push(cacheDirectory);
740
1163
  return {
741
1164
  runner,
742
1165
  cloudflare,
1166
+ wait: fakeWait(),
1167
+ liveVersionCacheDirectory: cacheDirectory,
743
1168
  processEnv: {
744
1169
  CLOUDFLARE_ACCOUNT_ID: 'account-1',
745
1170
  CLOUDFLARE_API_TOKEN: 'token',