@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
@@ -1,44 +1,71 @@
1
1
  name: Setup Nix/devenv
2
- description: Restore caches and build the SmoothBricks devenv shell after checkout.
2
+ description: >
3
+ Build the SmoothBricks devenv shell after checkout. Restores the small devenv/direnv actions/cache segment on every
4
+ runner (eval SQLite + shell scripts). On GARM/NixOS (NIX_REMOTE=daemon + /var/cache/ci) skips Nix install and the
5
+ large store NAR (host store). On ubuntu/macOS installs Nix and restores the NAR/profile cache.
3
6
 
4
7
  outputs:
5
8
  nix-cache-hit:
6
9
  description: Whether the Nix profile/store cache restored from the exact primary key.
7
- value: ${{ steps.nix-cache.outputs.cache-hit }}
10
+ value: ${{ steps.nix-cache.outputs.cache-hit || 'false' }}
8
11
  devenv-cache-hit:
9
12
  description: Whether the devenv/direnv cache restored from the exact primary key.
10
- value: ${{ steps.devenv-cache.outputs.cache-hit }}
13
+ value: ${{ steps.devenv-cache.outputs.cache-hit || 'false' }}
14
+ host-runner:
15
+ description: Whether this job is on a host-nix runner (GARM) with shared store/caches.
16
+ value: ${{ steps.runner-kind.outputs.host }}
11
17
 
12
18
  runs:
13
19
  using: composite
14
20
  steps:
15
21
  # This action is intentionally post-checkout only. Repo-local actions do not
16
22
  # exist before actions/checkout has populated the working tree.
23
+
24
+ # Runner image/profile owns paths (NIX_REMOTE, /var/cache/ci/*). Detect here
25
+ # so every workflow can `uses: setup-devenv` without forking step graphs.
26
+ - name: 🔎 Runner kind
27
+ id: runner-kind
28
+ shell: bash
29
+ run: |
30
+ if [ "${NIX_REMOTE:-}" = daemon ] && [ -d /var/cache/ci ]; then
31
+ echo "host=true" >> "$GITHUB_OUTPUT"
32
+ echo "host-nix runner (shared store + /var/cache/ci)"
33
+ else
34
+ echo "host=false" >> "$GITHUB_OUTPUT"
35
+ echo "ephemeral runner (install Nix + GH caches)"
36
+ fi
37
+
38
+ # Devenv segment (own key): tooling/direnv/.devenv + .direnv — includes
39
+ # nix-eval-cache.db. Independent of the nix NAR; restore on host and ephemeral
40
+ # whether or not the store cache hit. Missing store paths are fine: devenv
41
+ # verifies shell drv/out exist on eval-cache hit and invalidates + re-evals
42
+ # / realizes when GC removed them. Packages still come from host store, NAR,
43
+ # or Cachix — this blob only skips evaluation.
44
+ - name: 🧊 Cache devenv and direnv
45
+ id: devenv-cache
46
+ uses: ./.github/actions/cache-nix-devenv
47
+ with:
48
+ segment: devenv
49
+
17
50
  - name: ❄️ Install Nix
51
+ if: steps.runner-kind.outputs.host != 'true'
18
52
  uses: DeterminateSystems/determinate-nix-action@v3
19
53
 
20
54
  - name: 🧊 Cache Nix profiles and store NAR
21
55
  id: nix-cache
56
+ if: steps.runner-kind.outputs.host != 'true'
22
57
  uses: ./.github/actions/cache-nix-devenv
23
58
  with:
24
59
  segment: nix
25
60
 
26
61
  - name: ♻️ Restore Nix store from NAR
62
+ if: steps.runner-kind.outputs.host != 'true'
27
63
  shell: bash
28
64
  working-directory: tooling/direnv
29
65
  run: ./github-actions-bootstrap.sh restore-store
30
66
 
31
- # Restore direnv state only when the Nix cache key is exact. Partial Nix
32
- # restores can contain a NAR for a different shell closure, and .direnv
33
- # points directly at derivations inside that closure.
34
- - name: 🧊 Cache devenv and direnv
35
- id: devenv-cache
36
- if: ${{ steps.nix-cache.outputs.cache-hit == 'true' }}
37
- uses: ./.github/actions/cache-nix-devenv
38
- with:
39
- segment: devenv
40
-
41
67
  - name: ⚡ Enable Cachix
68
+ if: steps.runner-kind.outputs.host != 'true'
42
69
  uses: cachix/cachix-action@v17
43
70
  with:
44
71
  name: devenv
@@ -48,12 +75,14 @@ runs:
48
75
  working-directory: tooling/direnv
49
76
  run: ./github-actions-bootstrap.sh install-devenv
50
77
 
78
+ # node_modules + ttsc plugin binaries: GH Actions cache on ephemeral runners.
79
+ # Host runners keep those under the /var/cache/ci bind (bun / ttsc env).
51
80
  - name: 📦 Cache node_modules
81
+ if: steps.runner-kind.outputs.host != 'true'
52
82
  uses: ./.github/actions/cache-node-modules
53
83
 
54
- # Restores compiled ttsc native plugins into .cache/ttsc/plugins. TTSC_CACHE_DIR
55
- # is set by devenv enterShell (+ bootstrap GITHUB_ENV for later steps).
56
84
  - name: 🧊 Cache ttsc plugins
85
+ if: steps.runner-kind.outputs.host != 'true'
57
86
  uses: ./.github/actions/cache-ttsc-plugins
58
87
 
59
88
  - name: 🐚 Build devenv shell
@@ -9,10 +9,17 @@
9
9
  # change is upstream code that deserves a human look before it lands. Re-runs
10
10
  # refresh the same branch in place.
11
11
  #
12
- # NOTE: managed files include .github/workflows/*, and the default GITHUB_TOKEN
13
- # is server-side forbidden to push workflow-file changes. Configure a GitHub App
14
- # or PAT token with contents:write + workflows:write as MANAGED_FILES_TOKEN;
15
- # without it, runs that touch workflow files fail at push with a clear error.
12
+ # REQUIRED SECRET: MANAGED_FILES_TOKEN
13
+ # Managed files include .github/workflows/*. The default GITHUB_TOKEN is
14
+ # server-side forbidden to create/update workflow files, even with
15
+ # contents:write. Configure a GitHub App installation token or classic/fine-
16
+ # grained PAT with:
17
+ # - contents: write
18
+ # - pull-requests: write
19
+ # - workflows: write
20
+ # as repository secret MANAGED_FILES_TOKEN. Without it, any update that
21
+ # touches workflow YAML fails at push (this is the usual "managed files
22
+ # workflow isn't doing anything" failure mode).
16
23
  name: Managed files
17
24
 
18
25
  on:
@@ -41,6 +48,7 @@ jobs:
41
48
  filter: blob:none
42
49
  fetch-depth: 0
43
50
  persist-credentials: true
51
+ # Prefer MANAGED_FILES_TOKEN so push can update workflow files.
44
52
  token: ${{ secrets.MANAGED_FILES_TOKEN || github.token }}
45
53
 
46
54
  - name: 🧱 Setup Nix/devenv
@@ -53,27 +61,71 @@ jobs:
53
61
  - name: 📤 Push review branch and open PR
54
62
  env:
55
63
  GH_TOKEN: ${{ secrets.MANAGED_FILES_TOKEN || github.token }}
64
+ # secrets.* is empty when unset — used to fail before a useless push.
65
+ MANAGED_FILES_TOKEN_CONFIGURED: ${{ secrets.MANAGED_FILES_TOKEN != '' }}
56
66
  run: |
67
+ set -euo pipefail
57
68
  cd "$(git rev-parse --show-toplevel)"
69
+
58
70
  if [ -z "$(git status --porcelain)" ]; then
59
71
  echo "Managed files are in sync; nothing to do."
60
72
  exit 0
61
73
  fi
74
+
75
+ echo "Pending managed-file changes:"
76
+ git status --porcelain
77
+
78
+ workflow_changes="$(git status --porcelain -- .github/workflows || true)"
79
+ if [ -n "$workflow_changes" ] && [ "${MANAGED_FILES_TOKEN_CONFIGURED}" != "true" ]; then
80
+ {
81
+ echo "::error title=MANAGED_FILES_TOKEN required::Managed update touches .github/workflows/* but repository secret MANAGED_FILES_TOKEN is not set."
82
+ echo ""
83
+ echo "GitHub refuses pushes that create/update workflow files when the credentials are the default GITHUB_TOKEN / a GitHub App without the workflows permission:"
84
+ echo " refusing to allow a GitHub App to create or update workflow ... without \`workflows\` permission"
85
+ echo ""
86
+ echo "Fix:"
87
+ echo " 1. Create a GitHub App (or PAT) with contents:write, pull-requests:write, workflows:write"
88
+ echo " 2. Install it on this repo"
89
+ echo " 3. Store an installation token or PAT as repository secret MANAGED_FILES_TOKEN"
90
+ echo " 4. Re-run this workflow"
91
+ echo ""
92
+ echo "Workflow files that would be updated:"
93
+ echo "$workflow_changes"
94
+ } >&2
95
+ exit 1
96
+ fi
97
+
62
98
  git config user.name "github-actions[bot]"
63
99
  git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
64
100
  git switch -C chore/managed-files
65
101
  git add -A
66
102
  git commit -m "chore: regenerate smoo-managed files"
67
- git push --force origin chore/managed-files
68
- gh pr create \
69
- --head chore/managed-files \
70
- --base "${GITHUB_REF_NAME}" \
71
- --title "chore: regenerate smoo-managed files" \
72
- --body "Automated refresh of smoo-managed files after drift (usually a @smoothbricks/cli pin bump). One persistent PR; re-runs force-push this branch in place. Review the template changes - do NOT enable auto-merge." \
73
- 2>/dev/null || echo "PR already open; branch refreshed in place."
103
+
104
+ if ! git push --force origin chore/managed-files 2> /tmp/managed-files-push.err; then
105
+ cat /tmp/managed-files-push.err >&2
106
+ if grep -q 'workflows' /tmp/managed-files-push.err; then
107
+ {
108
+ echo ""
109
+ echo "::error title=workflows permission missing::Push rejected because the token cannot update GitHub Actions workflow files."
110
+ echo "Set repository secret MANAGED_FILES_TOKEN to a GitHub App/PAT with contents:write + pull-requests:write + workflows:write, then re-run."
111
+ } >&2
112
+ fi
113
+ exit 1
114
+ fi
115
+
116
+ if ! gh pr view chore/managed-files --json number --jq .number >/dev/null 2>&1; then
117
+ gh pr create \
118
+ --head chore/managed-files \
119
+ --base "${GITHUB_REF_NAME}" \
120
+ --title "chore: regenerate smoo-managed files" \
121
+ --body "Automated refresh of smoo-managed files after drift (usually a \`@smoothbricks/cli\` pin bump). One persistent PR; re-runs force-push this branch in place. Review the template changes — do NOT enable auto-merge."
122
+ else
123
+ echo "PR already open for chore/managed-files; branch refreshed in place."
124
+ fi
74
125
 
75
126
  - name: 🧹 Cleanup and cache Nix/devenv
76
- if: ${{ always() }}
127
+ # success() is default; always() still saves GH Nix cache after a red job.
128
+ if: always()
77
129
  uses: ./.github/actions/save-nix-devenv
78
130
  with:
79
131
  nix-cache-hit: ${{ steps.setup.outputs.nix-cache-hit }}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smoothbricks/cli",
3
- "version": "0.10.4",
3
+ "version": "0.10.6",
4
4
  "type": "module",
5
5
  "description": "SmoothBricks monorepo automation CLI",
6
6
  "bin": {
@@ -64,7 +64,7 @@
64
64
  "dependencies": {
65
65
  "@arethetypeswrong/core": "^0.18.2",
66
66
  "@prettier/sync": "^0.6.1",
67
- "@smoothbricks/nx-plugin": "0.3.3",
67
+ "@smoothbricks/nx-plugin": "0.3.4",
68
68
  "@smoothbricks/validation": "0.1.4",
69
69
  "commander": "^14.0.3",
70
70
  "prettier": "^3.6.1",
package/src/cli.ts CHANGED
@@ -59,18 +59,18 @@ function buildProgram(): Command {
59
59
  );
60
60
  monorepo.command('update').action(async () => {
61
61
  const { updateManagedFiles } = await import('./monorepo/index.js');
62
- updateManagedFiles(await findRepoRoot());
62
+ await updateManagedFiles(await findRepoRoot());
63
63
  });
64
64
  monorepo
65
65
  .command('check')
66
66
  .option('--warn', 'report drift as warnings (GitHub annotations) instead of failing')
67
67
  .action(async (options: { warn?: boolean }) => {
68
68
  const { checkManagedFiles } = await import('./monorepo/index.js');
69
- checkManagedFiles(await findRepoRoot(), { warn: options.warn });
69
+ await checkManagedFiles(await findRepoRoot(), { warn: options.warn });
70
70
  });
71
71
  monorepo.command('diff').action(async () => {
72
72
  const { diffManagedFiles } = await import('./monorepo/index.js');
73
- diffManagedFiles(await findRepoRoot());
73
+ await diffManagedFiles(await findRepoRoot());
74
74
  });
75
75
  monorepo
76
76
  .command('validate-commit-msg <commitMsgFile>')
@@ -84,12 +84,12 @@ function buildProgram(): Command {
84
84
  .option('--stage', 'stage bun.lock when versions were resynced; quiet when clean')
85
85
  .option(
86
86
  '--mode <mode>',
87
- 'install: match package.json (CI); publish: map unpublished -next to last stable tag (pre-pack)',
88
- 'publish',
87
+ 'install: match package.json (default, CI); publish: map unpublished -next to last stable tag (pre-pack only)',
88
+ 'install',
89
89
  )
90
90
  .action(async (options: { stage?: boolean; mode?: 'install' | 'publish' }) => {
91
91
  const { syncBunLockfileVersions } = await import('./monorepo/index.js');
92
- const mode = options.mode === 'install' ? 'install' : 'publish';
92
+ const mode = options.mode === 'publish' ? 'publish' : 'install';
93
93
  syncBunLockfileVersions(await findRepoRoot(), {
94
94
  mode,
95
95
  ...(options.stage ? { log: false, stage: true } : {}),
@@ -38,7 +38,7 @@ describe('GitHub CI Nx target expansion', () => {
38
38
  ['test', ['api', 'desktop']],
39
39
  ]);
40
40
  expect(expanded.runs.map((run) => nxRunManyArgs(run))).toEqual([
41
- ['run-many', '-t', 'test', '--projects=api,desktop', '--parallel=5'],
41
+ ['run-many', '-t', 'test', '--projects=api,desktop', '--parallel=100%'],
42
42
  ]);
43
43
  });
44
44
 
@@ -65,7 +65,7 @@ describe('GitHub CI Nx target expansion', () => {
65
65
 
66
66
  expect(expanded.unmatchedGlobs).toEqual([]);
67
67
  expect(expanded.runs.map((run) => nxRunManyArgs(run))).toEqual([
68
- ['run-many', '-t', 'missing', '--projects=api', '--parallel=5'],
68
+ ['run-many', '-t', 'missing', '--projects=api', '--parallel=100%'],
69
69
  ]);
70
70
  });
71
71
 
@@ -114,7 +114,7 @@ describe('GitHub CI Nx target expansion', () => {
114
114
  '-t',
115
115
  'test',
116
116
  '--exclude=tag:ci:skip:test',
117
- '--parallel=5',
117
+ '--parallel=100%',
118
118
  ]);
119
119
  expect(nxRunManyArgs({ target: 'test', projects: projects.slice(0, 1) })).not.toContain(
120
120
  '--exclude=tag:ci:skip:test',
@@ -27,6 +27,9 @@ const parseNxProjectDeployConfigurations = typia.json.createIsParse<NxProjectDep
27
27
 
28
28
  type NxSmartMode = 'auto' | 'affected' | 'run-many';
29
29
 
30
+ /** Nx workers = host cores (CI runners sized for full machine use). */
31
+ const NX_PARALLEL = '100%';
32
+
30
33
  export async function cleanupGithubCiCache(root: string): Promise<void> {
31
34
  const githubOutput = process.env.GITHUB_OUTPUT;
32
35
  const markCacheReady = async (ready: boolean): Promise<void> => {
@@ -139,7 +142,7 @@ export function nxSmartArgs(target: string, mode: 'affected' | 'run-many', confi
139
142
  if (configuration) {
140
143
  args.push(`--configuration=${configuration}`);
141
144
  }
142
- args.push(`--exclude=tag:ci:skip:${target}`, '--parallel=5');
145
+ args.push(`--exclude=tag:ci:skip:${target}`, `--parallel=${NX_PARALLEL}`);
143
146
  return args;
144
147
  }
145
148
 
@@ -262,7 +265,7 @@ export function nxRunManyArgs(run: NxTargetRun, configuration?: string): string[
262
265
  if (configuration) {
263
266
  nxArgs.push(`--configuration=${configuration}`);
264
267
  }
265
- nxArgs.push('--parallel=5');
268
+ nxArgs.push(`--parallel=${NX_PARALLEL}`);
266
269
  return nxArgs;
267
270
  }
268
271
 
@@ -343,7 +346,7 @@ export async function githubCiNxDeploy(
343
346
  const projectList = projects.join(',');
344
347
  const targets = options.verify === true ? ['build', 'lint', 'test', 'deploy'] : ['deploy'];
345
348
  for (const target of targets) {
346
- const nxArgs = ['run-many', '-t', target, `--projects=${projectList}`, '--parallel=5'];
349
+ const nxArgs = ['run-many', '-t', target, `--projects=${projectList}`, `--parallel=${NX_PARALLEL}`];
347
350
  if (target === 'deploy') {
348
351
  nxArgs.push(`--configuration=${options.configuration}`);
349
352
  }
package/src/lib/json.ts CHANGED
@@ -23,6 +23,8 @@ export interface PackagePublishConfig {
23
23
 
24
24
  export interface PackageSmooGithub {
25
25
  pushBranches?: string[];
26
+ /** GitHub Actions runs-on for managed CI (string or label list). Default: ubuntu-latest. */
27
+ runsOn?: string | string[];
26
28
  }
27
29
 
28
30
  export interface PackageSmooConfig {
@@ -52,6 +54,8 @@ export interface PackageJson {
52
54
  devDependencies?: StringMap;
53
55
  peerDependencies?: StringMap;
54
56
  optionalDependencies?: StringMap;
57
+ /** Bun patchedDependencies map (package@version → patch path). */
58
+ patchedDependencies?: StringMap;
55
59
  engines?: StringMap;
56
60
  scripts?: StringMap;
57
61
  publishConfig?: PackagePublishConfig;
@@ -165,7 +169,7 @@ export function ensureScripts(pkg: PackageJson): StringMap {
165
169
  /** Ensure package.json.dependencies (or other string-map dep field) exists. */
166
170
  export function ensureDependencyMap(
167
171
  pkg: PackageJson,
168
- key: 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies',
172
+ key: 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies' | 'patchedDependencies',
169
173
  ): StringMap {
170
174
  const current = pkg[key];
171
175
  if (isStringMap(current)) {
@@ -5,9 +5,15 @@ import { readFile } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
  import { CiWorkflowStepKind, defineCiWorkflow, renderCiWorkflowYaml } from '../ci-workflow.js';
7
7
 
8
+ const nixosRunsOn = ['nixos-latest-x64', 'self-hosted'] as const;
9
+
8
10
  describe('CI workflow definition', () => {
9
11
  it('renders the checked-in local CI workflow copy', async () => {
10
- const rendered = renderCiWorkflowYaml({ deploy: false, pushBranches: ['main'] });
12
+ const rendered = renderCiWorkflowYaml({
13
+ deploy: false,
14
+ pushBranches: ['main'],
15
+ runsOn: [...nixosRunsOn],
16
+ });
11
17
  const packageRoot = join(import.meta.dir, '..', '..', '..');
12
18
 
13
19
  await expect(readFile(join(packageRoot, '..', '..', '.github/workflows/ci.yml'), 'utf8')).resolves.toBe(rendered);
@@ -25,6 +31,13 @@ describe('CI workflow definition', () => {
25
31
  'smoo github-ci nx-deploy --configuration staging --mode affected --name "Deploy Staging" --step 11',
26
32
  );
27
33
  expect(rendered).toContain("# Step 12\n # Nx's database cache needs artifact files");
34
+ expect(rendered).toContain('uses: ./.github/actions/setup-devenv');
35
+ expect(rendered).toContain('id: setup');
36
+ expect(rendered).not.toContain('Setup Nix/devenv (fork)');
37
+ expect(rendered).not.toContain('Setup Nix/devenv (nixos)');
38
+ expect(rendered).not.toContain('github-actions-bootstrap.sh');
39
+ expect(rendered).toContain('uses: ./.github/actions/save-nix-devenv');
40
+ expect(rendered).toContain('runs-on: ubuntu-latest');
28
41
  });
29
42
 
30
43
  it('adds Cloudflare credentials for Wrangler-backed deploys', () => {
@@ -44,4 +57,27 @@ describe('CI workflow definition', () => {
44
57
  expect(restoreKey).toBe('${{ runner.os }}-${{ runner.arch }}-nx-db-v1-${{ github.sha }}');
45
58
  expect(saveKey).toBe(restoreKey);
46
59
  });
60
+
61
+ it('nixos config: fork PRs on ubuntu; one setup-devenv (composite owns host path)', () => {
62
+ const rendered = renderCiWorkflowYaml({
63
+ deploy: false,
64
+ pushBranches: ['main'],
65
+ runsOn: [...nixosRunsOn],
66
+ });
67
+
68
+ expect(rendered).toContain(
69
+ "runs-on:\n ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&\n fromJSON('[\"nixos-latest-x64\",\"self-hosted\"]') || 'ubuntu-latest' }}",
70
+ );
71
+ expect(rendered).toContain('uses: ./.github/actions/setup-devenv');
72
+ expect(rendered).toContain('id: setup');
73
+ expect(rendered).not.toContain('Setup Nix/devenv (fork)');
74
+ expect(rendered).not.toContain('Setup Nix/devenv (nixos)');
75
+ expect(rendered).not.toContain('github-actions-bootstrap.sh');
76
+ expect(rendered).not.toContain('determinate-nix-action');
77
+ expect(rendered).toContain('if: always()');
78
+ expect(rendered).toContain('uses: ./.github/actions/save-nix-devenv');
79
+ // workflow yaml does not embed composite internals
80
+ expect(rendered).not.toContain('cache-node-modules');
81
+ expect(rendered).not.toContain('cache-ttsc-plugins');
82
+ });
47
83
  });
@@ -19,11 +19,14 @@ import {
19
19
  runPublishWorkflow,
20
20
  } from '../publish-workflow.js';
21
21
 
22
+ const nixosRunsOn = ['nixos-latest-x64', 'self-hosted'] as const;
23
+
22
24
  describe('publish workflow definition', () => {
23
25
  it('renders the checked-in local publish workflow copy', async () => {
24
26
  const rendered = renderPublishWorkflowYaml({
25
27
  repoName: '@smoothbricks/codebase',
26
28
  platformTargetGlobs: PLATFORM_TARGET_GLOBS,
29
+ runsOn: [...nixosRunsOn],
27
30
  });
28
31
  const packageRoot = join(import.meta.dir, '..', '..', '..');
29
32
  await expect(readFile(join(packageRoot, '..', '..', '.github/workflows/publish.yml'), 'utf8')).resolves.toBe(
@@ -35,6 +38,7 @@ describe('publish workflow definition', () => {
35
38
  const rendered = renderPublishWorkflowYaml({
36
39
  repoName: '@smoothbricks/codebase',
37
40
  platformTargetGlobs: PLATFORM_TARGET_GLOBS,
41
+ runsOn: [...nixosRunsOn],
38
42
  });
39
43
 
40
44
  await expect(
@@ -125,6 +129,7 @@ describe('publish workflow definition', () => {
125
129
  const native = renderPublishWorkflowYaml({
126
130
  repoName: '@smoothbricks/codebase',
127
131
  platformTargetGlobs: PLATFORM_TARGET_GLOBS,
132
+ runsOn: [...nixosRunsOn],
128
133
  });
129
134
  const linuxCandidate = native.slice(
130
135
  native.indexOf(' linux-release-candidate:'),
@@ -137,8 +142,9 @@ describe('publish workflow definition', () => {
137
142
  expect(linuxOnly).toContain(
138
143
  `smoo github-ci nx-run-many --targets "${LINUX_PLATFORM_TARGET_GLOBS.join(',')}" --projects`,
139
144
  );
140
- expect(native).toContain(' linux-release-candidate:\n runs-on: ubuntu-latest');
145
+ expect(native).toContain(' linux-release-candidate:');
141
146
  expect(native).toContain(' macos-platform:\n runs-on: macos-latest');
147
+ expect(native).toContain(' publish-on-linux:');
142
148
  expect(native).toContain(' publish-on-linux:\n needs: [linux-release-candidate, macos-platform]');
143
149
  expect(linuxCandidate).not.toContain('needs:');
144
150
  expect(macosPlatform).not.toContain('needs:');
@@ -612,3 +618,24 @@ function packageNameFromTag(tag: string): string {
612
618
  }
613
619
  return tag.slice(0, versionSeparator);
614
620
  }
621
+
622
+ it('linux publish jobs use smoo.github.runsOn; macOS stays macos-latest', () => {
623
+ const rendered = renderPublishWorkflowYaml({
624
+ repoName: '@smoothbricks/codebase',
625
+ platformTargetGlobs: PLATFORM_TARGET_GLOBS,
626
+ runsOn: [...nixosRunsOn],
627
+ });
628
+ expect(rendered).toContain(
629
+ `runs-on:
630
+ \${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
631
+ fromJSON('["nixos-latest-x64","self-hosted"]') || 'ubuntu-latest' }}`,
632
+ );
633
+ expect(rendered).toContain(' macos-platform:\n runs-on: macos-latest');
634
+ // Only the pre-publish linux job runs on self-hosted
635
+ expect(rendered.split('fromJSON(\'["nixos-latest-x64","self-hosted"]\')').length - 1).toBe(1);
636
+ expect(rendered).not.toContain(' linux-release-candidate:\n runs-on: ubuntu-latest');
637
+ // NPM publishing happens from GitHub runner
638
+ expect(rendered).toContain(
639
+ ' publish-on-linux:\n needs: [linux-release-candidate, macos-platform]\n runs-on: ubuntu-latest',
640
+ );
641
+ });
@@ -1,5 +1,7 @@
1
1
  /* biome-ignore-all lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions are emitted literally. */
2
2
 
3
+ import { renderRunsOnLine } from './github-runs-on.js';
4
+
3
5
  export enum CiWorkflowStepKind {
4
6
  Checkout = 'checkout',
5
7
  SetupDevenv = 'setup-devenv',
@@ -26,6 +28,8 @@ export interface CiWorkflowDefinitionOptions {
26
28
  deploy: boolean;
27
29
  deployProvider?: 'cloudflare';
28
30
  pushBranches: string[];
31
+ /** Default ubuntu-latest when omitted. */
32
+ runsOn?: string | string[];
29
33
  }
30
34
 
31
35
  type CiWorkflowStepInput = Omit<CiWorkflowStep, 'number'>;
@@ -80,7 +84,7 @@ defaults:
80
84
  jobs:
81
85
  main:
82
86
  name: Validate
83
- runs-on: ubuntu-latest
87
+ ${renderRunsOnLine(options.runsOn)}
84
88
  timeout-minutes: 45
85
89
  env:
86
90
  NIX_STORE_NAR: ${githubExpression('github.workspace')}/nix-store.nar
@@ -150,7 +154,10 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
150
154
  ' fetch-depth: 0',
151
155
  ];
152
156
  case CiWorkflowStepKind.SetupDevenv:
157
+ // One step everywhere. setup-devenv detects host-nix (GARM) vs ephemeral
158
+ // and skips GH install/cache steps internally — same for ci/publish/managed.
153
159
  return [` - name: ${step.name}`, ' id: setup', ' uses: ./.github/actions/setup-devenv'];
160
+
154
161
  case CiWorkflowStepKind.SetNxShas:
155
162
  return [
156
163
  ` - name: ${step.name}`,
@@ -184,7 +191,7 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
184
191
  // recursion guard, so the default token suffices.
185
192
  return [
186
193
  ` - name: ${step.name}`,
187
- " if: ${{ steps.managed-drift.outputs.drifted != '' && steps.managed-drift.outputs.drifted != '0' }}",
194
+ " if: steps.managed-drift.outputs.drifted != '' && steps.managed-drift.outputs.drifted != '0'",
188
195
  ' run: gh workflow run managed-files.yml --ref "$GITHUB_REF_NAME"',
189
196
  ];
190
197
  case CiWorkflowStepKind.Deploy:
@@ -212,7 +219,8 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
212
219
  case CiWorkflowStepKind.UploadTraceDbs:
213
220
  return [
214
221
  ` - name: ${step.name}`,
215
- ' if: ${{ always() }}',
222
+ ' # success() is the default; always() keeps traces after a red build/test.',
223
+ ' if: always()',
216
224
  ' uses: actions/upload-artifact@v7.0.1',
217
225
  ' with:',
218
226
  ' name: trace-results-${{ github.run_id }}',
@@ -224,7 +232,9 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
224
232
  case CiWorkflowStepKind.SaveNixDevenv:
225
233
  return [
226
234
  ` - name: ${step.name}`,
227
- ' if: ${{ always() }}',
235
+ ' # always() still saves after a red job. Nix NAR is ephemeral-only;',
236
+ ' # devenv eval-cache also saves on host-nix when setup missed.',
237
+ ' if: always()',
228
238
  ' uses: ./.github/actions/save-nix-devenv',
229
239
  ' with:',
230
240
  ' nix-cache-hit: ${{ steps.setup.outputs.nix-cache-hit }}',
@@ -0,0 +1,45 @@
1
+ /** Shared `runs-on:` emission for generated GitHub workflow YAML. */
2
+
3
+ export type WorkflowRunsOn = string | string[] | undefined;
4
+
5
+ export function isNixosRunner(runsOn: WorkflowRunsOn): boolean {
6
+ const labels = runsOn === undefined ? [] : typeof runsOn === 'string' ? [runsOn] : runsOn;
7
+ return labels.some((label) => label === 'nixos' || label.startsWith('nixos-'));
8
+ }
9
+
10
+ /**
11
+ * Trusted jobs use configured nixos self-hosted labels; fork PRs stay on
12
+ * ubuntu-latest (no access to the private runner fleet).
13
+ */
14
+ export function githubUsesNixosRunnerExpr(): string {
15
+ return "(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)";
16
+ }
17
+
18
+ /**
19
+ * Full ` runs-on: …` line(s), indented for a job under `jobs.<id>:`.
20
+ * Nixos labels emit the multiline prettier-stable fork-gate expression.
21
+ */
22
+ export function renderRunsOnLine(runsOn: WorkflowRunsOn): string {
23
+ if (!isNixosRunner(runsOn)) {
24
+ let value: string;
25
+ if (runsOn === undefined) {
26
+ value = 'ubuntu-latest';
27
+ } else if (typeof runsOn === 'string') {
28
+ value = runsOn.length > 0 ? runsOn : 'ubuntu-latest';
29
+ } else if (runsOn.length === 0) {
30
+ value = 'ubuntu-latest';
31
+ } else if (runsOn.length === 1) {
32
+ value = runsOn[0] ?? 'ubuntu-latest';
33
+ } else {
34
+ value = `[${runsOn.map((label) => `'${label}'`).join(', ')}]`;
35
+ }
36
+ return ` runs-on: ${value}`;
37
+ }
38
+
39
+ const labels = typeof runsOn === 'string' ? [runsOn] : runsOn;
40
+ // Multiline matches prettier so checked-in workflows stay byte-identical.
41
+ const nixosJson = JSON.stringify(labels);
42
+ return ` runs-on:
43
+ \${{ ${githubUsesNixosRunnerExpr()} &&
44
+ fromJSON('${nixosJson}') || 'ubuntu-latest' }}`;
45
+ }
@@ -9,6 +9,7 @@ import { applyManagedFiles, printResults, warnOnManagedFileDrift } from './manag
9
9
  import { listValidCommitScopes, validatePublicTags } from './package-policy.js';
10
10
  import { runInitPacks, runValidatePacks } from './packs/index.js';
11
11
  import { syncRootRuntimeVersions } from './runtime.js';
12
+ import { applyToolConfigDefaults } from './tool-validation.js';
12
13
 
13
14
  export interface InitOptions {
14
15
  runtimeOnly?: boolean;
@@ -50,7 +51,7 @@ export async function initMonorepo(root: string, options: InitOptions): Promise<
50
51
  return;
51
52
  }
52
53
 
53
- printResults(applyManagedFiles(root, 'update'));
54
+ printResults(await applyManagedFiles(root, 'update'));
54
55
  await runInitPacks({ root, syncRuntime: process.env.DEVENV_ROOT !== undefined || options.syncRuntime === true });
55
56
  }
56
57
 
@@ -74,24 +75,35 @@ export async function validateMonorepo(root: string, options: ValidateOptions =
74
75
  }
75
76
  }
76
77
 
77
- export function updateManagedFiles(root: string): void {
78
- printResults(applyManagedFiles(root, 'update'));
78
+ export async function updateManagedFiles(root: string): Promise<void> {
79
+ printResults(await applyManagedFiles(root, 'update'));
80
+ // Tool dependency policy (typescript API 6, @typescript/native for ttsc, nx, …)
81
+ // lives next to managed templates — update must install them, not only rewrite files.
82
+ await applyToolConfigDefaults(root);
83
+ syncBunLockfileVersions(root, { mode: 'install' });
84
+ console.log('installing workspace dependencies (bun install)');
85
+ const install = await $`bun install --no-summary`.cwd(root).nothrow();
86
+ if (install.exitCode !== 0) {
87
+ const stderr = decode(install.stderr).trim();
88
+ const stdout = decode(install.stdout).trim();
89
+ throw new Error(`bun install failed after monorepo update${stderr || stdout ? `:\n${stderr || stdout}` : ''}`);
90
+ }
79
91
  }
80
92
 
81
- export function checkManagedFiles(root: string, options: { warn?: boolean } = {}): void {
93
+ export async function checkManagedFiles(root: string, options: { warn?: boolean } = {}): Promise<void> {
82
94
  if (options.warn === true) {
83
- warnOnManagedFileDrift(root);
95
+ await warnOnManagedFileDrift(root);
84
96
  return;
85
97
  }
86
- const results = applyManagedFiles(root, 'check');
98
+ const results = await applyManagedFiles(root, 'check');
87
99
  printResults(results);
88
100
  if (results.some((result) => result.action === 'drifted')) {
89
101
  throw new Error('Managed monorepo files are out of date. Run: smoo monorepo update');
90
102
  }
91
103
  }
92
104
 
93
- export function diffManagedFiles(root: string): void {
94
- printResults(applyManagedFiles(root, 'diff'));
105
+ export async function diffManagedFiles(root: string): Promise<void> {
106
+ printResults(await applyManagedFiles(root, 'diff'));
95
107
  }
96
108
 
97
109
  export function validateCommitMessageFile(