@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
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Reconciles the three places a repository's secrets are described, all of
3
+ * which smoo already owns:
4
+ *
5
+ * 1. **Workers declare names.** `.dev.vars.example` per wrangler project — the
6
+ * file `wrangler types --env-file` reads and `prepare-env` prompts from.
7
+ * 2. **Workflows declare where values come from.** `smoo.github.deploySecrets`
8
+ * and `e2eSecrets` map an env name to a repository secret, and the managed
9
+ * workflows render exactly those into the job environment.
10
+ * 3. **A developer shell declares how to fetch one locally.** `smoo.secrets`
11
+ * names a command whose stdout is the value.
12
+ *
13
+ * Nothing joined them, so a Worker could declare a secret no workflow supplies
14
+ * and no repository holds. That fails at deploy time, on a stage, with a
15
+ * fail-closed message about the value rather than about the missing
16
+ * declaration — the shape that costs an afternoon: a Worker declares
17
+ * `STRIPE_PUBLISHABLE_KEY`, `ci.yml` passes `secrets.STRIPE_PUBLISHABLE_KEY`,
18
+ * the repository holds no such secret, and the empty value surfaces as the
19
+ * payment library's own `publishable_key_mismatch` refusal.
20
+ */
21
+
22
+ import { spawnSync } from 'node:child_process';
23
+ import { existsSync, readFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import { repositoryOwnerFromUrl, repositorySecretMapping } from '../lib/secret-names.js';
26
+ import { readPackageJsonObject, repositoryInfo } from '../lib/workspace.js';
27
+ import { parseDevVarsExample } from '../wrangler/prepare-env.js';
28
+
29
+ /** Where an env name is described, which repository secret carries it, and whether that exists. */
30
+ export interface SecretRow {
31
+ name: string;
32
+ /** The repository secret this env name reads from, by convention or declaration. */
33
+ repositorySecret: string;
34
+ /** Wrangler projects whose `.dev.vars.example` declares it. */
35
+ declaredByWorkers: string[];
36
+ /** True when a managed workflow renders `secrets.<name>` into a job. */
37
+ suppliedByWorkflow: boolean;
38
+ /** True when `smoo.secrets` can fetch it for a developer shell. */
39
+ fetchableLocally: boolean;
40
+ /** True when the repository holds a secret of this name. */
41
+ onRepository: boolean;
42
+ }
43
+
44
+ export interface SecretSources {
45
+ /** Worker label -> env names it declares. */
46
+ workerSecrets: Record<string, readonly string[]>;
47
+ /** Env names a managed workflow passes into a job. */
48
+ workflowSecrets: readonly string[];
49
+ /** Env name -> repository secret, by convention with declared exceptions. */
50
+ secretNames: Readonly<Record<string, string>>;
51
+ /** Env names `smoo.secrets` can fetch locally. */
52
+ localCommands: readonly string[];
53
+ /** Repository secret names GitHub currently holds. */
54
+ repositorySecrets: readonly string[];
55
+ }
56
+
57
+ /**
58
+ * One row per name known to any source, sorted, so a reader sees the whole
59
+ * picture rather than one source's view of it.
60
+ */
61
+ export function reconcileSecrets(sources: SecretSources): SecretRow[] {
62
+ const declaredByAnyWorker: string[] = Object.values(sources.workerSecrets).flatMap((names) => [...names]);
63
+ const names = new Set<string>([...declaredByAnyWorker, ...sources.workflowSecrets, ...sources.localCommands]);
64
+ // A repository secret that already carries a known env name is that name's
65
+ // row, not a row of its own: listing ACME_GITHUB_CLIENT_SECRET beside
66
+ // GITHUB_CLIENT_SECRET would report one value as two secrets, one of them
67
+ // permanently "declared by nothing".
68
+ const carriesKnownEnvName = new Set(
69
+ names.size > 0 ? [...names].map((name) => sources.secretNames[name] ?? name) : [],
70
+ );
71
+ for (const secret of sources.repositorySecrets) {
72
+ if (!carriesKnownEnvName.has(secret)) names.add(secret);
73
+ }
74
+ return [...names]
75
+ .sort((left, right) => left.localeCompare(right))
76
+ .map((name) => ({
77
+ name,
78
+ repositorySecret: sources.secretNames[name] ?? name,
79
+ declaredByWorkers: Object.entries(sources.workerSecrets)
80
+ .filter(([, declared]) => declared.includes(name))
81
+ .map(([label]) => label)
82
+ .sort((left, right) => left.localeCompare(right)),
83
+ suppliedByWorkflow: sources.workflowSecrets.includes(name),
84
+ fetchableLocally: sources.localCommands.includes(name),
85
+ onRepository: sources.repositorySecrets.includes(sources.secretNames[name] ?? name),
86
+ }));
87
+ }
88
+
89
+ /**
90
+ * The rows a CI deploy cannot satisfy: a Worker declares the name, a workflow
91
+ * promises to pass it, and the repository has no value to pass. Reported
92
+ * separately from "declared but not wired into any workflow", because the
93
+ * remedies differ — set a secret, versus declare it in `smoo.github`.
94
+ */
95
+ export function unsatisfiedSecrets(rows: readonly SecretRow[]): SecretRow[] {
96
+ return rows.filter((row) => row.suppliedByWorkflow && !row.onRepository);
97
+ }
98
+
99
+ /** Worker-declared names no managed workflow passes: a CI deploy will run without them. */
100
+ export function unwiredSecrets(rows: readonly SecretRow[]): SecretRow[] {
101
+ return rows.filter((row) => row.declaredByWorkers.length > 0 && !row.suppliedByWorkflow);
102
+ }
103
+
104
+ /** Env names the managed workflows pass into a job, from the declarations that render them. */
105
+ export function workflowSecretNames(root: string): string[] {
106
+ const manifest = readPackageJsonObject(join(root, 'package.json'));
107
+ const smoo = manifest?.smoo;
108
+ const github = smoo?.github;
109
+ const names = new Set<string>([
110
+ ...Object.keys(github?.deploySecrets ?? {}),
111
+ ...Object.keys(github?.e2eSecrets ?? {}),
112
+ ]);
113
+ if (smoo?.remoteCache?.tokenSecret) names.add(smoo.remoteCache.tokenSecret);
114
+ for (const origin of github?.cargoCredentials?.gitOrigins ?? []) {
115
+ if (origin.tokenEnv) names.add(origin.tokenEnv);
116
+ }
117
+ for (const source of github?.sourceCheckouts ?? []) {
118
+ if (source.tokenEnv) names.add(source.tokenEnv);
119
+ }
120
+ if (smoo?.privateNpm?.readTokenEnv) names.add(smoo.privateNpm.readTokenEnv);
121
+ if (smoo?.privateNpm?.publishTokenEnv) names.add(smoo.privateNpm.publishTokenEnv);
122
+ return [...names].sort((left, right) => left.localeCompare(right));
123
+ }
124
+
125
+ /**
126
+ * Env name -> repository secret for every name in play, following the naming
127
+ * convention and honouring the declared exceptions a repository still needs.
128
+ */
129
+ export function secretNameMapping(root: string, envNames: readonly string[]): Record<string, string> {
130
+ const manifest = readPackageJsonObject(join(root, 'package.json'));
131
+ const declared = { ...(manifest?.smoo?.github?.deploySecrets ?? {}), ...(manifest?.smoo?.github?.e2eSecrets ?? {}) };
132
+ const repository = manifest ? repositoryInfo(manifest) : null;
133
+ const owner = repository ? (repositoryOwnerFromUrl(repository.url) ?? '') : '';
134
+ return repositorySecretMapping(envNames, owner, declared);
135
+ }
136
+
137
+ /** Every wrangler project's declared secret names, keyed by the project directory. */
138
+ export function workerSecretNames(root: string, workspaceDirs: readonly string[]): Record<string, string[]> {
139
+ const byWorker: Record<string, string[]> = {};
140
+ for (const dir of workspaceDirs) {
141
+ const examplePath = join(root, dir, '.dev.vars.example');
142
+ if (!existsSync(examplePath)) continue;
143
+ const names = parseDevVarsExample(readFileSync(examplePath, 'utf8'));
144
+ if (names.length > 0) byWorker[dir] = names;
145
+ }
146
+ return byWorker;
147
+ }
148
+
149
+ /** Names `smoo.secrets` declares a fetch command for. */
150
+ export function localSecretCommandNames(root: string): string[] {
151
+ const manifest = readPackageJsonObject(join(root, 'package.json'));
152
+ return Object.keys(manifest?.smoo?.secrets ?? {}).sort((left, right) => left.localeCompare(right));
153
+ }
154
+
155
+ /**
156
+ * Resolve one declared secret through its command. The value is returned, never
157
+ * logged: callers hand it to `gh secret set` over stdin.
158
+ */
159
+ export function fetchLocalSecret(
160
+ root: string,
161
+ name: string,
162
+ ): { ok: true; value: string } | { ok: false; reason: string } {
163
+ const manifest = readPackageJsonObject(join(root, 'package.json'));
164
+ const declared = manifest?.smoo?.secrets?.[name];
165
+ if (!declared) {
166
+ return { ok: false, reason: `smoo.secrets declares no command for ${name}` };
167
+ }
168
+ const [command, ...args] = declared.command;
169
+ const result = spawnSync(command, args, { encoding: 'utf8' });
170
+ if (result.status !== 0) {
171
+ // Name the command, never its output: a failing secret fetch prints
172
+ // credentials often enough that it is worth refusing to.
173
+ return {
174
+ ok: false,
175
+ reason: `${name}: \`${declared.command.join(' ')}\` exited ${result.status ?? 'without status'}`,
176
+ };
177
+ }
178
+ const value = result.stdout.replace(/\n$/, '');
179
+ if (value.length === 0) {
180
+ return { ok: false, reason: `${name}: \`${declared.command.join(' ')}\` produced no value` };
181
+ }
182
+ return { ok: true, value };
183
+ }
@@ -1,5 +1,5 @@
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';
@@ -22,6 +22,12 @@ import {
22
22
  type ProcessRunOptions,
23
23
  writeTemporaryConfigForTest,
24
24
  } from './deploy-stage.js';
25
+ import {
26
+ type FetchLike,
27
+ findVersionIdByTag,
28
+ LIVE_VERSION_CACHE_TTL_MS,
29
+ readCachedLiveVersion,
30
+ } from './live-version.js';
25
31
  import type { LiveKvNamespace } from './stage.js';
26
32
 
27
33
  const HASH = '16577780061662788004';
@@ -58,19 +64,49 @@ class FakeRunner implements ProcessRunner {
58
64
  secretsJson: string | undefined;
59
65
  /** Runs before the fake reports success, so a test can read a temporary file the command was handed. */
60
66
  onCall: ((args: string[], cwd: string) => Promise<void>) | undefined;
67
+ /**
68
+ * How many `deployments status` reads still answer with the OLD version after a traffic shift.
69
+ * This is the whole point of the fake: Cloudflare accepts the shift before it serves it.
70
+ */
71
+ propagationPolls = 0;
72
+
73
+ private pendingVersionId: string | null = null;
61
74
 
62
75
  constructor(
63
- private readonly versions: unknown = [],
64
- private readonly deployment: unknown = {},
76
+ private versions: unknown = [],
77
+ private deployment: unknown = {},
65
78
  ) {}
66
79
 
80
+ /** The versions payload as a growable list, so an upload can add the version it just created. */
81
+ private versionList(): unknown[] {
82
+ const list: unknown[] = Array.isArray(this.versions) ? this.versions : [this.versions];
83
+ this.versions = list;
84
+ return list;
85
+ }
86
+
87
+ private shiftTrafficTo(versionId: string): void {
88
+ this.pendingVersionId = versionId;
89
+ }
90
+
91
+ private readDeployment(): unknown {
92
+ if (this.pendingVersionId !== null) {
93
+ if (this.propagationPolls > 0) {
94
+ this.propagationPolls -= 1;
95
+ } else {
96
+ this.deployment = { versions: [{ version_id: this.pendingVersionId, percentage: 100 }] };
97
+ this.pendingVersionId = null;
98
+ }
99
+ }
100
+ return this.deployment;
101
+ }
102
+
67
103
  async run(command: string, args: string[], options: ProcessRunOptions): Promise<ProcessResult> {
68
104
  this.calls.push({ command, args, cwd: options.cwd, env: childEnvironment(options) });
69
105
  if (args[0] === 'versions' && args[1] === 'list') {
70
106
  return success(this.versions);
71
107
  }
72
108
  if (args[0] === 'deployments' && args[1] === 'status') {
73
- return success(this.deployment);
109
+ return success(this.readDeployment());
74
110
  }
75
111
  const configIndex = args.indexOf('--config');
76
112
  if (configIndex >= 0) {
@@ -84,6 +120,21 @@ class FakeRunner implements ProcessRunner {
84
120
  this.secretsMode = statSync(secretsPath).mode & 0o777;
85
121
  this.secretsJson = readFileSync(secretsPath, 'utf8');
86
122
  }
123
+ if (args[0] === 'versions' && args[1] === 'deploy') {
124
+ // Resolved with the same reader the deploy uses, so the fake cannot agree with a lookup
125
+ // production would miss.
126
+ const tag = args[args.indexOf('--version-tag') + 1];
127
+ const existing = tag ? findVersionIdByTag(this.versions, tag) : null;
128
+ if (existing) this.shiftTrafficTo(existing);
129
+ }
130
+ if (args[0] === 'deploy') {
131
+ const tagIndex = args.indexOf('--tag');
132
+ if (tagIndex >= 0) {
133
+ const versionId = `uploaded-${args[tagIndex + 1]}`;
134
+ this.versionList().push({ id: versionId, annotations: { 'workers/tag': args[tagIndex + 1] } });
135
+ this.shiftTrafficTo(versionId);
136
+ }
137
+ }
87
138
  await this.onCall?.(args, options.cwd);
88
139
  return success({});
89
140
  }
@@ -179,8 +230,8 @@ class FakeCloudflare implements CloudflareClient {
179
230
  }
180
231
  }
181
232
 
182
- describe('deploy-stage remote version fallback', () => {
183
- it('returns a remote cache hit for the active tagged 100% version', async () => {
233
+ describe('deploy-stage against live state', () => {
234
+ it('uploads nothing and shifts no traffic when the live version already is the task hash', async () => {
184
235
  const root = await fixtureRoot();
185
236
  const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
186
237
  versions: [{ version_id: 'version-1', percentage: 100 }],
@@ -189,14 +240,17 @@ describe('deploy-stage remote version fallback', () => {
189
240
  const result = await deployStage(root, { stage: 'pr123' }, dependencies(runner, new FakeCloudflare()));
190
241
 
191
242
  expect(result.action).toBe('remote-cache-hit');
243
+ // Two reads and nothing else. This is what makes a redundant deploy — the one a cross-project
244
+ // `dependsOn` edge adds — provably free instead of assumed free.
192
245
  expect(runner.calls.map((call) => call.args.slice(0, 2))).toEqual([
193
246
  ['versions', 'list'],
194
247
  ['deployments', 'status'],
195
248
  ]);
196
249
  });
197
250
 
198
- it('activates an existing tagged version that is not current', async () => {
251
+ it('redeploys after a rollback, when the live version is no longer the task hash', async () => {
199
252
  const root = await fixtureRoot();
253
+ // The build for this hash is still uploaded; someone rolled traffic back to the older version.
200
254
  const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
201
255
  versions: [{ version_id: 'version-2', percentage: 100 }],
202
256
  });
@@ -204,11 +258,108 @@ describe('deploy-stage remote version fallback', () => {
204
258
  const result = await deployStage(root, { stage: 'pr123' }, dependencies(runner, new FakeCloudflare()));
205
259
 
206
260
  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();
261
+ const shiftIndex = runner.calls.findIndex((call) => call.args[0] === 'versions' && call.args[1] === 'deploy');
262
+ expect(runner.calls[shiftIndex]?.args.slice(0, 4)).toEqual(['versions', 'deploy', '--version-tag', `nx-${HASH}`]);
263
+ // And the step does not return until it has read live state again after that shift.
264
+ expect(runner.calls.slice(shiftIndex + 1).some((call) => call.args[0] === 'deployments')).toBe(true);
209
265
  expect(existsSync(requiredTestValue(runner.configPathSeen, 'config path'))).toBe(false);
210
266
  });
211
267
 
268
+ it('waits out propagation before reporting the deploy done', async () => {
269
+ const root = await fixtureRoot();
270
+ const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
271
+ versions: [{ version_id: 'version-2', percentage: 100 }],
272
+ });
273
+ runner.propagationPolls = 3;
274
+
275
+ const result = await deployStage(root, { stage: 'pr123' }, dependencies(runner, new FakeCloudflare()));
276
+
277
+ expect(result.action).toBe('activated');
278
+ const statusReads = runner.calls.filter((call) => call.args[0] === 'deployments').length;
279
+ expect(statusReads).toBe(5);
280
+ });
281
+
282
+ it('fails with what it expected and what it saw when the version never becomes live', async () => {
283
+ const root = await fixtureRoot();
284
+ const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
285
+ versions: [{ version_id: 'version-2', percentage: 100 }],
286
+ });
287
+ runner.propagationPolls = Number.POSITIVE_INFINITY;
288
+
289
+ await expect(deployStage(root, { stage: 'pr123' }, dependencies(runner, new FakeCloudflare()))).rejects.toThrow(
290
+ `fixture-worker-pr123: deployed version nx-${HASH} was not serving traffic after 10s; live is an untagged version (version-2)`,
291
+ );
292
+ });
293
+
294
+ it('leaves the cache holding the tag it made live, so the next query needs no API call', async () => {
295
+ const root = await fixtureRoot();
296
+ const cacheDirectory = await mkdtemp(join(tmpdir(), 'smoo-live-version-'));
297
+ roots.push(cacheDirectory);
298
+ const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
299
+ versions: [{ version_id: 'version-2', percentage: 100 }],
300
+ });
301
+
302
+ await deployStage(
303
+ root,
304
+ { stage: 'pr123' },
305
+ { ...dependencies(runner, new FakeCloudflare()), liveVersionCacheDirectory: cacheDirectory },
306
+ );
307
+
308
+ const cached = await readCachedLiveVersion(
309
+ cacheDirectory,
310
+ { accountId: 'account-1', workerName: 'fixture-worker-pr123', stage: 'pr123' },
311
+ LIVE_VERSION_CACHE_TTL_MS,
312
+ );
313
+ expect(cached).toMatchObject({ versionTag: `nx-${HASH}`, versionId: 'version-1' });
314
+ });
315
+
316
+ it('is not done until a declared version endpoint answers with the new tag', async () => {
317
+ const root = await fixtureRoot();
318
+ const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
319
+ versions: [{ version_id: 'version-2', percentage: 100 }],
320
+ });
321
+ const bodies = [`nx-${'0'.repeat(20)}`, `nx-${'0'.repeat(20)}`, `nx-${HASH}`];
322
+ const requested: string[] = [];
323
+ const stubFetch: FetchLike = async (input) => {
324
+ requested.push(String(input));
325
+ return new Response(bodies.shift() ?? `nx-${HASH}`);
326
+ };
327
+
328
+ const result = await deployStage(
329
+ root,
330
+ { stage: 'pr123', versionEndpoint: 'https://fixture.example.test/__version' },
331
+ {
332
+ ...dependencies(runner, new FakeCloudflare()),
333
+ wait: { ...fakeWait(), fetch: stubFetch },
334
+ },
335
+ );
336
+
337
+ expect(result.action).toBe('activated');
338
+ expect(requested).toEqual([
339
+ 'https://fixture.example.test/__version',
340
+ 'https://fixture.example.test/__version',
341
+ 'https://fixture.example.test/__version',
342
+ ]);
343
+ });
344
+
345
+ it('fails naming the endpoint answer when the edge never reports the new tag', async () => {
346
+ const root = await fixtureRoot();
347
+ const runner = new FakeRunner([{ id: 'version-1', annotations: { 'workers/tag': `nx-${HASH}` } }], {
348
+ versions: [{ version_id: 'version-2', percentage: 100 }],
349
+ });
350
+ const stubFetch: FetchLike = async () => new Response('nx-previous');
351
+
352
+ await expect(
353
+ deployStage(
354
+ root,
355
+ { stage: 'pr123', versionEndpoint: 'https://fixture.example.test/__version' },
356
+ { ...dependencies(runner, new FakeCloudflare()), wait: { ...fakeWait(), fetch: stubFetch } },
357
+ ),
358
+ ).rejects.toThrow(
359
+ `fixture-worker-pr123: https://fixture.example.test/__version did not report version nx-${HASH} after 10s; it reported nx-previous`,
360
+ );
361
+ });
362
+
212
363
  it('uploads a missing tag with a temporary config and secure secrets file, then removes both', async () => {
213
364
  const root = await fixtureRoot();
214
365
  await writeFile(join(root, '.dev.vars.example'), 'FIXTURE_SECRET=""\nFIXTURE_TOKEN=""\n');
@@ -231,9 +382,12 @@ describe('deploy-stage remote version fallback', () => {
231
382
  );
232
383
 
233
384
  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}`);
385
+ const upload = requiredTestValue(
386
+ runner.calls.find((call) => call.args[0] === 'deploy'),
387
+ 'upload call',
388
+ );
389
+ expect(upload.args).toContain('--tag');
390
+ expect(upload.args).toContain(`nx-${HASH}`);
237
391
  expect(runner.secretsMode).toBe(0o600);
238
392
  expect(JSON.parse(requiredTestValue(runner.secretsJson, 'secrets JSON'))).toEqual({
239
393
  FIXTURE_SECRET: 'shared-secret',
@@ -615,6 +769,10 @@ describe('deployStage with a flat JSON config', () => {
615
769
  ['deployments', 'status'],
616
770
  ['d1', 'migrations'],
617
771
  ['versions', 'deploy'],
772
+ // The traffic shift is not the end of the deploy: the step re-reads live state and only
773
+ // returns once the tag it activated is the one being served.
774
+ ['deployments', 'status'],
775
+ ['versions', 'list'],
618
776
  ]);
619
777
  });
620
778
 
@@ -736,10 +894,30 @@ async function fixtureRoot(toml = FIXTURE): Promise<string> {
736
894
  return root;
737
895
  }
738
896
 
897
+ /**
898
+ * A wait whose clock only advances when the code under test sleeps: real bounds, no real seconds,
899
+ * and a deterministic poll count a test can assert on.
900
+ */
901
+ function fakeWait(): { budgetMs: number; intervalMs: number; now: () => number; sleep: (ms: number) => Promise<void> } {
902
+ let clock = 0;
903
+ return {
904
+ budgetMs: 10_000,
905
+ intervalMs: 1_000,
906
+ now: () => clock,
907
+ sleep: async (ms: number) => {
908
+ clock += ms;
909
+ },
910
+ };
911
+ }
912
+
739
913
  function dependencies(runner: ProcessRunner, cloudflare: CloudflareClient) {
914
+ const cacheDirectory = mkdtempSync(join(tmpdir(), 'smoo-live-version-'));
915
+ roots.push(cacheDirectory);
740
916
  return {
741
917
  runner,
742
918
  cloudflare,
919
+ wait: fakeWait(),
920
+ liveVersionCacheDirectory: cacheDirectory,
743
921
  processEnv: {
744
922
  CLOUDFLARE_ACCOUNT_ID: 'account-1',
745
923
  CLOUDFLARE_API_TOKEN: 'token',
@@ -2,11 +2,20 @@ import { randomUUID } from 'node:crypto';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { readFile, rm, writeFile } from 'node:fs/promises';
4
4
  import { dirname, join } from 'node:path';
5
- import typia from 'typia';
6
5
  import { parseJsonFileText } from '../lib/json.js';
7
6
  import { mergeEnv, printCommandOutput } from '../lib/run.js';
8
7
  import { type CloudflareClient, CloudflareRestClient, type D1DatabaseRecord } from './cloudflare.js';
9
8
  import { type FlatWranglerConfig, parseFlatWranglerConfig, planFlatStageResources } from './flat-config.js';
9
+ import {
10
+ awaitLiveVersion,
11
+ awaitVersionEndpoint,
12
+ currentDeploymentVersionId,
13
+ findVersionIdByTag,
14
+ type LiveVersionProbe,
15
+ liveVersionCacheDirectory,
16
+ type VersionEndpointWaitOptions,
17
+ writeCachedLiveVersion,
18
+ } from './live-version.js';
10
19
  import { parseDevVarsExample } from './prepare-env.js';
11
20
  import {
12
21
  type ConfiguredStageResourcePlan,
@@ -69,6 +78,10 @@ export interface WranglerCommandDependencies {
69
78
  runner?: ProcessRunner;
70
79
  cloudflare?: CloudflareClient;
71
80
  processEnv?: NodeJS.ProcessEnv;
81
+ /** Bounds and clock for the post-deploy wait; tests drive it without sleeping. */
82
+ wait?: VersionEndpointWaitOptions;
83
+ /** Where a successful deploy records the tag it made live, for `smoo wrangler deployed-version`. */
84
+ liveVersionCacheDirectory?: string;
72
85
  }
73
86
 
74
87
  export interface DeployStageResult {
@@ -78,13 +91,17 @@ export interface DeployStageResult {
78
91
  versionTag?: string;
79
92
  }
80
93
 
81
- const isUnknownRecord = typia.createIs<Record<string, unknown>>();
82
-
83
94
  export interface DeployStageOptions {
84
95
  /** `staging`, `production`, or `prN`. */
85
96
  stage: string;
86
97
  /** A build-generated flat wrangler.json to deploy instead of `./wrangler.toml` (see `prepareFlatConfig`). */
87
98
  config?: string;
99
+ /**
100
+ * A URL, served by this worker, whose trimmed body is the running version tag. When given, the
101
+ * deploy is not done until that URL answers with the tag it just made live — the control plane
102
+ * accepting a traffic shift is not the edge serving it.
103
+ */
104
+ versionEndpoint?: string;
88
105
  }
89
106
 
90
107
  export async function deployStage(
@@ -124,16 +141,24 @@ export async function deployStage(
124
141
  // `reconcileStageResources` nor `versions list` looks at.
125
142
  const run: ProcessRunOptions = prepared.envFlag ? { cwd } : { cwd, unsetEnv: ['CLOUDFLARE_ENV'] };
126
143
  const workerName = prepared.plan.workerName;
144
+ const probe: LiveVersionProbe = {
145
+ deployments: () => wranglerJson(runner, ['deployments', 'status', '--name', workerName, '--json'], run),
146
+ versions: () => wranglerJson(runner, ['versions', 'list', '--name', workerName, '--json'], run),
147
+ };
127
148
 
128
149
  // The tagged-version lookup runs before the migrations: a cache hit means this exact build is
129
150
  // already live, so its migrations ran with it and re-applying them would touch the remote
130
151
  // database for nothing. The activation and upload paths below still migrate first.
152
+ //
153
+ // This reads Cloudflare every time and never the local cache. A cache hit here would mean
154
+ // "we believe this hash is live" — exactly the belief a rollback falsifies — and believing it
155
+ // would skip the deploy that repairs the rollback. Reading live state is what makes a
156
+ // redundant deploy a proven no-op instead of an assumed one, which is in turn what makes a
157
+ // cross-project `dependsOn: ["<other>:deploy"]` edge cheap enough to be the ordering mechanism.
131
158
  let taggedVersionId: string | null = null;
132
159
  if (versionTag && workerExists) {
133
- const versions = await wranglerJson(runner, ['versions', 'list', '--name', workerName, '--json'], run);
134
- const deployments = await wranglerJson(runner, ['deployments', 'status', '--name', workerName, '--json'], run);
135
- taggedVersionId = findVersionIdByTag(versions, versionTag);
136
- if (taggedVersionId && isFullCurrentDeployment(deployments, taggedVersionId)) {
160
+ taggedVersionId = findVersionIdByTag(await probe.versions(), versionTag);
161
+ if (taggedVersionId && currentDeploymentVersionId(await probe.deployments()) === taggedVersionId) {
137
162
  return { stage, workerName, action: 'remote-cache-hit', versionTag };
138
163
  }
139
164
  }
@@ -163,6 +188,7 @@ export async function deployStage(
163
188
  ],
164
189
  run,
165
190
  );
191
+ await confirmVersionIsLive(probe, versionTag, { stage, workerName, accountId }, options, dependencies, cwd);
166
192
  return { stage, workerName, action: 'activated', versionTag };
167
193
  }
168
194
 
@@ -174,6 +200,9 @@ export async function deployStage(
174
200
  deployArgs.push('--secrets-file', temporarySecretsPath);
175
201
  }
176
202
  await wrangler(runner, deployArgs, run);
203
+ if (versionTag) {
204
+ await confirmVersionIsLive(probe, versionTag, { stage, workerName, accountId }, options, dependencies, cwd);
205
+ }
177
206
  return { stage, workerName, action: 'deployed', ...(versionTag ? { versionTag } : {}) };
178
207
  } finally {
179
208
  if (temporaryConfigPath) {
@@ -578,49 +607,47 @@ export function nxTaskVersionTag(environment: NodeJS.ProcessEnv): string | undef
578
607
  throw new Error('NX_TASK_HASH must be canonical decimal digits or at least 32 hexadecimal characters.');
579
608
  }
580
609
 
581
- export function findVersionIdByTag(value: unknown, tag: string): string | null {
582
- if (Array.isArray(value)) {
583
- for (const entry of value) {
584
- const found = findVersionIdByTag(entry, tag);
585
- if (found) return found;
586
- }
587
- return null;
588
- }
589
- if (!isUnknownRecord(value)) return null;
590
- const annotations = isUnknownRecord(value.annotations) ? value.annotations : undefined;
591
- const metadata = isUnknownRecord(value.metadata) ? value.metadata : undefined;
592
- const annotationTag = annotations?.['workers/tag'];
593
- const candidateTag =
594
- typeof annotationTag === 'string' ? annotationTag : typeof value.tag === 'string' ? value.tag : metadata?.tag;
595
- if (candidateTag === tag) {
596
- if (typeof value.id === 'string') return value.id;
597
- if (typeof value.version_id === 'string') return value.version_id;
598
- }
599
- for (const nested of Object.values(value)) {
600
- const found = findVersionIdByTag(nested, tag);
601
- if (found) return found;
602
- }
603
- return null;
610
+ /** What a freshly activated version has to be true of, and where the fact gets recorded. */
611
+ interface DeployedVersionIdentity {
612
+ stage: DeploymentStage;
613
+ workerName: string;
614
+ accountId: string;
604
615
  }
605
616
 
606
- export function isFullCurrentDeployment(value: unknown, versionId: string): boolean {
607
- if (Array.isArray(value)) return value.some((entry) => isFullCurrentDeployment(entry, versionId));
608
- if (!isUnknownRecord(value)) return false;
609
- if (Array.isArray(value.versions)) {
610
- return (
611
- value.versions.length === 1 &&
612
- value.versions.some(
613
- (version) =>
614
- isUnknownRecord(version) &&
615
- (version.version_id === versionId || version.id === versionId) &&
616
- Number(version.percentage) === 100,
617
- )
618
- );
617
+ /**
618
+ * Turns "Cloudflare accepted the traffic shift" into "the new version answers".
619
+ *
620
+ * Without this the deploy step goes green while the edge still serves the previous version — the
621
+ * measured gap was about 20 s — so anything ordered after this deploy could call into code that
622
+ * has not shipped yet. The wait is what a `dependsOn: ["<other>:deploy"]` edge actually buys; an
623
+ * edge onto a step that returns early orders nothing.
624
+ */
625
+ async function confirmVersionIsLive(
626
+ probe: LiveVersionProbe,
627
+ versionTag: string,
628
+ identity: DeployedVersionIdentity,
629
+ options: DeployStageOptions,
630
+ dependencies: WranglerCommandDependencies,
631
+ cwd: string,
632
+ ): Promise<void> {
633
+ const wait = dependencies.wait ?? {};
634
+ const live = await awaitLiveVersion(probe, versionTag, wait);
635
+ if (!live.ok) throw new Error(`${identity.workerName}: ${live.error.message}`);
636
+ if (options.versionEndpoint) {
637
+ const answered = await awaitVersionEndpoint(options.versionEndpoint, versionTag, wait);
638
+ if (!answered.ok) throw new Error(`${identity.workerName}: ${answered.error.message}`);
619
639
  }
620
- return Object.values(value).some((entry) => isFullCurrentDeployment(entry, versionId));
640
+ const processEnv = dependencies.processEnv ?? process.env;
641
+ const directory = dependencies.liveVersionCacheDirectory ?? liveVersionCacheDirectory(cwd, processEnv);
642
+ await writeCachedLiveVersion(
643
+ directory,
644
+ { accountId: identity.accountId, workerName: identity.workerName, stage: identity.stage },
645
+ { versionTag, versionId: live.value.versionId, fetchedAt: Date.now() },
646
+ );
621
647
  }
622
648
 
623
- async function wranglerJson(runner: ProcessRunner, args: string[], run: ProcessRunOptions): Promise<unknown> {
649
+ /** One wrangler invocation whose stdout must be JSON; shared by the deploy and the read-only query. */
650
+ export async function wranglerJson(runner: ProcessRunner, args: string[], run: ProcessRunOptions): Promise<unknown> {
624
651
  const result = await wrangler(runner, args, run);
625
652
  try {
626
653
  return JSON.parse(result.stdout);