@smoothbricks/cli 0.3.3 โ†’ 0.4.0

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 (33) hide show
  1. package/dist/cli.js +14 -0
  2. package/dist/github-ci/index.d.ts +12 -0
  3. package/dist/github-ci/index.d.ts.map +1 -1
  4. package/dist/github-ci/index.js +72 -4
  5. package/dist/monorepo/ci-workflow.d.ts +26 -0
  6. package/dist/monorepo/ci-workflow.d.ts.map +1 -0
  7. package/dist/monorepo/ci-workflow.js +201 -0
  8. package/dist/monorepo/managed-files.d.ts.map +1 -1
  9. package/dist/monorepo/managed-files.js +97 -5
  10. package/dist/monorepo/package-policy.d.ts.map +1 -1
  11. package/dist/monorepo/package-policy.js +4 -2
  12. package/dist/monorepo/publish-workflow.d.ts +7 -1
  13. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  14. package/dist/monorepo/publish-workflow.js +59 -15
  15. package/dist/monorepo/runtime.d.ts +3 -0
  16. package/dist/monorepo/runtime.d.ts.map +1 -1
  17. package/dist/monorepo/runtime.js +66 -2
  18. package/managed/raw/git-format-staged.yml +4 -0
  19. package/package.json +3 -3
  20. package/src/cli.ts +35 -5
  21. package/src/github-ci/index.ts +94 -8
  22. package/src/monorepo/__tests__/ci-workflow.test.ts +34 -0
  23. package/src/monorepo/__tests__/publish-workflow.test.ts +65 -2
  24. package/src/monorepo/ci-workflow.ts +229 -0
  25. package/src/monorepo/managed-files.ts +115 -5
  26. package/src/monorepo/package-policy.test.ts +24 -9
  27. package/src/monorepo/package-policy.ts +4 -2
  28. package/src/monorepo/publish-workflow.ts +73 -16
  29. package/src/monorepo/runtime.test.ts +30 -0
  30. package/src/monorepo/runtime.ts +94 -2
  31. package/src/release/__tests__/fixture-repo.test.ts +14 -13
  32. package/src/release/__tests__/helpers/fixture-repo.ts +85 -12
  33. package/managed/templates/github/workflows/ci.yml +0 -102
@@ -14,6 +14,7 @@ export var PublishWorkflowStepKind;
14
14
  PublishWorkflowStepKind["UploadTraceDbs"] = "upload-trace-dbs";
15
15
  PublishWorkflowStepKind["ValidateMonorepoConfig"] = "validate-monorepo-config";
16
16
  PublishWorkflowStepKind["PublishRelease"] = "publish-release";
17
+ PublishWorkflowStepKind["DeployProduction"] = "deploy-production";
17
18
  PublishWorkflowStepKind["SaveNixDevenv"] = "save-nix-devenv";
18
19
  })(PublishWorkflowStepKind || (PublishWorkflowStepKind = {}));
19
20
  export function definePublishWorkflow(options = {}) {
@@ -26,6 +27,19 @@ export function definePublishWorkflow(options = {}) {
26
27
  if (isSmoothBricksCodebasePackageName(options.repoName)) {
27
28
  setupSteps.push({ kind: PublishWorkflowStepKind.BuildSelfHostedCli, name: '๐Ÿ—๏ธ Build self-hosted smoo' });
28
29
  }
30
+ const releaseSteps = [
31
+ {
32
+ kind: PublishWorkflowStepKind.PublishRelease,
33
+ name: `๐Ÿ“ฆ Publish release (${versionMode})`,
34
+ },
35
+ ];
36
+ if (options.deploy === true) {
37
+ releaseSteps.push({
38
+ kind: PublishWorkflowStepKind.DeployProduction,
39
+ name: '๐Ÿš€ Deploy production',
40
+ condition: 'deploy-production',
41
+ });
42
+ }
29
43
  return {
30
44
  steps: numberWorkflowSteps([
31
45
  ...setupSteps,
@@ -63,10 +77,7 @@ export function definePublishWorkflow(options = {}) {
63
77
  name: `โœ… Validate monorepo config (${versionMode})`,
64
78
  condition: 'version-mode-not-none',
65
79
  },
66
- {
67
- kind: PublishWorkflowStepKind.PublishRelease,
68
- name: `๐Ÿ“ฆ Publish release (${versionMode})`,
69
- },
80
+ ...releaseSteps,
70
81
  {
71
82
  kind: PublishWorkflowStepKind.SaveNixDevenv,
72
83
  name: '๐Ÿงน Cleanup and cache Nix/devenv',
@@ -84,7 +95,7 @@ export async function runPublishWorkflow(workflow, context) {
84
95
  let failed = false;
85
96
  let failure;
86
97
  for (const step of workflow.steps) {
87
- if (!shouldRunStep(step, version, failed)) {
98
+ if (!shouldRunStep(step, version, failed, context.inputs)) {
88
99
  continue;
89
100
  }
90
101
  try {
@@ -132,6 +143,9 @@ export async function runPublishWorkflow(workflow, context) {
132
143
  dryRun: context.inputs.dryRun,
133
144
  });
134
145
  break;
146
+ case PublishWorkflowStepKind.DeployProduction:
147
+ await context.callbacks.deployProduction();
148
+ break;
135
149
  case PublishWorkflowStepKind.SaveNixDevenv:
136
150
  await context.callbacks.saveNixDevenv(setupOutputs);
137
151
  break;
@@ -147,10 +161,13 @@ export async function runPublishWorkflow(workflow, context) {
147
161
  }
148
162
  return { version, failed };
149
163
  }
150
- function shouldRunStep(step, version, failed) {
164
+ function shouldRunStep(step, version, failed, inputs) {
151
165
  if (step.condition === 'version-mode-not-none') {
152
166
  return version.mode !== 'none';
153
167
  }
168
+ if (step.condition === 'deploy-production') {
169
+ return version.mode !== 'none' && inputs.deployEnvironment === 'production' && !inputs.dryRun;
170
+ }
154
171
  if (step.condition === 'failure') {
155
172
  return failed;
156
173
  }
@@ -158,9 +175,17 @@ function shouldRunStep(step, version, failed) {
158
175
  }
159
176
  export function renderPublishWorkflowYaml(options = {}) {
160
177
  const steps = definePublishWorkflow(options).steps;
161
- return `${renderPublishWorkflowHeader()}${renderPublishWorkflowSteps(steps)}`;
178
+ return `${renderPublishWorkflowHeader(options)}${renderPublishWorkflowSteps(steps, options)}`;
162
179
  }
163
- function renderPublishWorkflowHeader() {
180
+ function renderPublishWorkflowHeader(options) {
181
+ const deployInput = options.deploy === true
182
+ ? `
183
+ deploy_environment:
184
+ type: choice
185
+ description: Deploy live systems after a successful publish.
186
+ options: [none, production]
187
+ default: none`
188
+ : '';
164
189
  return `name: Publish
165
190
 
166
191
  on:
@@ -176,7 +201,7 @@ on:
176
201
  dry_run:
177
202
  type: boolean
178
203
  description: Run release commands without writing versions, tags, publishes, or GitHub Releases.
179
- default: false
204
+ default: false${deployInput}
180
205
 
181
206
  permissions:
182
207
  contents: write
@@ -199,12 +224,12 @@ jobs:
199
224
  steps:
200
225
  `;
201
226
  }
202
- function renderPublishWorkflowSteps(steps) {
227
+ function renderPublishWorkflowSteps(steps, options) {
203
228
  const lines = [];
204
229
  for (const step of steps) {
205
230
  lines.push(...sectionLinesBefore(step));
206
231
  lines.push(...commentLinesForStep(step));
207
- lines.push(...yamlLinesForStep(step));
232
+ lines.push(...yamlLinesForStep(step, options));
208
233
  lines.push('');
209
234
  }
210
235
  return `${lines.join('\n').trimEnd()}\n`;
@@ -244,7 +269,7 @@ function commentLinesForStep(step) {
244
269
  }
245
270
  return [` # Step ${step.number}`];
246
271
  }
247
- function yamlLinesForStep(step) {
272
+ function yamlLinesForStep(step, options) {
248
273
  switch (step.kind) {
249
274
  case PublishWorkflowStepKind.Checkout:
250
275
  return [
@@ -312,6 +337,8 @@ function yamlLinesForStep(step) {
312
337
  ' # Missing package names are bootstrapped locally before trust setup.',
313
338
  ` run: smoo release publish --bump "${githubExpression('inputs.bump')}" --dry-run "${githubExpression('inputs.dry_run')}"`,
314
339
  ];
340
+ case PublishWorkflowStepKind.DeployProduction:
341
+ return deployProductionStep(step, options);
315
342
  case PublishWorkflowStepKind.SaveNixDevenv:
316
343
  return [
317
344
  ` - name: ${step.name}`,
@@ -323,13 +350,30 @@ function yamlLinesForStep(step) {
323
350
  ];
324
351
  }
325
352
  }
326
- function conditionalRunStep(step, run) {
353
+ function deployProductionStep(step, options) {
327
354
  return [
328
355
  ` - name: ${step.name}`,
329
- ` if: ${githubExpression("steps.version.outputs.mode != 'none'")}`,
330
- ` run: ${run}`,
356
+ ' if:',
357
+ " ${{ steps.version.outputs.mode != 'none' && inputs.deploy_environment == 'production' && inputs.dry_run !=",
358
+ " 'true' }}",
359
+ ...deployEnvLines(options),
360
+ ' run: smoo github-ci nx-deploy --configuration production --mode run-many --verify --name "Deploy Production"',
331
361
  ];
332
362
  }
363
+ function deployEnvLines(options) {
364
+ if (options.deployProvider !== 'cloudflare') {
365
+ return [];
366
+ }
367
+ return [
368
+ ' env:',
369
+ ' CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}',
370
+ ' CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}',
371
+ ];
372
+ }
373
+ function conditionalRunStep(step, run) {
374
+ const condition = "steps.version.outputs.mode != 'none'";
375
+ return [` - name: ${step.name}`, ` if: ${githubExpression(condition)}`, ` run: ${run}`];
376
+ }
333
377
  function githubExpression(expression) {
334
378
  return ['$', '{{ ', expression, ' }}'].join('');
335
379
  }
@@ -1,2 +1,5 @@
1
1
  export declare function syncRootRuntimeVersions(root: string): Promise<void>;
2
+ type RuntimeTypesPinMode = 'major' | 'exact';
3
+ export declare function runtimeTypesRangeForPublishedVersions(packageName: string, runtimeVersion: string, pinMode: RuntimeTypesPinMode, versions: readonly string[]): string;
4
+ export {};
2
5
  //# sourceMappingURL=runtime.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/monorepo/runtime.ts"],"names":[],"mappings":"AAKA,wBAAsB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CA2BzE"}
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/monorepo/runtime.ts"],"names":[],"mappings":"AAKA,wBAAsB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAqCzE;AAED,KAAK,mBAAmB,GAAG,OAAO,GAAG,OAAO,CAAC;AAgB7C,wBAAgB,qCAAqC,CACnD,WAAW,EAAE,MAAM,EACnB,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE,mBAAmB,EAC5B,QAAQ,EAAE,SAAS,MAAM,EAAE,GAC1B,MAAM,CAsBR"}
@@ -21,13 +21,77 @@ export async function syncRootRuntimeVersions(root) {
21
21
  changed = setStringProperty(engines, 'node', `>=${nodeMajor}.0.0`) || changed;
22
22
  changed = setStringProperty(packageJson, 'packageManager', `bun@${bunVersion}`) || changed;
23
23
  const devDependencies = getOrCreateRecord(packageJson, 'devDependencies');
24
- changed = setStringProperty(devDependencies, '@types/node', `~${nodeMajor}.0.0`) || changed;
25
- changed = setStringProperty(devDependencies, '@types/bun', bunVersion) || changed;
24
+ changed =
25
+ setStringProperty(devDependencies, '@types/node', await runtimeTypesRange(root, '@types/node', nodeVersion, 'major')) || changed;
26
+ changed =
27
+ setStringProperty(devDependencies, '@types/bun', await runtimeTypesRange(root, '@types/bun', bunVersion, 'exact')) || changed;
26
28
  if (changed) {
27
29
  writeJsonObject(packageJsonPath, packageJson);
28
30
  console.log('updated package.json runtime versions');
29
31
  }
30
32
  }
33
+ async function runtimeTypesRange(root, packageName, runtimeVersion, pinMode) {
34
+ const versionsText = await $ `bun pm view ${packageName} versions --json`.cwd(root).text();
35
+ const versions = JSON.parse(versionsText);
36
+ if (!Array.isArray(versions) || !versions.every((version) => typeof version === 'string')) {
37
+ throw new Error(`Unable to read published ${packageName} versions`);
38
+ }
39
+ return runtimeTypesRangeForPublishedVersions(packageName, runtimeVersion, pinMode, versions);
40
+ }
41
+ export function runtimeTypesRangeForPublishedVersions(packageName, runtimeVersion, pinMode, versions) {
42
+ const parsedRuntimeVersion = parseVersion(runtimeVersion);
43
+ if (!parsedRuntimeVersion) {
44
+ throw new Error(`Unable to parse runtime version ${runtimeVersion}`);
45
+ }
46
+ if (pinMode === 'major') {
47
+ const runtimeMajor = parsedRuntimeVersion[0].toString();
48
+ if (latestVersion(versions.filter((version) => versionMajor(version) === runtimeMajor))) {
49
+ return `~${runtimeMajor}.0.0`;
50
+ }
51
+ }
52
+ else if (versions.includes(runtimeVersion)) {
53
+ return runtimeVersion;
54
+ }
55
+ const latest = latestVersion(versions);
56
+ if (!latest) {
57
+ throw new Error(`Unable to find any published ${packageName} versions`);
58
+ }
59
+ console.warn(`${packageName} has no published types for runtime ${runtimeVersion}; using ${latest}`);
60
+ return pinMode === 'major' ? `~${latest}` : latest;
61
+ }
62
+ function latestVersion(versions) {
63
+ let latest = null;
64
+ for (const version of versions) {
65
+ if (!parseVersion(version)) {
66
+ continue;
67
+ }
68
+ if (!latest || compareVersions(version, latest) > 0) {
69
+ latest = version;
70
+ }
71
+ }
72
+ return latest;
73
+ }
74
+ function compareVersions(left, right) {
75
+ const leftParts = parseVersion(left);
76
+ const rightParts = parseVersion(right);
77
+ if (!leftParts || !rightParts) {
78
+ return 0;
79
+ }
80
+ for (let index = 0; index < leftParts.length; index++) {
81
+ const diff = leftParts[index] - rightParts[index];
82
+ if (diff !== 0) {
83
+ return diff;
84
+ }
85
+ }
86
+ return 0;
87
+ }
88
+ function versionMajor(version) {
89
+ return parseVersion(version)?.[0].toString() ?? null;
90
+ }
91
+ function parseVersion(version) {
92
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
93
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
94
+ }
31
95
  function runtimeCommand(root, name) {
32
96
  const candidates = [
33
97
  join(root, 'tooling', 'direnv', '.devenv', 'profile', 'bin', name),
@@ -42,6 +42,10 @@ formatters:
42
42
  - '!*.nix'
43
43
  # Bun lockfiles are generated and should not be rewritten by Prettier
44
44
  - '!bun.lock'
45
+ # Binary artifacts: Prettier reads them as UTF-8 and can corrupt them
46
+ # by replacing invalid byte sequences.
47
+ - '!*.wasm'
48
+ - '!*.wasm.sha256'
45
49
 
46
50
  # Alejandra for Nix files
47
51
  alejandra:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smoothbricks/cli",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "SmoothBricks monorepo automation CLI",
6
6
  "bin": {
@@ -45,7 +45,7 @@
45
45
  ],
46
46
  "dependencies": {
47
47
  "@arethetypeswrong/cli": "^0.18.2",
48
- "@smoothbricks/nx-plugin": "0.2.2",
48
+ "@smoothbricks/nx-plugin": "0.2.4",
49
49
  "@smoothbricks/validation": "0.1.1",
50
50
  "commander": "^14.0.3",
51
51
  "publint": "^0.3.18",
@@ -72,7 +72,7 @@
72
72
  "test": {
73
73
  "executor": "@smoothbricks/nx-plugin:bounded-exec",
74
74
  "options": {
75
- "command": "bun test",
75
+ "command": "bun test --timeout=30000",
76
76
  "cwd": "{projectRoot}",
77
77
  "timeoutMs": 120000,
78
78
  "killAfterMs": 1e4
package/src/cli.ts CHANGED
@@ -298,18 +298,48 @@ function buildProgram(): Command {
298
298
  .requiredOption('--target <target>')
299
299
  .option('--name <name>')
300
300
  .option('--step <step>')
301
- .action(async (options: { target: string; name?: string; step?: string }) => {
302
- const { githubCiNxSmart } = await import('./github-ci/index.js');
303
- await githubCiNxSmart(await findRepoRoot(), options);
304
- });
301
+ .option('--mode <mode>', 'auto, affected, or run-many', 'auto')
302
+ .option('--configuration <configuration>')
303
+ .action(
304
+ async (options: {
305
+ target: string;
306
+ name?: string;
307
+ step?: string;
308
+ mode?: 'auto' | 'affected' | 'run-many';
309
+ configuration?: string;
310
+ }) => {
311
+ const { githubCiNxSmart } = await import('./github-ci/index.js');
312
+ await githubCiNxSmart(await findRepoRoot(), options);
313
+ },
314
+ );
305
315
  githubCi
306
316
  .command('nx-run-many')
307
317
  .requiredOption('--targets <targets>')
308
318
  .option('--projects <projects>')
309
- .action(async (options: { targets: string; projects?: string }) => {
319
+ .option('--configuration <configuration>')
320
+ .action(async (options: { targets: string; projects?: string; configuration?: string }) => {
310
321
  const { githubCiNxRunMany } = await import('./github-ci/index.js');
311
322
  await githubCiNxRunMany(await findRepoRoot(), options);
312
323
  });
324
+ githubCi
325
+ .command('nx-deploy')
326
+ .requiredOption('--configuration <configuration>')
327
+ .option('--mode <mode>', 'auto, affected, or run-many', 'run-many')
328
+ .option('--name <name>')
329
+ .option('--step <step>')
330
+ .option('--verify', 'run build, lint, and test before deploy')
331
+ .action(
332
+ async (options: {
333
+ configuration: string;
334
+ mode?: 'auto' | 'affected' | 'run-many';
335
+ name?: string;
336
+ step?: string;
337
+ verify?: boolean;
338
+ }) => {
339
+ const { githubCiNxDeploy } = await import('./github-ci/index.js');
340
+ await githubCiNxDeploy(await findRepoRoot(), options);
341
+ },
342
+ );
313
343
 
314
344
  return program;
315
345
  }
@@ -4,6 +4,8 @@ import { dirname, join } from 'node:path';
4
4
  import { $ } from 'bun';
5
5
  import { decode, run, runStatus } from '../lib/run.js';
6
6
 
7
+ type NxSmartMode = 'auto' | 'affected' | 'run-many';
8
+
7
9
  export async function cleanupGithubCiCache(root: string): Promise<void> {
8
10
  const githubOutput = process.env.GITHUB_OUTPUT;
9
11
  const markCacheReady = async (ready: boolean): Promise<void> => {
@@ -113,17 +115,17 @@ async function addReferencesFrom(roots: Set<string>, path: string, cwd: string):
113
115
 
114
116
  export async function githubCiNxSmart(
115
117
  root: string,
116
- options: { target: string; name?: string; step?: string },
118
+ options: { target: string; name?: string; step?: string; mode?: NxSmartMode; configuration?: string },
117
119
  ): Promise<void> {
118
120
  const name = options.name ?? options.target;
119
121
  const step = options.step ?? '';
120
122
  await createGithubStatus(name, step);
121
- const mode =
122
- process.env.GITHUB_EVENT_NAME === 'push' && process.env.GITHUB_REF_NAME === 'main' ? 'run-many' : 'affected';
123
- const nxArgs =
124
- mode === 'run-many'
125
- ? ['run-many', '-t', options.target, '--parallel=5']
126
- : ['affected', '-t', options.target, '--parallel=5'];
123
+ const mode = resolveNxSmartMode(options.mode ?? 'auto');
124
+ const nxArgs = mode === 'run-many' ? ['run-many', '-t', options.target] : ['affected', '-t', options.target];
125
+ if (options.configuration) {
126
+ nxArgs.push(`--configuration=${options.configuration}`);
127
+ }
128
+ nxArgs.push('--parallel=5');
127
129
  const status = await runStatus('nx', nxArgs, root);
128
130
  await updateGithubStatus(name, status === 0 ? 'success' : 'failure', step);
129
131
  if (status !== 0) {
@@ -131,14 +133,98 @@ export async function githubCiNxSmart(
131
133
  }
132
134
  }
133
135
 
134
- export async function githubCiNxRunMany(root: string, options: { targets: string; projects?: string }): Promise<void> {
136
+ export async function githubCiNxRunMany(
137
+ root: string,
138
+ options: { targets: string; projects?: string; configuration?: string },
139
+ ): Promise<void> {
135
140
  const nxArgs = ['run-many', '-t', options.targets, '--parallel=5'];
136
141
  if (options.projects) {
137
142
  nxArgs.push(`--projects=${options.projects}`);
138
143
  }
144
+ if (options.configuration) {
145
+ nxArgs.push(`--configuration=${options.configuration}`);
146
+ }
139
147
  await run('nx', nxArgs, root);
140
148
  }
141
149
 
150
+ export async function githubCiNxDeploy(
151
+ root: string,
152
+ options: { configuration: string; mode?: NxSmartMode; name?: string; step?: string; verify?: boolean },
153
+ ): Promise<void> {
154
+ const name = options.name ?? `Deploy ${options.configuration}`;
155
+ const step = options.step ?? '';
156
+ await createGithubStatus(name, step);
157
+ const mode = resolveNxSmartMode(options.mode ?? 'run-many');
158
+ const projects = await deployProjectsWithConfiguration(root, options.configuration, mode);
159
+ if (projects.length === 0) {
160
+ console.log(`No ${mode} deploy projects with configuration ${options.configuration}; skipping.`);
161
+ await updateGithubStatus(name, 'success', step);
162
+ return;
163
+ }
164
+
165
+ const projectList = projects.join(',');
166
+ const targets = options.verify === true ? ['build', 'lint', 'test', 'deploy'] : ['deploy'];
167
+ for (const target of targets) {
168
+ const nxArgs = ['run-many', '-t', target, `--projects=${projectList}`, '--parallel=5'];
169
+ if (target === 'deploy') {
170
+ nxArgs.push(`--configuration=${options.configuration}`);
171
+ }
172
+ const status = await runStatus('nx', nxArgs, root);
173
+ if (status !== 0) {
174
+ await updateGithubStatus(name, 'failure', step);
175
+ throw new Error(`nx ${nxArgs.join(' ')} failed with exit code ${status}`);
176
+ }
177
+ }
178
+ await updateGithubStatus(name, 'success', step);
179
+ }
180
+
181
+ function resolveNxSmartMode(mode: NxSmartMode): 'affected' | 'run-many' {
182
+ if (mode === 'affected' || mode === 'run-many') {
183
+ return mode;
184
+ }
185
+ return process.env.GITHUB_EVENT_NAME === 'push' && process.env.GITHUB_REF_NAME === 'main' ? 'run-many' : 'affected';
186
+ }
187
+
188
+ async function deployProjectsWithConfiguration(
189
+ root: string,
190
+ configuration: string,
191
+ mode: 'affected' | 'run-many',
192
+ ): Promise<string[]> {
193
+ const listArgs =
194
+ mode === 'affected'
195
+ ? ['show', 'projects', '--affected', '--withTarget', 'deploy', '--json']
196
+ : ['show', 'projects', '--withTarget', 'deploy', '--json'];
197
+ const result = await $`nx ${listArgs}`.cwd(root).quiet();
198
+ const candidates = nxProjectList(decode(result.stdout));
199
+ const projects: string[] = [];
200
+ for (const project of candidates) {
201
+ if (await deployTargetHasConfiguration(root, project, configuration)) {
202
+ projects.push(project);
203
+ }
204
+ }
205
+ return projects.sort((a, b) => a.localeCompare(b));
206
+ }
207
+
208
+ async function deployTargetHasConfiguration(root: string, project: string, configuration: string): Promise<boolean> {
209
+ const result = await $`nx show project ${project} --json`.cwd(root).quiet();
210
+ const parsed: unknown = JSON.parse(decode(result.stdout));
211
+ const targets = recordValue(parsed)?.targets;
212
+ const deploy = recordValue(targets)?.deploy;
213
+ const configurations = recordValue(deploy)?.configurations;
214
+ return recordValue(configurations)?.[configuration] !== undefined;
215
+ }
216
+
217
+ function nxProjectList(output: string): string[] {
218
+ const parsed: unknown = JSON.parse(output);
219
+ return Array.isArray(parsed) ? parsed.filter((project): project is string => typeof project === 'string') : [];
220
+ }
221
+
222
+ function recordValue(value: unknown): Record<string, unknown> | undefined {
223
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
224
+ ? (value as Record<string, unknown>)
225
+ : undefined;
226
+ }
227
+
142
228
  async function createGithubStatus(name: string, step: string): Promise<void> {
143
229
  await postGithubStatus(name, 'pending', `Running ${name}...`, step);
144
230
  }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { CiWorkflowStepKind, defineCiWorkflow, renderCiWorkflowYaml } from '../ci-workflow.js';
5
+
6
+ describe('CI workflow definition', () => {
7
+ it('renders the checked-in local CI workflow copy', async () => {
8
+ const rendered = renderCiWorkflowYaml({ deploy: false, pushBranches: ['main'] });
9
+ const packageRoot = join(import.meta.dir, '..', '..', '..');
10
+
11
+ await expect(readFile(join(packageRoot, '..', '..', '.github/workflows/ci.yml'), 'utf8')).resolves.toBe(rendered);
12
+ });
13
+
14
+ it('inserts deploy after tests and renumbers following deeplink steps', () => {
15
+ const steps = defineCiWorkflow({ deploy: true, pushBranches: ['main'] });
16
+ const rendered = renderCiWorkflowYaml({ deploy: true, pushBranches: ['main'] });
17
+
18
+ expect(steps.map((step) => [step.kind, step.number])).toContainEqual([CiWorkflowStepKind.Deploy, 9]);
19
+ expect(rendered).toContain('- name: ๐Ÿš€ Deploy Staging');
20
+ expect(rendered).not.toContain('CLOUDFLARE_API_TOKEN');
21
+ expect(rendered).not.toContain('CLOUDFLARE_ACCOUNT_ID');
22
+ expect(rendered).toContain(
23
+ 'smoo github-ci nx-deploy --configuration staging --mode affected --name "Deploy Staging" --step 9',
24
+ );
25
+ expect(rendered).toContain("# Step 10\n # Nx's database cache needs artifact files");
26
+ });
27
+
28
+ it('adds Cloudflare credentials for Wrangler-backed deploys', () => {
29
+ const rendered = renderCiWorkflowYaml({ deploy: true, deployProvider: 'cloudflare', pushBranches: ['main'] });
30
+
31
+ expect(rendered).toContain('CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}');
32
+ expect(rendered).toContain('CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}');
33
+ });
34
+ });
@@ -28,6 +28,61 @@ describe('publish workflow definition', () => {
28
28
  expect(rendered).toContain('packages must already exist on npm and use trusted publishing/OIDC');
29
29
  });
30
30
 
31
+ it('omits production deploy controls when no production deploy target exists', () => {
32
+ const rendered = renderPublishWorkflowYaml({ repoName: '@smoothbricks/codebase' });
33
+
34
+ expect(rendered).not.toContain('deploy_environment');
35
+ expect(rendered).not.toContain('Deploy production');
36
+ expect(rendered).not.toContain('nx-deploy');
37
+ });
38
+
39
+ it('renders production deploy controls for repos with production deploy targets', () => {
40
+ const rendered = renderPublishWorkflowYaml({ deploy: true, repoName: '@smoothbricks/codebase' });
41
+
42
+ expect(rendered).toContain('deploy_environment:');
43
+ expect(rendered).toContain('- name: ๐Ÿš€ Deploy production');
44
+ expect(rendered).not.toContain('CLOUDFLARE_API_TOKEN');
45
+ expect(rendered).not.toContain('CLOUDFLARE_ACCOUNT_ID');
46
+ expect(rendered).toContain(
47
+ 'smoo github-ci nx-deploy --configuration production --mode run-many --verify --name "Deploy Production"',
48
+ );
49
+ });
50
+
51
+ it('adds Cloudflare credentials for Wrangler-backed production deploys', () => {
52
+ const rendered = renderPublishWorkflowYaml({
53
+ deploy: true,
54
+ deployProvider: 'cloudflare',
55
+ repoName: '@smoothbricks/codebase',
56
+ });
57
+
58
+ expect(rendered).toContain('CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}');
59
+ expect(rendered).toContain('CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}');
60
+ });
61
+
62
+ it('deploys production only after a real publish when requested', async () => {
63
+ const deployed = await publishWorkflowScenario({
64
+ deploy: true,
65
+ repairs: [],
66
+ current: [],
67
+ bump: 'auto',
68
+ deployEnvironment: 'production',
69
+ dryRun: false,
70
+ version: { mode: 'new', projects: ['app'] },
71
+ }).run();
72
+ const dryRun = await publishWorkflowScenario({
73
+ deploy: true,
74
+ repairs: [],
75
+ current: [],
76
+ bump: 'auto',
77
+ deployEnvironment: 'production',
78
+ dryRun: true,
79
+ version: { mode: 'new', projects: ['app'] },
80
+ }).run();
81
+
82
+ expect(deployed.productionDeployed).toBe(true);
83
+ expect(dryRun.productionDeployed).toBe(false);
84
+ });
85
+
31
86
  it('bootstraps the self-hosted CLI before release commands only for the SmoothBricks repo', async () => {
32
87
  const smoothbricks = await publishWorkflowScenario({
33
88
  repoName: '@smoothbricks/codebase',
@@ -143,10 +198,12 @@ interface ReleaseGap {
143
198
  }
144
199
 
145
200
  interface WorkflowScenarioConfig {
201
+ deploy?: boolean;
146
202
  repoName?: string;
147
203
  repairs: ReleaseGap[];
148
204
  current: ReleaseGap[];
149
205
  bump: PublishWorkflowBump;
206
+ deployEnvironment?: 'none' | 'production';
150
207
  dryRun: boolean;
151
208
  version: PublishWorkflowVersionOutputs;
152
209
  }
@@ -161,6 +218,7 @@ interface WorkflowOutcome {
161
218
  repairBuildArtifacts: string[];
162
219
  validation: { checks: number; builds: string[]; lints: string[]; tests: string[]; validates: number };
163
220
  publishRan: boolean;
221
+ productionDeployed: boolean;
164
222
  publishSawValidatedRelease: boolean;
165
223
  publishCompletedTags: string[];
166
224
  remainingDurableGaps: string[];
@@ -170,8 +228,8 @@ function publishWorkflowScenario(config: WorkflowScenarioConfig): { run(): Promi
170
228
  return {
171
229
  async run() {
172
230
  const state = new WorkflowScenarioState(config);
173
- await runPublishWorkflow(definePublishWorkflow({ repoName: config.repoName }), {
174
- inputs: { bump: config.bump, dryRun: config.dryRun },
231
+ await runPublishWorkflow(definePublishWorkflow({ deploy: config.deploy, repoName: config.repoName }), {
232
+ inputs: { bump: config.bump, deployEnvironment: config.deployEnvironment ?? 'none', dryRun: config.dryRun },
175
233
  callbacks: state.callbacks(),
176
234
  });
177
235
  return state.outcome();
@@ -186,6 +244,7 @@ class WorkflowScenarioState {
186
244
  private repairObservedSelfHostedCli = false;
187
245
  private versionObservedSelfHostedCli = false;
188
246
  private publishReleaseRan = false;
247
+ private productionDeployRan = false;
189
248
  private publishSawValidation = false;
190
249
  private readonly repaired = new Set<string>();
191
250
  private readonly repairBuilds = new Set<string>();
@@ -275,6 +334,9 @@ class WorkflowScenarioState {
275
334
  }
276
335
  }
277
336
  },
337
+ deployProduction: async () => {
338
+ this.productionDeployRan = true;
339
+ },
278
340
  saveNixDevenv: async () => {},
279
341
  };
280
342
  }
@@ -296,6 +358,7 @@ class WorkflowScenarioState {
296
358
  validates: this.validationState.validates,
297
359
  },
298
360
  publishRan: this.publishReleaseRan,
361
+ productionDeployed: this.productionDeployRan,
299
362
  publishSawValidatedRelease: this.publishSawValidation,
300
363
  publishCompletedTags: [...this.publishedCurrent].sort(),
301
364
  remainingDurableGaps: [...this.durableGaps].sort(),