@smoothbricks/cli 0.10.4 โ†’ 0.10.6

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 (56) hide show
  1. package/dist/cli.js +5 -5
  2. package/dist/github-ci/index.d.ts.map +1 -1
  3. package/dist/github-ci/index.js +5 -3
  4. package/dist/lib/json.d.ts +5 -1
  5. package/dist/lib/json.d.ts.map +1 -1
  6. package/dist/lib/json.js +18 -16
  7. package/dist/monorepo/ci-workflow.d.ts +2 -0
  8. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  9. package/dist/monorepo/ci-workflow.js +10 -4
  10. package/dist/monorepo/github-runs-on.d.ts +14 -0
  11. package/dist/monorepo/github-runs-on.d.ts.map +1 -0
  12. package/dist/monorepo/github-runs-on.js +43 -0
  13. package/dist/monorepo/index.d.ts +3 -3
  14. package/dist/monorepo/index.d.ts.map +1 -1
  15. package/dist/monorepo/index.js +20 -8
  16. package/dist/monorepo/lockfile.d.ts +2 -1
  17. package/dist/monorepo/lockfile.d.ts.map +1 -1
  18. package/dist/monorepo/lockfile.js +1 -1
  19. package/dist/monorepo/managed-files.d.ts +30 -3
  20. package/dist/monorepo/managed-files.d.ts.map +1 -1
  21. package/dist/monorepo/managed-files.js +83 -63
  22. package/dist/monorepo/packs/index.js +2 -2
  23. package/dist/monorepo/publish-workflow.d.ts +3 -0
  24. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  25. package/dist/monorepo/publish-workflow.js +17 -14
  26. package/dist/monorepo/tool-validation.d.ts +2 -2
  27. package/dist/monorepo/tool-validation.d.ts.map +1 -1
  28. package/dist/monorepo/tool-validation.js +110 -16
  29. package/dist/release/index.d.ts.map +1 -1
  30. package/dist/release/index.js +6 -0
  31. package/managed/raw/envrc +5 -3
  32. package/managed/raw/patches/ttsc@0.19.3.patch +67 -0
  33. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +24 -7
  34. package/managed/raw/tooling/direnv/setup-environment.ts +23 -3
  35. package/managed/templates/github/actions/cache-nix-devenv/action.yml +13 -11
  36. package/managed/templates/github/actions/save-nix-devenv/action.yml +34 -3
  37. package/managed/templates/github/actions/setup-devenv/action.yml +44 -15
  38. package/managed/templates/github/workflows/managed-files.yml +64 -12
  39. package/package.json +2 -2
  40. package/src/cli.ts +6 -6
  41. package/src/github-ci/index.test.ts +3 -3
  42. package/src/github-ci/index.ts +6 -3
  43. package/src/lib/json.ts +5 -1
  44. package/src/monorepo/__tests__/ci-workflow.test.ts +37 -1
  45. package/src/monorepo/__tests__/publish-workflow.test.ts +28 -1
  46. package/src/monorepo/ci-workflow.ts +14 -4
  47. package/src/monorepo/github-runs-on.ts +45 -0
  48. package/src/monorepo/index.ts +20 -8
  49. package/src/monorepo/lockfile.ts +3 -2
  50. package/src/monorepo/managed-files.test.ts +49 -0
  51. package/src/monorepo/managed-files.ts +105 -64
  52. package/src/monorepo/packs/index.ts +2 -2
  53. package/src/monorepo/publish-workflow.ts +23 -15
  54. package/src/monorepo/tool-validation.test.ts +38 -16
  55. package/src/monorepo/tool-validation.ts +114 -16
  56. package/src/release/index.ts +6 -0
@@ -8,9 +8,10 @@ export interface SyncBunLockfileVersionsOptions {
8
8
  /** `git add` bun.lock when versions were resynced. */
9
9
  stage?: boolean;
10
10
  /**
11
- * `install` (default for validation): lockfile must match package.json.
11
+ * `install` (default): lockfile must match package.json (including -next).
12
12
  * `publish`: rewrite unpublished prerelease package.json versions to the last
13
13
  * stable git tag so `bun pm pack` embeds installable dependency versions.
14
+ * Only use publish mode around pack; never leave the tree in publish mode.
14
15
  */
15
16
  mode?: 'install' | 'publish';
16
17
  }
@@ -34,7 +35,7 @@ export interface SyncBunLockfileVersionsOptions {
34
35
  // rewrites lockfile back to package.json and frozen CI then fails.
35
36
  export function syncBunLockfileVersions(root: string, options: SyncBunLockfileVersionsOptions = {}): number {
36
37
  const log = options.log ?? true;
37
- const mode = options.mode ?? 'publish';
38
+ const mode = options.mode ?? 'install';
38
39
  const lockfilePath = join(root, 'bun.lock');
39
40
  if (!existsSync(lockfilePath)) {
40
41
  throw new Error('bun.lock not found');
@@ -5,13 +5,16 @@ import { join } from 'node:path';
5
5
  import { LINUX_PLATFORM_TARGET_GLOBS, PLATFORM_TARGET_GLOBS } from '@smoothbricks/nx-plugin/workspace-config-policy';
6
6
  import fc from 'fast-check';
7
7
  import {
8
+ deployTargetInfoFromProjects,
8
9
  extractInlineLocalBlocksForTest,
9
10
  INLINE_LOCAL_BEGIN,
10
11
  INLINE_LOCAL_END,
11
12
  LOCAL_SECTION_MARKER,
13
+ type NxGraphProjectNode,
12
14
  platformTargetGlobsForTest,
13
15
  reinsertInlineLocalBlocksForTest,
14
16
  splitLocalSectionForTest,
17
+ targetNamesFromProjects,
15
18
  } from './managed-files.js';
16
19
 
17
20
  const MANAGED = '# managed content\npath merge=driver\n';
@@ -172,6 +175,52 @@ describe('managed publish platform discovery', () => {
172
175
  });
173
176
  });
174
177
 
178
+ describe('nx graph project helpers', () => {
179
+ const sampleNodes: Record<string, NxGraphProjectNode> = {
180
+ lib: {
181
+ data: {
182
+ targets: {
183
+ build: {},
184
+ 'bundle-linux': {},
185
+ lint: {},
186
+ },
187
+ },
188
+ },
189
+ app: {
190
+ data: {
191
+ targets: {
192
+ deploy: {
193
+ options: { command: 'echo no' },
194
+ configurations: {
195
+ staging: { command: 'wrangler deploy --env staging' },
196
+ production: { options: { command: 'wrangler deploy --env production' } },
197
+ },
198
+ },
199
+ 'package-macos': {},
200
+ },
201
+ },
202
+ },
203
+ };
204
+
205
+ it('collects target names from graph nodes without project names', () => {
206
+ expect(targetNamesFromProjects(sampleNodes).sort()).toEqual(
207
+ ['build', 'bundle-linux', 'deploy', 'lint', 'package-macos'].sort(),
208
+ );
209
+ });
210
+
211
+ it('detects deploy configurations and cloudflare provider from graph nodes', () => {
212
+ expect(deployTargetInfoFromProjects(sampleNodes, 'staging')).toEqual({
213
+ exists: true,
214
+ provider: 'cloudflare',
215
+ });
216
+ expect(deployTargetInfoFromProjects(sampleNodes, 'production')).toEqual({
217
+ exists: true,
218
+ provider: 'cloudflare',
219
+ });
220
+ expect(deployTargetInfoFromProjects(sampleNodes, 'preview')).toEqual({ exists: false });
221
+ });
222
+ });
223
+
175
224
  describe('managed cache actions', () => {
176
225
  it('renders the checked-in action copies from their managed templates', async () => {
177
226
  for (const action of CACHE_ACTIONS) {
@@ -1,9 +1,10 @@
1
- import { execFileSync } from 'node:child_process';
2
1
  import { appendFileSync, existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
2
  import { dirname, join, resolve } from 'node:path';
4
3
  import { fileURLToPath } from 'node:url';
5
4
  import { PLATFORM_TARGET_GLOBS } from '@smoothbricks/nx-plugin/workspace-config-policy';
6
- import { type PackageJson, parseNxProjectJsonText, parseStringArrayText } from '../lib/json.js';
5
+ // Nx package exports are CLI-oriented; this is the module the CLI uses for graph + daemon IPC.
6
+ import { createProjectGraphAsync } from 'nx/src/project-graph/project-graph.js';
7
+ import type { PackageJson } from '../lib/json.js';
7
8
  import { listReleasePackages, readPackageJson } from '../lib/workspace.js';
8
9
  import { renderCiWorkflowYaml } from './ci-workflow.js';
9
10
  import { renderPublishWorkflowYaml } from './publish-workflow.js';
@@ -137,6 +138,7 @@ interface ManagedFileContext {
137
138
  stagingDeployProvider?: 'cloudflare';
138
139
  productionDeployProvider?: 'cloudflare';
139
140
  ciPushBranches: string[];
141
+ ciRunsOn: string | string[];
140
142
  nodeModulesCacheKey: string;
141
143
  repoName: string;
142
144
  platformTargetGlobs: string[];
@@ -199,6 +201,11 @@ const managedFiles: ManagedFile[] = [
199
201
  target: 'tooling/direnv/merge-newer-pins.sh',
200
202
  executable: true,
201
203
  },
204
+ {
205
+ kind: 'raw',
206
+ source: 'patches/ttsc@0.19.3.patch',
207
+ target: 'patches/ttsc@0.19.3.patch',
208
+ },
202
209
  {
203
210
  kind: 'generated',
204
211
  source: 'ci-workflow',
@@ -249,8 +256,8 @@ const managedFiles: ManagedFile[] = [
249
256
 
250
257
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
251
258
 
252
- export function applyManagedFiles(root: string, mode: 'update' | 'check' | 'diff'): FileResult[] {
253
- const context = getManagedFileContext(root);
259
+ export async function applyManagedFiles(root: string, mode: 'update' | 'check' | 'diff'): Promise<FileResult[]> {
260
+ const context = await getManagedFileContext(root);
254
261
  return managedFiles.map((file) => applyManagedFile(root, file, mode, context));
255
262
  }
256
263
 
@@ -301,6 +308,7 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
301
308
  deploy: context.hasStagingDeployTargets,
302
309
  deployProvider: context.stagingDeployProvider,
303
310
  pushBranches: context.ciPushBranches,
311
+ runsOn: context.ciRunsOn,
304
312
  });
305
313
  }
306
314
  if (file.source === 'publish-workflow') {
@@ -309,6 +317,7 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
309
317
  deployProvider: context.productionDeployProvider,
310
318
  repoName: context.repoName,
311
319
  platformTargetGlobs: context.platformTargetGlobs,
320
+ runsOn: context.ciRunsOn,
312
321
  });
313
322
  }
314
323
  throw new Error(`Unknown generated managed file source ${file.source}`);
@@ -322,13 +331,16 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
322
331
  return renderTemplate(context, content);
323
332
  }
324
333
 
325
- function getManagedFileContext(root: string): ManagedFileContext {
334
+ async function getManagedFileContext(root: string): Promise<ManagedFileContext> {
326
335
  const packageJson = readPackageJson(join(root, 'package.json'));
327
336
  const repoName = packageJson?.name ?? 'monorepo';
328
337
  const ciPushBranches = getCiPushBranches(packageJson?.json);
329
- const stagingDeploy = getDeployTargetInfo(root, 'staging');
330
- const productionDeploy = getDeployTargetInfo(root, 'production');
331
- const platformTargetGlobs = platformTargetGlobsForTest(readResolvedNxTargetNames(root));
338
+ const ciRunsOn = getCiRunsOn(packageJson?.json);
339
+ // In-process Nx API โ†’ daemon socket (no second Node/`nx` CLI process).
340
+ const nxProjects = await loadNxGraphProjects(root);
341
+ const stagingDeploy = deployTargetInfoFromProjects(nxProjects, 'staging');
342
+ const productionDeploy = deployTargetInfoFromProjects(nxProjects, 'production');
343
+ const platformTargetGlobs = platformTargetGlobsForTest(targetNamesFromProjects(nxProjects));
332
344
  const nodeModulesCacheKey = existsSync(join(root, 'bun.lock'))
333
345
  ? `$${"{{ hashFiles('bun.lock', 'package.json', 'packages/*/package.json') }}"}`
334
346
  : `$${"{{ hashFiles('bun.lockb', 'package.json', 'packages/*/package.json') }}"}`;
@@ -339,6 +351,7 @@ function getManagedFileContext(root: string): ManagedFileContext {
339
351
  stagingDeployProvider: stagingDeploy.provider,
340
352
  productionDeployProvider: productionDeploy.provider,
341
353
  ciPushBranches,
354
+ ciRunsOn,
342
355
  nodeModulesCacheKey,
343
356
  repoName,
344
357
  platformTargetGlobs,
@@ -353,81 +366,109 @@ export function platformTargetGlobsForTest(targetNames: Iterable<string>): strin
353
366
  });
354
367
  }
355
368
 
356
- function readResolvedNxTargetNames(root: string): string[] {
357
- const output = execFileSync('nx', ['show', 'projects', '--json'], {
358
- cwd: root,
359
- encoding: 'utf8',
360
- stdio: ['ignore', 'pipe', 'inherit'],
361
- });
362
- const projects = parseStringArrayText(output);
363
- if (!projects) {
364
- return [];
365
- }
366
- const targetNames = new Set<string>();
367
- for (const project of projects) {
368
- const projectOutput = execFileSync('nx', ['show', 'project', project, '--json'], {
369
- cwd: root,
370
- encoding: 'utf8',
371
- stdio: ['ignore', 'pipe', 'inherit'],
372
- });
373
- const projectJson = parseNxProjectJsonText(projectOutput);
374
- const targets = projectJson?.targets;
375
- if (targets) {
376
- for (const targetName of Object.keys(targets)) {
377
- targetNames.add(targetName);
378
- }
369
+ /** Resolved Nx project node as returned under `graph.nodes` / project-graph.json. */
370
+ export type NxGraphProjectNode = {
371
+ data?: { targets?: Record<string, NxGraphTarget> };
372
+ targets?: Record<string, NxGraphTarget>;
373
+ };
374
+
375
+ type NxGraphTarget = {
376
+ command?: string;
377
+ options?: { command?: string };
378
+ configurations?: Record<string, { command?: string; options?: { command?: string } }>;
379
+ };
380
+
381
+ /**
382
+ * Load resolved project nodes via Nx's programmatic API.
383
+ *
384
+ * `createProjectGraphAsync` is what the CLI uses: with the daemon up it speaks the
385
+ * daemon unix socket instead of rebuilding the graph in a fresh Node process.
386
+ * Spawning `nx graph` / `nx show` only added CLI boot + plugin-worker overhead on
387
+ * top of that. smoo runs under Bun; Bun can import this CJS module directly.
388
+ */
389
+ async function loadNxGraphProjects(root: string): Promise<Record<string, NxGraphProjectNode>> {
390
+ const prevCwd = process.cwd();
391
+ process.chdir(root);
392
+ try {
393
+ const graph = (await createProjectGraphAsync()) as {
394
+ nodes: Record<string, { data?: { targets?: Record<string, NxGraphTarget> } }>;
395
+ };
396
+ const out: Record<string, NxGraphProjectNode> = {};
397
+ for (const [name, node] of Object.entries(graph.nodes)) {
398
+ out[name] = { data: { targets: node.data?.targets ?? {} } };
379
399
  }
400
+ return out;
401
+ } finally {
402
+ process.chdir(prevCwd);
380
403
  }
381
- return [...targetNames];
382
404
  }
383
405
 
384
- function renderTemplate(context: ManagedFileContext, template: string): string {
385
- return template
386
- .replaceAll('{{REPO_NAME}}', context.repoName)
387
- .replaceAll('__SMOO_CI_PUSH_BRANCHES__', renderYamlFlowList(context.ciPushBranches))
388
- .replaceAll('{{NODE_MODULES_CACHE_KEY}}', context.nodeModulesCacheKey);
406
+ function targetsOfProject(node: NxGraphProjectNode): Record<string, NxGraphTarget> {
407
+ return node.data?.targets ?? node.targets ?? {};
389
408
  }
390
409
 
391
- function getDeployTargetInfo(root: string, configuration: string): DeployTargetInfo {
392
- const output = execFileSync('nx', ['show', 'projects', '--withTarget', 'deploy', '--json'], {
393
- cwd: root,
394
- encoding: 'utf8',
395
- stdio: ['ignore', 'pipe', 'inherit'],
396
- });
397
- const projects = parseStringArrayText(output);
398
- if (!projects) {
399
- return { exists: false };
410
+ /** Test seam: collect target names from a graph.nodes map. */
411
+ export function targetNamesFromProjects(nodes: Record<string, NxGraphProjectNode>): string[] {
412
+ const targetNames = new Set<string>();
413
+ for (const node of Object.values(nodes)) {
414
+ for (const name of Object.keys(targetsOfProject(node))) {
415
+ targetNames.add(name);
416
+ }
400
417
  }
418
+ return [...targetNames];
419
+ }
420
+
421
+ /** Test seam: deploy configuration presence/provider from graph nodes. */
422
+ export function deployTargetInfoFromProjects(
423
+ nodes: Record<string, NxGraphProjectNode>,
424
+ configuration: string,
425
+ ): DeployTargetInfo {
401
426
  let exists = false;
402
427
  let provider: DeployTargetInfo['provider'];
403
- for (const project of projects) {
404
- const target = nxDeployTarget(root, project, configuration);
405
- if (!target.exists) {
428
+ for (const node of Object.values(nodes)) {
429
+ const info = deployTargetInfoFromTargets(targetsOfProject(node), configuration);
430
+ if (!info.exists) {
406
431
  continue;
407
432
  }
408
433
  exists = true;
409
- provider ??= target.provider;
434
+ provider ??= info.provider;
410
435
  }
411
436
  return { exists, provider };
412
437
  }
413
438
 
414
- function nxDeployTarget(root: string, project: string, configuration: string): DeployTargetInfo {
415
- const output = execFileSync('nx', ['show', 'project', project, '--json'], {
416
- cwd: root,
417
- encoding: 'utf8',
418
- stdio: ['ignore', 'pipe', 'inherit'],
419
- });
420
- const projectJson = parseNxProjectJsonText(output);
421
- const deploy = projectJson?.targets?.deploy;
422
- const config = deploy?.configurations?.[configuration];
439
+ function deployTargetInfoFromTargets(targets: Record<string, NxGraphTarget>, configuration: string): DeployTargetInfo {
440
+ const deploy = targets.deploy;
441
+ if (!deploy) {
442
+ return { exists: false };
443
+ }
444
+ const config = deploy.configurations?.[configuration];
423
445
  if (!config) {
424
446
  return { exists: false };
425
447
  }
426
- const commandValue = config.command ?? config.options?.command ?? deploy?.options?.command;
448
+ const commandValue = config.command ?? config.options?.command ?? deploy.options?.command ?? deploy.command;
427
449
  const command = typeof commandValue === 'string' ? commandValue : '';
428
450
  return { exists: true, provider: command.includes('wrangler ') ? 'cloudflare' : undefined };
429
451
  }
430
452
 
453
+ function renderTemplate(context: ManagedFileContext, template: string): string {
454
+ return template
455
+ .replaceAll('{{REPO_NAME}}', context.repoName)
456
+ .replaceAll('__SMOO_CI_PUSH_BRANCHES__', renderYamlFlowList(context.ciPushBranches))
457
+ .replaceAll('{{NODE_MODULES_CACHE_KEY}}', context.nodeModulesCacheKey);
458
+ }
459
+
460
+ function getCiRunsOn(packageJson: PackageJson | null | undefined): string | string[] {
461
+ const configured = packageJson?.smoo?.github?.runsOn;
462
+ if (configured === undefined) {
463
+ return 'ubuntu-latest';
464
+ }
465
+ if (typeof configured === 'string') {
466
+ return configured.length > 0 ? configured : 'ubuntu-latest';
467
+ }
468
+ const labels = configured.filter((label) => label.length > 0);
469
+ return labels.length > 0 ? labels : 'ubuntu-latest';
470
+ }
471
+
431
472
  function getCiPushBranches(packageJson: PackageJson | null | undefined): string[] {
432
473
  const configured = readCiPushBranches(packageJson);
433
474
  return configured.length > 0 ? configured : ['main'];
@@ -453,8 +494,8 @@ export function printResults(results: FileResult[]): void {
453
494
  }
454
495
  }
455
496
 
456
- export function validateManagedFiles(root: string): number {
457
- const results = applyManagedFiles(root, 'check');
497
+ export async function validateManagedFiles(root: string): Promise<number> {
498
+ const results = await applyManagedFiles(root, 'check');
458
499
  printResults(results);
459
500
  const failures = results.filter((result) => result.action === 'drifted').length;
460
501
  if (failures > 0) {
@@ -473,8 +514,8 @@ export function validateManagedFiles(root: string): number {
473
514
  * Under GitHub Actions the drifted-file count is published as the step output
474
515
  * `drifted`, so downstream steps gate declaratively instead of parsing logs.
475
516
  */
476
- export function warnOnManagedFileDrift(root: string): void {
477
- const results = applyManagedFiles(root, 'check');
517
+ export async function warnOnManagedFileDrift(root: string): Promise<void> {
518
+ const results = await applyManagedFiles(root, 'check');
478
519
  printResults(results);
479
520
  const drifted = results.filter((result) => result.action === 'drifted');
480
521
  if (process.env.GITHUB_OUTPUT) {
@@ -92,11 +92,11 @@ const packs: MonorepoPack[] = [
92
92
  // Managed-file drift is derived state (CLI template x pinned version) with
93
93
  // its own remediation flow; it warns instead of failing so validation only
94
94
  // blocks on actual package issues. See warnOnManagedFileDrift.
95
- warnOnManagedFileDrift(ctx.root);
95
+ await warnOnManagedFileDrift(ctx.root);
96
96
  return (
97
97
  runtimeFailures +
98
98
  validateRootPackagePolicy(ctx.root) +
99
- validateToolConfig(ctx.root) +
99
+ (await validateToolConfig(ctx.root)) +
100
100
  validateNxProjectNames(ctx.root) +
101
101
  validateNxReleaseConfig(ctx.root) +
102
102
  validateBunLockfileVersions(ctx.root) +
@@ -7,6 +7,7 @@ import {
7
7
  } from '@smoothbricks/nx-plugin/workspace-config-policy';
8
8
  import type { Options as PrettierOptions } from 'prettier';
9
9
  import { isSmoothBricksCodebasePackageName } from '../lib/cli-package.js';
10
+ import { renderRunsOnLine, type WorkflowRunsOn } from './github-runs-on.js';
10
11
 
11
12
  const PUBLISH_WORKFLOW_FORMAT_OPTIONS = Object.freeze({
12
13
  parser: 'yaml',
@@ -55,6 +56,8 @@ export interface PublishWorkflowDefinitionOptions {
55
56
  deployProvider?: 'cloudflare';
56
57
  repoName?: string;
57
58
  platformTargetGlobs?: readonly string[];
59
+ /** Linux jobs only. Default ubuntu-latest. Same smoo.github.runsOn as CI. */
60
+ runsOn?: WorkflowRunsOn;
58
61
  }
59
62
 
60
63
  export interface PublishWorkflowInputs {
@@ -441,7 +444,8 @@ function yamlLinesForStep(step: PublishWorkflowStep, options: PublishWorkflowDef
441
444
  case PublishWorkflowStepKind.UploadTraceDbs:
442
445
  return [
443
446
  ` - name: ${step.name}`,
444
- ` if: ${githubExpression('failure()')}`,
447
+ ' # Default is success(); failure() uploads traces only when the job already failed.',
448
+ ' if: failure()',
445
449
  ' uses: actions/upload-artifact@v7.0.1',
446
450
  ' with:',
447
451
  ` name: trace-results-${githubExpression('github.run_id')}`,
@@ -465,7 +469,8 @@ function yamlLinesForStep(step: PublishWorkflowStep, options: PublishWorkflowDef
465
469
  case PublishWorkflowStepKind.SaveNixDevenv:
466
470
  return [
467
471
  ` - name: ${step.name}`,
468
- ` if: ${githubExpression('always()')}`,
472
+ ' # success() is default; always() still saves GH Nix cache after a red job.',
473
+ ' if: always()',
469
474
  ' uses: ./.github/actions/save-nix-devenv',
470
475
  ' with:',
471
476
  ` nix-cache-hit: ${githubExpression('steps.setup.outputs.nix-cache-hit')}`,
@@ -498,7 +503,7 @@ function deployEnvLines(options: PublishWorkflowDefinitionOptions): string[] {
498
503
 
499
504
  function conditionalRunStep(step: PublishWorkflowStep, run: string): string[] {
500
505
  const condition = "steps.version.outputs.mode != 'none'";
501
- return [` - name: ${step.name}`, ` if: ${githubExpression(condition)}`, ` run: ${run}`];
506
+ return [` - name: ${step.name}`, ` if: ${condition}`, ` run: ${run}`];
502
507
  }
503
508
 
504
509
  function renderSingleJobPublishWorkflowSteps(
@@ -514,7 +519,7 @@ function renderSingleJobPublishWorkflowSteps(
514
519
  if (step.kind === PublishWorkflowStepKind.Build) {
515
520
  lines.push(
516
521
  ' - name: ๐Ÿง Build supplemental Linux targets',
517
- ` if: ${githubExpression("steps.version.outputs.mode != 'none'")}`,
522
+ " if: steps.version.outputs.mode != 'none'",
518
523
  ` run: smoo github-ci nx-run-many --targets "${LINUX_PLATFORM_TARGET_GLOBS.join(',')}" --projects "${githubExpression(
519
524
  'steps.version.outputs.projects',
520
525
  )}"`,
@@ -566,7 +571,7 @@ defaults:
566
571
 
567
572
  jobs:
568
573
  linux-release-candidate:
569
- runs-on: ubuntu-latest
574
+ ${renderRunsOnLine(options.runsOn)}
570
575
  permissions:
571
576
  contents: write
572
577
  id-token: none
@@ -642,7 +647,7 @@ function renderLinuxReleaseCandidateSteps(
642
647
  } else if (step.kind === PublishWorkflowStepKind.Build) {
643
648
  lines.push(
644
649
  ` - name: ${step.name}`,
645
- ` if: ${githubExpression("steps.version.outputs.mode != 'none'")}`,
650
+ " if: steps.version.outputs.mode != 'none'",
646
651
  ' run:',
647
652
  ` smoo github-ci nx-run-many --targets build --projects "${githubExpression(
648
653
  'steps.version.outputs.projects',
@@ -667,7 +672,7 @@ function renderLinuxReleaseCandidateSteps(
667
672
  '',
668
673
  ` # Step ${stepNumber}`,
669
674
  ' - name: ๐Ÿง Build supplemental Linux targets',
670
- ` if: ${githubExpression("steps.version.outputs.mode != 'none'")}`,
675
+ " if: steps.version.outputs.mode != 'none'",
671
676
  ' run:',
672
677
  ` smoo github-ci nx-run-many --targets "${LINUX_PLATFORM_TARGET_GLOBS.join(
673
678
  ',',
@@ -700,7 +705,7 @@ function renderLinuxReleaseCandidateSteps(
700
705
  '',
701
706
  ` # Step ${stepNumber++}`,
702
707
  ' - name: ๐Ÿ“ค Upload validated build outputs',
703
- ` if: ${githubExpression("steps.version.outputs.mode != 'none'")}`,
708
+ " if: steps.version.outputs.mode != 'none'",
704
709
  ' uses: actions/upload-artifact@v7.0.1',
705
710
  ' with:',
706
711
  ` name: publish-release-outputs-${githubExpression('github.run_id')}`,
@@ -714,7 +719,7 @@ function renderLinuxReleaseCandidateSteps(
714
719
  '',
715
720
  ` # Step ${stepNumber++}`,
716
721
  ' - name: ๐Ÿ“ค Upload supplemental Linux outputs',
717
- ` if: ${githubExpression("steps.version.outputs.mode != 'none'")}`,
722
+ " if: steps.version.outputs.mode != 'none'",
718
723
  ' uses: actions/upload-artifact@v7.0.1',
719
724
  ' with:',
720
725
  ` name: publish-linux-outputs-${githubExpression('github.run_id')}`,
@@ -730,7 +735,8 @@ function renderLinuxReleaseCandidateSteps(
730
735
  '',
731
736
  ` # Step ${stepNumber}`,
732
737
  ' - name: ๐Ÿงน Cleanup and cache Nix/devenv',
733
- ` if: ${githubExpression('always()')}`,
738
+ ' # success() is default; always() still saves GH Nix cache after a red job.',
739
+ ' if: always()',
734
740
  ' uses: ./.github/actions/save-nix-devenv',
735
741
  ' with:',
736
742
  ` nix-cache-hit: ${githubExpression('steps.setup.outputs.nix-cache-hit')}`,
@@ -792,7 +798,8 @@ function renderMacosPlatformSteps(options: PublishWorkflowDefinitionOptions): st
792
798
  '',
793
799
  ` # Step ${stepNumber}`,
794
800
  ' - name: ๐Ÿงน Cleanup and cache Nix/devenv',
795
- ` if: ${githubExpression('always()')}`,
801
+ ' # success() is default; always() still saves GH Nix cache after a red job.',
802
+ ' if: always()',
796
803
  ' uses: ./.github/actions/save-nix-devenv',
797
804
  ' with:',
798
805
  ` nix-cache-hit: ${githubExpression('steps.setup.outputs.nix-cache-hit')}`,
@@ -876,7 +883,7 @@ function renderFinalLinuxPublishSteps(options: PublishWorkflowDefinitionOptions)
876
883
  '',
877
884
  ` # Step ${stepNumber++}`,
878
885
  ' - name: ๐Ÿ“ฆ Apply verified Linux outputs',
879
- ` if: ${githubExpression(`${mode} != 'none'`)}`,
886
+ ` if: ${mode} != 'none'`,
880
887
  ' run:',
881
888
  ` smoo github-ci apply-outputs --source-sha "${githubExpression(
882
889
  'needs.linux-release-candidate.outputs.release-sha',
@@ -896,7 +903,7 @@ function renderFinalLinuxPublishSteps(options: PublishWorkflowDefinitionOptions)
896
903
  '',
897
904
  ` # Step ${stepNumber++}`,
898
905
  ' - name: ๐ŸŽ Apply verified macOS outputs',
899
- ` if: ${githubExpression(`${mode} != 'none'`)}`,
906
+ ` if: ${mode} != 'none'`,
900
907
  ' run:',
901
908
  ` smoo github-ci apply-outputs --source-sha "${githubExpression('github.sha')}"`,
902
909
  ` "${githubExpression('runner.temp')}/publish-artifacts/publish-macos-outputs-${githubExpression(
@@ -905,7 +912,7 @@ function renderFinalLinuxPublishSteps(options: PublishWorkflowDefinitionOptions)
905
912
  '',
906
913
  ` # Step ${stepNumber++}`,
907
914
  ` - name: โœ… Validate restored release (${githubExpression(mode)})`,
908
- ` if: ${githubExpression(`${mode} != 'none'`)}`,
915
+ ` if: ${mode} != 'none'`,
909
916
  ' run: smoo monorepo validate',
910
917
  '',
911
918
  ' # --- Release ------------------------------------------------------------',
@@ -937,7 +944,8 @@ function renderFinalLinuxPublishSteps(options: PublishWorkflowDefinitionOptions)
937
944
  '',
938
945
  ` # Step ${stepNumber}`,
939
946
  ' - name: ๐Ÿงน Cleanup and cache Nix/devenv',
940
- ` if: ${githubExpression('always()')}`,
947
+ ' # success() is default; always() still saves GH Nix cache after a red job.',
948
+ ' if: always()',
941
949
  ' uses: ./.github/actions/save-nix-devenv',
942
950
  ' with:',
943
951
  ` nix-cache-hit: ${githubExpression('steps.setup.outputs.nix-cache-hit')}`,