@smoothbricks/cli 0.10.8 → 0.10.10

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 (45) hide show
  1. package/dist/cli.js +7 -5
  2. package/dist/github-ci/index.d.ts +9 -7
  3. package/dist/github-ci/index.d.ts.map +1 -1
  4. package/dist/github-ci/index.js +36 -35
  5. package/dist/monorepo/ci-workflow.d.ts +3 -0
  6. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  7. package/dist/monorepo/ci-workflow.js +59 -15
  8. package/dist/monorepo/managed-files.d.ts +1 -0
  9. package/dist/monorepo/managed-files.d.ts.map +1 -1
  10. package/dist/monorepo/managed-files.js +15 -5
  11. package/dist/monorepo/publish-workflow.d.ts +2 -2
  12. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  13. package/dist/monorepo/publish-workflow.js +7 -7
  14. package/dist/release/index.d.ts +1 -0
  15. package/dist/release/index.d.ts.map +1 -1
  16. package/dist/release/index.js +14 -6
  17. package/dist/wrangler/cloudflare.d.ts +4 -2
  18. package/dist/wrangler/cloudflare.d.ts.map +1 -1
  19. package/dist/wrangler/cloudflare.js +6 -3
  20. package/dist/wrangler/{deploy-environment.d.ts → deploy-stage.d.ts} +6 -6
  21. package/dist/wrangler/deploy-stage.d.ts.map +1 -0
  22. package/dist/wrangler/{deploy-environment.js → deploy-stage.js} +26 -26
  23. package/dist/wrangler/stage.d.ts +58 -0
  24. package/dist/wrangler/stage.d.ts.map +1 -0
  25. package/dist/wrangler/{environment.js → stage.js} +44 -44
  26. package/package.json +8 -8
  27. package/src/cli.ts +11 -8
  28. package/src/github-ci/index.test.ts +105 -34
  29. package/src/github-ci/index.ts +53 -46
  30. package/src/monorepo/__tests__/ci-workflow.test.ts +87 -49
  31. package/src/monorepo/__tests__/publish-workflow.test.ts +14 -12
  32. package/src/monorepo/ci-workflow.ts +67 -14
  33. package/src/monorepo/managed-files.test.ts +26 -5
  34. package/src/monorepo/managed-files.ts +18 -5
  35. package/src/monorepo/publish-workflow.ts +15 -12
  36. package/src/release/index.ts +12 -4
  37. package/src/wrangler/cloudflare.test.ts +76 -0
  38. package/src/wrangler/cloudflare.ts +6 -3
  39. package/src/wrangler/{deploy-environment.test.ts → deploy-stage.test.ts} +11 -11
  40. package/src/wrangler/{deploy-environment.ts → deploy-stage.ts} +41 -41
  41. package/src/wrangler/{environment.test.ts → stage.test.ts} +15 -15
  42. package/src/wrangler/{environment.ts → stage.ts} +49 -52
  43. package/dist/wrangler/deploy-environment.d.ts.map +0 -1
  44. package/dist/wrangler/environment.d.ts +0 -58
  45. package/dist/wrangler/environment.d.ts.map +0 -1
@@ -3,40 +3,40 @@ import { getStaticTOMLValue, parseTOML } from 'toml-eslint-parser';
3
3
  import typia from 'typia';
4
4
  import { cloneEnvBlock } from './prepare-env.js';
5
5
  const MAX_PULL_REQUEST_NUMBER = 999999999;
6
- const ENVIRONMENT_PATTERN = /^(?:staging|production|pr[1-9][0-9]{0,8})$/;
7
- export function pullRequestEnvironment(prNumber) {
6
+ const STAGE_PATTERN = /^(?:staging|production|pr[1-9][0-9]{0,8})$/;
7
+ export function pullRequestStage(prNumber) {
8
8
  if (!Number.isInteger(prNumber) || prNumber < 1 || prNumber > MAX_PULL_REQUEST_NUMBER) {
9
9
  throw new Error(`Pull request number must be an integer from 1 through ${MAX_PULL_REQUEST_NUMBER}.`);
10
10
  }
11
11
  return `pr${prNumber}`;
12
12
  }
13
- export function parseEnvironmentToken(value) {
14
- if (!ENVIRONMENT_PATTERN.test(value)) {
15
- throw new Error('Environment must be exactly staging, production, or pr followed by an integer from 1 through 999999999.');
13
+ export function parseDeploymentStage(value) {
14
+ if (!STAGE_PATTERN.test(value)) {
15
+ throw new Error('Deployment stage must be exactly staging, production, or pr followed by an integer from 1 through 999999999.');
16
16
  }
17
17
  if (value === 'staging' || value === 'production')
18
18
  return value;
19
- return pullRequestEnvironment(Number(value.slice(2)));
19
+ return pullRequestStage(Number(value.slice(2)));
20
20
  }
21
- export function isPullRequestEnvironment(environment) {
22
- return environment.startsWith('pr');
21
+ export function isPullRequestStage(stage) {
22
+ return stage.startsWith('pr');
23
23
  }
24
- export function environmentDomain(environment, zone) {
25
- const token = parseEnvironmentToken(environment);
24
+ export function stageDomain(stage, zone) {
25
+ const token = parseDeploymentStage(stage);
26
26
  if (!zone || zone.startsWith('.') || zone.endsWith('.')) {
27
27
  throw new Error('Zone must be a non-empty DNS name without leading or trailing dots.');
28
28
  }
29
29
  return token === 'production' ? zone : `${token}.${zone}`;
30
30
  }
31
- export function environmentResourceName(base, environment) {
32
- const token = parseEnvironmentToken(environment);
31
+ export function stageResourceName(base, stage) {
32
+ const token = parseDeploymentStage(stage);
33
33
  if (!base) {
34
34
  throw new Error('Resource base name must not be empty.');
35
35
  }
36
36
  return token === 'production' ? base : `${base}-${token}`;
37
37
  }
38
- export function hasExactEnvironmentSegment(value, environment) {
39
- const escaped = environment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
38
+ export function hasExactStageSegment(value, stage) {
39
+ const escaped = stage.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
40
40
  return new RegExp(`(?:^|[-.])${escaped}(?=$|[-.])`).test(value);
41
41
  }
42
42
  const isWranglerRoot = (() => {
@@ -86,14 +86,14 @@ const isUnknownRows = (() => {
86
86
  });
87
87
  return input => Array.isArray(input) && input.every(elem => "object" === typeof elem && null !== elem && false === Array.isArray(elem) && _io0(elem));
88
88
  })();
89
- export function planConfiguredEnvironmentResources(toml, environment) {
90
- const block = parseRoot(toml).env?.[environment];
89
+ export function planConfiguredStageResources(toml, stage) {
90
+ const block = parseRoot(toml).env?.[stage];
91
91
  if (!isWranglerEnvironment(block)) {
92
- throw new Error(`Wrangler configuration must declare [env.${environment}].`);
92
+ throw new Error(`Wrangler configuration must declare [env.${stage}].`);
93
93
  }
94
- const workerName = requiredString(block, 'name', `[env.${environment}]`);
94
+ const workerName = requiredString(block, 'name', `[env.${stage}]`);
95
95
  return {
96
- environment,
96
+ stage,
97
97
  workerName,
98
98
  kvNamespaces: readKvBindings(block.kv_namespaces),
99
99
  r2Buckets: readRows(block.r2_buckets).map((row) => {
@@ -127,8 +127,8 @@ function stagingWorkerName(staging) {
127
127
  }
128
128
  return { workerName: staging.name, workerBaseName: staging.name.slice(0, -'-staging'.length) };
129
129
  }
130
- export function planPullRequestResources(toml, environment, liveNamespaces) {
131
- parseEnvironmentToken(environment);
130
+ export function planPullRequestResources(toml, stage, liveNamespaces) {
131
+ parseDeploymentStage(stage);
132
132
  const staging = stagingEnvironment(toml);
133
133
  const { workerBaseName } = stagingWorkerName(staging);
134
134
  const namespaceById = new Map(liveNamespaces.map((namespace) => [namespace.id, namespace]));
@@ -137,7 +137,7 @@ export function planPullRequestResources(toml, environment, liveNamespaces) {
137
137
  if (!stagingNamespace) {
138
138
  throw new Error(`Staging KV binding ${binding} references namespace ${id}, which is absent from the account listing.`);
139
139
  }
140
- const title = replaceExactToken(stagingNamespace.title, 'staging', environment);
140
+ const title = replaceExactToken(stagingNamespace.title, 'staging', stage);
141
141
  if (title === stagingNamespace.title) {
142
142
  throw new Error(`Staging KV namespace title ${stagingNamespace.title} has no exact staging segment.`);
143
143
  }
@@ -146,20 +146,20 @@ export function planPullRequestResources(toml, environment, liveNamespaces) {
146
146
  const r2Buckets = readRows(staging.r2_buckets).map((row) => {
147
147
  const binding = requiredString(row, 'binding', 'R2 binding');
148
148
  const stagingBucket = requiredString(row, 'bucket_name', `R2 binding ${binding}`);
149
- const bucketName = replaceExactToken(stagingBucket, 'staging', environment);
149
+ const bucketName = replaceExactToken(stagingBucket, 'staging', stage);
150
150
  if (bucketName === stagingBucket) {
151
151
  throw new Error(`Staging R2 bucket ${stagingBucket} has no exact staging segment.`);
152
152
  }
153
153
  return { binding, bucketName };
154
154
  });
155
155
  const routes = readRows(staging.routes).map((row) => ({
156
- pattern: replaceHostnameLabel(requiredString(row, 'pattern', 'route'), environment),
156
+ pattern: replaceHostnameLabel(requiredString(row, 'pattern', 'route'), stage),
157
157
  ...(typeof row.zone_name === 'string' ? { zoneName: row.zone_name } : {}),
158
158
  customDomain: row.custom_domain === true,
159
159
  }));
160
160
  return {
161
- environment,
162
- workerName: environmentResourceName(workerBaseName, environment),
161
+ stage,
162
+ workerName: stageResourceName(workerBaseName, stage),
163
163
  workerBaseName,
164
164
  kvNamespaces,
165
165
  r2Buckets,
@@ -167,14 +167,14 @@ export function planPullRequestResources(toml, environment, liveNamespaces) {
167
167
  };
168
168
  }
169
169
  export function derivePullRequestWranglerConfig(toml, options) {
170
- const environment = options.environment;
171
- parseEnvironmentToken(environment);
170
+ const stage = options.stage;
171
+ parseDeploymentStage(stage);
172
172
  if (!options.accountId) {
173
173
  throw new Error('Cloudflare account id is required to derive rate-limit namespaces.');
174
174
  }
175
175
  const staging = stagingEnvironment(toml);
176
176
  const { workerBaseName } = stagingWorkerName(staging);
177
- const cloned = cloneEnvBlock(toml, 'staging', environment);
177
+ const cloned = cloneEnvBlock(toml, 'staging', stage);
178
178
  const program = parseTOML(cloned);
179
179
  const rootValue = getStaticTOMLValue(program);
180
180
  if (!isWranglerRoot(rootValue)) {
@@ -183,7 +183,7 @@ export function derivePullRequestWranglerConfig(toml, options) {
183
183
  const root = rootValue;
184
184
  const edits = [];
185
185
  for (const table of program.body[0].body) {
186
- if (table.type !== 'TOMLTable' || table.resolvedKey[0] !== 'env' || table.resolvedKey[1] !== environment) {
186
+ if (table.type !== 'TOMLTable' || table.resolvedKey[0] !== 'env' || table.resolvedKey[1] !== stage) {
187
187
  continue;
188
188
  }
189
189
  const tableValue = valueAtPath(root, table.resolvedKey);
@@ -193,7 +193,7 @@ export function derivePullRequestWranglerConfig(toml, options) {
193
193
  for (const keyValue of table.body) {
194
194
  const key = cloned.slice(keyValue.key.range[0], keyValue.key.range[1]).trim();
195
195
  const current = tableValue[key];
196
- const next = deriveFieldValue(table.resolvedKey.slice(2), tableValue, key, current, environment, workerBaseName, options.accountId, options.kvNamespaceIds);
196
+ const next = deriveFieldValue(table.resolvedKey.slice(2), tableValue, key, current, stage, workerBaseName, options.accountId, options.kvNamespaceIds);
197
197
  if (next !== current) {
198
198
  edits.push({ start: keyValue.value.range[0], end: keyValue.value.range[1], value: tomlLiteral(next) });
199
199
  }
@@ -206,25 +206,25 @@ export function derivePullRequestWranglerConfig(toml, options) {
206
206
  parseTOML(derived);
207
207
  return derived;
208
208
  }
209
- function deriveFieldValue(path, table, key, current, environment, workerBaseName, accountId, kvNamespaceIds) {
209
+ function deriveFieldValue(path, table, key, current, stage, workerBaseName, accountId, kvNamespaceIds) {
210
210
  const section = path[0];
211
211
  if (path.length === 0 && key === 'name') {
212
- return environmentResourceName(workerBaseName, environment);
212
+ return stageResourceName(workerBaseName, stage);
213
213
  }
214
214
  if (section === 'routes' && key === 'pattern' && typeof current === 'string') {
215
- return replaceHostnameLabel(current, environment);
215
+ return replaceHostnameLabel(current, stage);
216
216
  }
217
217
  if (section === 'vars' && typeof current === 'string') {
218
218
  if (key === 'ENVIRONMENT') {
219
- return environment;
219
+ return stage;
220
220
  }
221
221
  if (key === 'AUTH_KEYS_INSTANCE_NAME') {
222
- return replaceExactToken(current, 'staging', environment);
222
+ return replaceExactToken(current, 'staging', stage);
223
223
  }
224
- return replaceHostnameLabel(current, environment);
224
+ return replaceHostnameLabel(current, stage);
225
225
  }
226
226
  if (section === 'send_email' && key === 'allowed_sender_addresses' && Array.isArray(current)) {
227
- return current.map((value) => (typeof value === 'string' ? replaceHostnameLabel(value, environment) : value));
227
+ return current.map((value) => (typeof value === 'string' ? replaceHostnameLabel(value, stage) : value));
228
228
  }
229
229
  if (section === 'kv_namespaces' && key === 'id' && typeof current === 'string') {
230
230
  const derived = kvNamespaceIds.get(current);
@@ -234,22 +234,22 @@ function deriveFieldValue(path, table, key, current, environment, workerBaseName
234
234
  return derived;
235
235
  }
236
236
  if (section === 'r2_buckets' && key === 'bucket_name' && typeof current === 'string') {
237
- return replaceExactToken(current, 'staging', environment);
237
+ return replaceExactToken(current, 'staging', stage);
238
238
  }
239
239
  if (section === 'ratelimits' && key === 'namespace_id') {
240
240
  const bindingName = requiredString(table, 'name', 'Rate-limit binding');
241
- return rateLimitNamespaceId(accountId, workerBaseName, environment, bindingName);
241
+ return rateLimitNamespaceId(accountId, workerBaseName, stage, bindingName);
242
242
  }
243
243
  return current;
244
244
  }
245
- export function rateLimitNamespaceId(accountId, workerBaseName, environment, bindingName) {
246
- const token = parseEnvironmentToken(environment);
245
+ export function rateLimitNamespaceId(accountId, workerBaseName, stage, bindingName) {
246
+ const token = parseDeploymentStage(stage);
247
247
  const digest = createHash('sha256').update(`${accountId}:${workerBaseName}:${token}:${bindingName}`).digest();
248
248
  const value = digest.readUInt32BE(0) & 2147483647;
249
249
  return String(value === 0 ? 1 : value);
250
250
  }
251
- function replaceHostnameLabel(value, environment) {
252
- return value.replace(/(^|[.@/])staging(?=\.)/g, `$1${environment}`);
251
+ function replaceHostnameLabel(value, stage) {
252
+ return value.replace(/(^|[.@/])staging(?=\.)/g, `$1${stage}`);
253
253
  }
254
254
  function replaceExactToken(value, from, to) {
255
255
  const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smoothbricks/cli",
3
- "version": "0.10.8",
3
+ "version": "0.10.10",
4
4
  "type": "module",
5
5
  "description": "SmoothBricks monorepo automation CLI",
6
6
  "bin": {
@@ -48,12 +48,12 @@
48
48
  "import": "./dist/wrangler/prepare-env.js",
49
49
  "default": "./dist/wrangler/prepare-env.js"
50
50
  },
51
- "./wrangler/environment": {
52
- "types": "./dist/wrangler/environment.d.ts",
53
- "bun": "./src/wrangler/environment.ts",
54
- "development": "./src/wrangler/environment.ts",
55
- "import": "./dist/wrangler/environment.js",
56
- "default": "./dist/wrangler/environment.js"
51
+ "./wrangler/stage": {
52
+ "types": "./dist/wrangler/stage.d.ts",
53
+ "bun": "./src/wrangler/stage.ts",
54
+ "development": "./src/wrangler/stage.ts",
55
+ "import": "./dist/wrangler/stage.js",
56
+ "default": "./dist/wrangler/stage.js"
57
57
  }
58
58
  },
59
59
  "sideEffects": [
@@ -71,7 +71,7 @@
71
71
  "dependencies": {
72
72
  "@arethetypeswrong/core": "^0.18.2",
73
73
  "@prettier/sync": "^0.6.1",
74
- "@smoothbricks/nx-plugin": "0.3.5",
74
+ "@smoothbricks/nx-plugin": "0.3.6",
75
75
  "@smoothbricks/validation": "0.1.5",
76
76
  "commander": "^14.0.3",
77
77
  "prettier": "^3.6.1",
package/src/cli.ts CHANGED
@@ -4,7 +4,7 @@ import { cliPackageVersion } from './lib/cli-package.js';
4
4
  import { findRepoRoot } from './lib/run.js';
5
5
  import { ensureChromium } from './playwright/index.js';
6
6
  import { resolvePrConflicts } from './pr/index.js';
7
- import { cleanupPullRequest, deployEnvironment } from './wrangler/deploy-environment.js';
7
+ import { cleanupPullRequest, deployStage } from './wrangler/deploy-stage.js';
8
8
  import { scaffold } from './wrangler/scaffold.js';
9
9
 
10
10
  export async function runCli(argv = process.argv.slice(2)): Promise<void> {
@@ -175,7 +175,8 @@ function buildProgram(): Command {
175
175
  .requiredOption('--targets <targets>', 'comma-separated Nx platform target names or globs')
176
176
  .requiredOption('--output <path>', 'output directory for current and repair artifacts')
177
177
  .option('--ref <ref>', 'fixed release graph ref to inspect')
178
- .action(async (options: { bump: string; output: string; ref?: string; targets: string }) => {
178
+ .option('--github-output <path>', 'append selected current platform projects to a GitHub Actions output file')
179
+ .action(async (options: { bump: string; githubOutput?: string; output: string; ref?: string; targets: string }) => {
179
180
  // The source self-hosting shim has no Typia transform; release commands import transformed output validators.
180
181
  const { releaseCollectPlatformOutputs } = await import('./release/index.js');
181
182
  await releaseCollectPlatformOutputs(await findRepoRoot(), options);
@@ -334,6 +335,7 @@ function buildProgram(): Command {
334
335
  .option('--step <step>')
335
336
  .option('--mode <mode>', 'auto, affected, or run-many', 'auto')
336
337
  .option('--configuration <configuration>')
338
+ .option('--stage <stage>')
337
339
  .action(
338
340
  async (options: {
339
341
  target: string;
@@ -341,6 +343,7 @@ function buildProgram(): Command {
341
343
  step?: string;
342
344
  mode?: 'auto' | 'affected' | 'run-many';
343
345
  configuration?: string;
346
+ stage?: string;
344
347
  }) => {
345
348
  const { githubCiNxSmart } = await import('./github-ci/index.js');
346
349
  await githubCiNxSmart(await findRepoRoot(), options);
@@ -375,14 +378,14 @@ function buildProgram(): Command {
375
378
  });
376
379
  githubCi
377
380
  .command('nx-deploy')
378
- .option('--environment <environment>', 'explicit staging, production, or prN override')
381
+ .option('--stage <stage>', 'explicit staging, production, or prN override')
379
382
  .option('--mode <mode>', 'auto, affected, or run-many', 'run-many')
380
383
  .option('--name <name>')
381
384
  .option('--step <step>')
382
385
  .option('--verify', 'run build, lint, and test before deploy')
383
386
  .action(
384
387
  async (options: {
385
- environment?: string;
388
+ stage?: string;
386
389
  mode?: 'auto' | 'affected' | 'run-many';
387
390
  name?: string;
388
391
  step?: string;
@@ -423,10 +426,10 @@ function buildProgram(): Command {
423
426
  scaffold(await findRepoRoot(), project, { force: options.force });
424
427
  });
425
428
  wrangler
426
- .command('deploy-environment')
427
- .requiredOption('--environment <environment>', 'staging, production, or prN')
428
- .action(async (options: { environment: string }) => {
429
- await deployEnvironment(process.cwd(), options.environment);
429
+ .command('deploy-stage')
430
+ .requiredOption('--stage <stage>', 'staging, production, or prN')
431
+ .action(async (options: { stage: string }) => {
432
+ await deployStage(process.cwd(), options.stage);
430
433
  });
431
434
  wrangler
432
435
  .command('cleanup-pr')
@@ -14,8 +14,8 @@ import {
14
14
  nxSmartArgs,
15
15
  publishGithubDeployment,
16
16
  readGitHeadSha,
17
- resolveDeploymentEnvironment,
18
- selectEnvironmentDeployProjects,
17
+ resolveDeploymentStage,
18
+ selectStageDeployProjects,
19
19
  } from './index.js';
20
20
  import {
21
21
  applyCollectedOutputs,
@@ -134,12 +134,13 @@ describe('GitHub CI Nx target expansion', () => {
134
134
  ).toEqual(['native:package-linux', 'native:compile-linux', 'native:build']);
135
135
  });
136
136
 
137
- it('adds the generic target skip tag only to nx-smart', () => {
138
- expect(nxSmartArgs('test', 'affected')).toEqual([
139
- 'affected',
137
+ it('adds optional stage before the generic target skip tag only to nx-smart', () => {
138
+ expect(nxSmartArgs('e2e-deployment', 'run-many', undefined, 'pr123')).toEqual([
139
+ 'run-many',
140
140
  '-t',
141
- 'test',
142
- '--exclude=tag:ci:skip:test',
141
+ 'e2e-deployment',
142
+ '--stage=pr123',
143
+ '--exclude=tag:ci:skip:e2e-deployment',
143
144
  '--parallel=100%',
144
145
  ]);
145
146
  expect(nxRunManyArgs({ target: 'test', projects: projects.slice(0, 1) })).not.toContain(
@@ -166,11 +167,12 @@ describe('collected Nx outputs', () => {
166
167
 
167
168
  it('collects an empty artifact when selected projects have no matching target', async () => {
168
169
  await withNxRunManyFixture(async ({ root, artifact }) => {
169
- await githubCiNxRunMany(root, {
170
+ const expanded = await githubCiNxRunMany(root, {
170
171
  targets: '*-linux',
171
172
  projects: 'app',
172
173
  collectOutputs: artifact,
173
174
  });
175
+ expect(expanded.runs).toEqual([]);
174
176
 
175
177
  const sourceSha = await readGitHeadSha(root);
176
178
  expect(JSON.parse(await readFile(join(artifact, 'manifest.json'), 'utf8'))).toEqual({
@@ -186,10 +188,11 @@ describe('collected Nx outputs', () => {
186
188
  await writeFile(join(root, 'packages/app/test-target.ts'), "await Bun.write('test-ran.txt', 'tested');\n");
187
189
  await writeFile(join(root, 'packages/app/build-target.ts'), "throw new Error('build must not run here');\n");
188
190
 
189
- await githubCiNxRunMany(root, {
191
+ const expanded = await githubCiNxRunMany(root, {
190
192
  targets: 'test',
191
193
  projectsWithTargets: '*-macos,*-ios',
192
194
  });
195
+ expect(expanded.runs.map((run) => run.projects.map((project) => project.project))).toEqual([['app']]);
193
196
 
194
197
  await expect(readFile(join(root, 'test-ran.txt'), 'utf8')).resolves.toBe('tested');
195
198
  await expect(readFile(join(root, 'build-ran.txt'), 'utf8')).rejects.toThrow();
@@ -538,10 +541,10 @@ async function withNxRunManyFixture(
538
541
  }
539
542
  }
540
543
 
541
- describe('event-aware environment deployment', () => {
542
- it('resolves same-repository PR, private push, release, and explicit production environments', () => {
544
+ describe('event-aware stage deployment', () => {
545
+ it('resolves same-repository PR, private push, release, and explicit production stages', () => {
543
546
  expect(
544
- resolveDeploymentEnvironment(
547
+ resolveDeploymentStage(
545
548
  undefined,
546
549
  { GITHUB_EVENT_NAME: 'pull_request' },
547
550
  {
@@ -552,12 +555,12 @@ describe('event-aware environment deployment', () => {
552
555
  ),
553
556
  ).toBe('pr123');
554
557
  expect(
555
- resolveDeploymentEnvironment(undefined, { GITHUB_EVENT_NAME: 'push', GITHUB_REF_NAME: 'private' }, undefined),
558
+ resolveDeploymentStage(undefined, { GITHUB_EVENT_NAME: 'push', GITHUB_REF_NAME: 'private' }, undefined),
556
559
  ).toBe('staging');
557
- expect(resolveDeploymentEnvironment(undefined, { GITHUB_EVENT_NAME: 'release' }, undefined)).toBe('production');
558
- expect(resolveDeploymentEnvironment('production', {}, undefined)).toBe('production');
560
+ expect(resolveDeploymentStage(undefined, { GITHUB_EVENT_NAME: 'release' }, undefined)).toBe('production');
561
+ expect(resolveDeploymentStage('production', {}, undefined)).toBe('production');
559
562
  expect(() =>
560
- resolveDeploymentEnvironment(
563
+ resolveDeploymentStage(
561
564
  undefined,
562
565
  { GITHUB_EVENT_NAME: 'pull_request' },
563
566
  {
@@ -569,15 +572,15 @@ describe('event-aware environment deployment', () => {
569
572
  ).toThrow(/same-repository/);
570
573
  });
571
574
 
572
- it('selects only deploy targets owned by the environment convention', async () => {
575
+ it('selects only deploy targets owned by the stage convention', async () => {
573
576
  const definitions: Record<string, unknown> = {
574
577
  'conloca-app': {
575
578
  targets: {
576
- deploy: { options: { command: 'smoo wrangler deploy-environment --environment {args.environment}' } },
579
+ deploy: { options: { command: 'smoo wrangler deploy-stage --stage {args.stage}' } },
577
580
  },
578
581
  },
579
582
  'conloca-app-backend': {
580
- targets: { deploy: { command: 'smoo wrangler deploy-environment --environment {args.environment}' } },
583
+ targets: { deploy: { command: 'smoo wrangler deploy-stage --stage {args.stage}' } },
581
584
  },
582
585
  'conloca-oauth-redirect': {
583
586
  targets: { deploy: { options: { command: 'wrangler deploy --config wrangler.toml' } } },
@@ -588,7 +591,7 @@ describe('event-aware environment deployment', () => {
588
591
  };
589
592
 
590
593
  await expect(
591
- selectEnvironmentDeployProjects(Object.keys(definitions), async (project) => definitions[project]),
594
+ selectStageDeployProjects(Object.keys(definitions), async (project) => definitions[project]),
592
595
  ).resolves.toEqual(['conloca-app', 'conloca-app-backend']);
593
596
  });
594
597
 
@@ -652,19 +655,21 @@ describe('event-aware environment deployment', () => {
652
655
  ]);
653
656
  });
654
657
 
655
- it('deploys app/backend, follows with e2e-deployed, and publishes PR metadata', async () => {
658
+ it('deploys app/backend, publishes PR metadata, and emits the resolved stage', async () => {
656
659
  const nxCalls: string[][] = [];
657
660
  const listCalls: Array<[string, string]> = [];
658
661
  const summaries: string[] = [];
659
662
  const deployments: Array<[string, string]> = [];
663
+ const outputs: string[] = [];
660
664
 
661
665
  await githubCiNxDeploy(
662
666
  '/repo',
663
- { mode: 'run-many', name: 'Deploy Environment' },
667
+ { mode: 'run-many', name: 'Deploy Stage' },
664
668
  {
665
669
  processEnv: {
666
670
  GITHUB_EVENT_NAME: 'pull_request',
667
671
  GITHUB_STEP_SUMMARY: '/summary',
672
+ GITHUB_OUTPUT: '/output',
668
673
  },
669
674
  setStatus: async () => {},
670
675
  eventPayload: {
@@ -674,7 +679,7 @@ describe('event-aware environment deployment', () => {
674
679
  },
675
680
  listProjects: async (_root, target, mode) => {
676
681
  listCalls.push([target, mode]);
677
- return target === 'deploy' ? ['conloca-app', 'conloca-app-backend'] : ['conloca-e2e'];
682
+ return ['conloca-app', 'conloca-app-backend'];
678
683
  },
679
684
  runNx: async (args) => {
680
685
  nxCalls.push(args);
@@ -683,24 +688,90 @@ describe('event-aware environment deployment', () => {
683
688
  appendSummary: async (_path, content) => {
684
689
  summaries.push(content);
685
690
  },
686
- publishDeployment: async (environment, url) => {
687
- deployments.push([environment, url]);
691
+ appendOutput: async (_path, content) => {
692
+ outputs.push(content);
693
+ },
694
+ publishDeployment: async (stage, url) => {
695
+ deployments.push([stage, url]);
688
696
  },
689
697
  },
690
698
  );
691
699
 
692
- expect(listCalls).toEqual([
693
- ['deploy', 'run-many'],
694
- ['e2e-deployed', 'run-many'],
695
- ]);
696
- expect(nxCalls).toHaveLength(2);
700
+ expect(listCalls).toEqual([['deploy', 'run-many']]);
701
+ expect(nxCalls).toHaveLength(1);
697
702
  expect(nxCalls[0]).toContain('--projects=conloca-app,conloca-app-backend');
698
703
  expect(nxCalls[0]).toContain('--exclude=tag:permanent-deploy-target');
699
- expect(nxCalls[0]).toContain('--environment=pr123');
700
- expect(nxCalls[1]).toContain('-t');
701
- expect(nxCalls[1]).toContain('e2e-deployed');
702
- expect(nxCalls[1]).toContain('--environment=pr123');
704
+ expect(nxCalls[0]).toContain('--stage=pr123');
705
+ expect(nxCalls[0]).not.toContain('e2e-deployment');
703
706
  expect(summaries).toEqual(['## pr123 deployment\n\n[View deployment](https://app.pr123.conloca.com)\n']);
704
707
  expect(deployments).toEqual([['pr123', 'https://app.pr123.conloca.com']]);
708
+ expect(outputs).toEqual(['stage=pr123\n']);
709
+ });
710
+
711
+ it('emits no stage when no deploy project exists', async () => {
712
+ const outputs: string[] = [];
713
+ const statuses: string[] = [];
714
+
715
+ await githubCiNxDeploy(
716
+ '/repo',
717
+ { stage: 'staging' },
718
+ {
719
+ processEnv: { GITHUB_OUTPUT: '/output' },
720
+ listProjects: async () => [],
721
+ appendOutput: async (_path, content) => {
722
+ outputs.push(content);
723
+ },
724
+ setStatus: async (status) => {
725
+ statuses.push(status);
726
+ },
727
+ },
728
+ );
729
+
730
+ expect(outputs).toEqual([]);
731
+ expect(statuses).toEqual(['pending', 'success']);
732
+ });
733
+
734
+ it('emits no stage after deployment or output failure', async () => {
735
+ const deploymentOutputs: string[] = [];
736
+ const deploymentStatuses: string[] = [];
737
+ await expect(
738
+ githubCiNxDeploy(
739
+ '/repo',
740
+ { stage: 'staging' },
741
+ {
742
+ processEnv: { GITHUB_OUTPUT: '/output' },
743
+ listProjects: async () => ['app'],
744
+ runNx: async () => 1,
745
+ appendOutput: async (_path, content) => {
746
+ deploymentOutputs.push(content);
747
+ },
748
+ setStatus: async (status) => {
749
+ deploymentStatuses.push(status);
750
+ },
751
+ },
752
+ ),
753
+ ).rejects.toThrow(/failed with exit code 1/);
754
+ expect(deploymentOutputs).toEqual([]);
755
+ expect(deploymentStatuses).toEqual(['pending', 'failure']);
756
+
757
+ const outputStatuses: string[] = [];
758
+ await expect(
759
+ githubCiNxDeploy(
760
+ '/repo',
761
+ { stage: 'staging' },
762
+ {
763
+ processEnv: { GITHUB_OUTPUT: '/output' },
764
+ listProjects: async () => ['app'],
765
+ runNx: async () => 0,
766
+ appendOutput: async () => {
767
+ throw new Error('output unavailable');
768
+ },
769
+ setStatus: async (status) => {
770
+ outputStatuses.push(status);
771
+ },
772
+ },
773
+ ),
774
+ ).rejects.toThrow('output unavailable');
775
+ expect(outputStatuses).toEqual(['pending', 'failure']);
705
776
  });
706
777
  });