@smoothbricks/cli 0.10.5 โ†’ 0.10.7

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 (65) hide show
  1. package/dist/cli.js +3 -2
  2. package/dist/github-ci/index.d.ts +1 -0
  3. package/dist/github-ci/index.d.ts.map +1 -1
  4. package/dist/github-ci/index.js +28 -5
  5. package/dist/lib/json.d.ts +3 -1
  6. package/dist/lib/json.d.ts.map +1 -1
  7. package/dist/lib/json.js +5 -20
  8. package/dist/lib/workspace.d.ts.map +1 -1
  9. package/dist/lib/workspace.js +1 -0
  10. package/dist/monorepo/ci-workflow.d.ts +2 -0
  11. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  12. package/dist/monorepo/ci-workflow.js +10 -4
  13. package/dist/monorepo/github-runs-on.d.ts +14 -0
  14. package/dist/monorepo/github-runs-on.d.ts.map +1 -0
  15. package/dist/monorepo/github-runs-on.js +43 -0
  16. package/dist/monorepo/index.d.ts +2 -2
  17. package/dist/monorepo/index.d.ts.map +1 -1
  18. package/dist/monorepo/index.js +7 -7
  19. package/dist/monorepo/managed-files.d.ts +14 -3
  20. package/dist/monorepo/managed-files.d.ts.map +1 -1
  21. package/dist/monorepo/managed-files.js +54 -74
  22. package/dist/monorepo/package-policy.js +2 -2
  23. package/dist/monorepo/packs/index.d.ts.map +1 -1
  24. package/dist/monorepo/packs/index.js +3 -1
  25. package/dist/monorepo/publish-workflow.d.ts +3 -0
  26. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  27. package/dist/monorepo/publish-workflow.js +17 -14
  28. package/dist/monorepo/runtime.d.ts +2 -2
  29. package/dist/monorepo/runtime.d.ts.map +1 -1
  30. package/dist/monorepo/runtime.js +11 -16
  31. package/dist/monorepo/tool-validation.d.ts.map +1 -1
  32. package/dist/monorepo/tool-validation.js +20 -23
  33. package/dist/nx/index.d.ts +7 -4
  34. package/dist/nx/index.d.ts.map +1 -1
  35. package/dist/nx/index.js +117 -56
  36. package/managed/raw/tooling/devenv +71 -0
  37. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +24 -7
  38. package/managed/templates/github/actions/cache-nix-devenv/action.yml +13 -11
  39. package/managed/templates/github/actions/save-nix-devenv/action.yml +34 -3
  40. package/managed/templates/github/actions/setup-devenv/action.yml +44 -15
  41. package/managed/templates/github/workflows/managed-files.yml +64 -12
  42. package/package.json +4 -4
  43. package/src/cli.ts +10 -3
  44. package/src/github-ci/index.test.ts +53 -4
  45. package/src/github-ci/index.ts +37 -5
  46. package/src/lib/json.ts +3 -1
  47. package/src/lib/workspace.ts +1 -0
  48. package/src/monorepo/__tests__/ci-workflow.test.ts +37 -1
  49. package/src/monorepo/__tests__/publish-workflow.test.ts +39 -4
  50. package/src/monorepo/ci-workflow.ts +14 -4
  51. package/src/monorepo/github-runs-on.ts +45 -0
  52. package/src/monorepo/index.ts +7 -7
  53. package/src/monorepo/managed-files.test.ts +58 -0
  54. package/src/monorepo/managed-files.ts +59 -76
  55. package/src/monorepo/package-policy.test.ts +31 -0
  56. package/src/monorepo/package-policy.ts +2 -2
  57. package/src/monorepo/packs/index.ts +3 -1
  58. package/src/monorepo/publish-workflow.ts +28 -15
  59. package/src/monorepo/runtime.test.ts +23 -17
  60. package/src/monorepo/runtime.ts +12 -17
  61. package/src/monorepo/tool-validation.test.ts +15 -8
  62. package/src/monorepo/tool-validation.ts +20 -25
  63. package/src/nx/index.test.ts +45 -14
  64. package/src/nx/index.ts +133 -64
  65. package/managed/raw/patches/ttsc@0.19.3.patch +0 -67
@@ -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.5",
3
+ "version": "0.10.7",
4
4
  "type": "module",
5
5
  "description": "SmoothBricks monorepo automation CLI",
6
6
  "bin": {
@@ -64,8 +64,8 @@
64
64
  "dependencies": {
65
65
  "@arethetypeswrong/core": "^0.18.2",
66
66
  "@prettier/sync": "^0.6.1",
67
- "@smoothbricks/nx-plugin": "0.3.4",
68
- "@smoothbricks/validation": "0.1.4",
67
+ "@smoothbricks/nx-plugin": "0.3.5",
68
+ "@smoothbricks/validation": "0.1.5",
69
69
  "commander": "^14.0.3",
70
70
  "prettier": "^3.6.1",
71
71
  "publint": "^0.3.18",
@@ -73,7 +73,7 @@
73
73
  "sherif": "^1.11.1",
74
74
  "toml-eslint-parser": "^1.0.3",
75
75
  "tslib": "^2.8.1",
76
- "typia": "13.1.19"
76
+ "typia": "13.2.0"
77
77
  },
78
78
  "devDependencies": {
79
79
  "fast-check": "^4.4.0"
package/src/cli.ts CHANGED
@@ -66,11 +66,11 @@ function buildProgram(): Command {
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>')
@@ -348,10 +348,17 @@ function buildProgram(): Command {
348
348
  .command('nx-run-many')
349
349
  .requiredOption('--targets <targets>')
350
350
  .option('--projects <projects>')
351
+ .option('--projects-with-targets <targets>', 'select projects owning any comma-separated target or target glob')
351
352
  .option('--configuration <configuration>')
352
353
  .option('--collect-outputs <directory>')
353
354
  .action(
354
- async (options: { targets: string; projects?: string; configuration?: string; collectOutputs?: string }) => {
355
+ async (options: {
356
+ targets: string;
357
+ projects?: string;
358
+ projectsWithTargets?: string;
359
+ configuration?: string;
360
+ collectOutputs?: string;
361
+ }) => {
355
362
  const { githubCiNxRunMany } = await import('./github-ci/index.js');
356
363
  await githubCiNxRunMany(await findRepoRoot(), options);
357
364
  },
@@ -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
 
@@ -53,6 +53,28 @@ describe('GitHub CI Nx target expansion', () => {
53
53
  ]);
54
54
  });
55
55
 
56
+ it('selects test owners from target globs and fails if any selected project lacks test', () => {
57
+ const platformProjects = projects.map((project) =>
58
+ project.project === 'mobile' ? { ...project, targets: [...project.targets, 'test'] } : project,
59
+ );
60
+ const expanded = expandNxTargetRuns(platformProjects, {
61
+ targets: 'test',
62
+ projectsWithTargets: '*-macos,*-ios',
63
+ });
64
+
65
+ expect(expanded.runs.map((run) => [run.target, run.projects.map((project) => project.project)])).toEqual([
66
+ ['test', ['desktop', 'mobile']],
67
+ ]);
68
+ const [testRun] = expanded.runs;
69
+ if (!testRun) {
70
+ throw new Error('Expected a test target run.');
71
+ }
72
+ expect(nxRunManyArgs(testRun)).toEqual(['run-many', '-t', 'test', '--projects=desktop,mobile', '--parallel=100%']);
73
+ expect(() => expandNxTargetRuns(projects, { targets: 'test', projectsWithTargets: '*-macos,*-ios' })).toThrow(
74
+ 'Nx target test is missing for project(s) selected by --projects-with-targets *-macos,*-ios: mobile.',
75
+ );
76
+ });
77
+
56
78
  it('reports a zero-match glob as an empty run set', () => {
57
79
  expect(expandNxTargetRuns(projects, { targets: '*-windows' })).toEqual({
58
80
  runs: [],
@@ -65,7 +87,7 @@ describe('GitHub CI Nx target expansion', () => {
65
87
 
66
88
  expect(expanded.unmatchedGlobs).toEqual([]);
67
89
  expect(expanded.runs.map((run) => nxRunManyArgs(run))).toEqual([
68
- ['run-many', '-t', 'missing', '--projects=api', '--parallel=5'],
90
+ ['run-many', '-t', 'missing', '--projects=api', '--parallel=100%'],
69
91
  ]);
70
92
  });
71
93
 
@@ -114,7 +136,7 @@ describe('GitHub CI Nx target expansion', () => {
114
136
  '-t',
115
137
  'test',
116
138
  '--exclude=tag:ci:skip:test',
117
- '--parallel=5',
139
+ '--parallel=100%',
118
140
  ]);
119
141
  expect(nxRunManyArgs({ target: 'test', projects: projects.slice(0, 1) })).not.toContain(
120
142
  '--exclude=tag:ci:skip:test',
@@ -155,6 +177,21 @@ describe('collected Nx outputs', () => {
155
177
  });
156
178
  });
157
179
 
180
+ it('runs test for projects selected by platform target ownership', async () => {
181
+ await withNxRunManyFixture(async ({ root }) => {
182
+ await writeFile(join(root, 'packages/app/test-target.ts'), "await Bun.write('test-ran.txt', 'tested');\n");
183
+ await writeFile(join(root, 'packages/app/build-target.ts'), "throw new Error('build must not run here');\n");
184
+
185
+ await githubCiNxRunMany(root, {
186
+ targets: 'test',
187
+ projectsWithTargets: '*-macos,*-ios',
188
+ });
189
+
190
+ await expect(readFile(join(root, 'test-ran.txt'), 'utf8')).resolves.toBe('tested');
191
+ await expect(readFile(join(root, 'build-ran.txt'), 'utf8')).rejects.toThrow();
192
+ });
193
+ });
194
+
158
195
  it('applies an empty manifest after artifact transport omits its empty workspace directory', async () => {
159
196
  await withOutputFixture(async ({ root, artifact }) => {
160
197
  const manifest = await collectNxOutputs(root, artifact, [], SOURCE_SHA);
@@ -472,7 +509,19 @@ async function withNxRunManyFixture(
472
509
  join(root, 'packages/app/package.json'),
473
510
  JSON.stringify({
474
511
  name: '@fixture/app',
475
- nx: { name: 'app', targets: { build: { executor: 'nx:noop' } } },
512
+ nx: {
513
+ name: 'app',
514
+ targets: {
515
+ 'build-macos': {
516
+ executor: 'nx:run-commands',
517
+ options: { command: 'bun packages/app/build-target.ts', cwd: '.' },
518
+ },
519
+ test: {
520
+ executor: 'nx:run-commands',
521
+ options: { command: 'bun packages/app/test-target.ts', cwd: '.' },
522
+ },
523
+ },
524
+ },
476
525
  }),
477
526
  );
478
527
  await $`git init --quiet`.cwd(root);
@@ -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
 
@@ -165,6 +168,7 @@ export interface NxRunManyOptions {
165
168
  configuration?: string;
166
169
  collectOutputs?: string;
167
170
  allowEmptyProjects?: boolean;
171
+ projectsWithTargets?: string;
168
172
  }
169
173
 
170
174
  export interface ExpandedNxTargetRuns {
@@ -173,10 +177,18 @@ export interface ExpandedNxTargetRuns {
173
177
  }
174
178
 
175
179
  export function expandNxTargetRuns(projects: ProjectTargets[], options: NxRunManyOptions): ExpandedNxTargetRuns {
176
- const selectedProjects = selectProjects(projects, options.projects);
177
- if (options.projects !== undefined && selectedProjects.length === 0 && options.allowEmptyProjects !== true) {
180
+ const namedProjects = selectProjects(projects, options.projects);
181
+ if (options.projects !== undefined && namedProjects.length === 0 && options.allowEmptyProjects !== true) {
178
182
  throw new Error(`No Nx projects matched --projects ${options.projects}.`);
179
183
  }
184
+ const selectedProjects = selectProjectsWithTargets(namedProjects, options.projectsWithTargets);
185
+ if (
186
+ options.projectsWithTargets !== undefined &&
187
+ selectedProjects.length === 0 &&
188
+ options.allowEmptyProjects !== true
189
+ ) {
190
+ throw new Error(`No Nx projects own targets matching --projects-with-targets ${options.projectsWithTargets}.`);
191
+ }
180
192
  const selectedTargetNames = [...new Set(selectedProjects.flatMap((project) => project.targets))].sort((a, b) =>
181
193
  a.localeCompare(b),
182
194
  );
@@ -192,6 +204,16 @@ export function expandNxTargetRuns(projects: ProjectTargets[], options: NxRunMan
192
204
  unmatchedGlobs.push(targetPattern);
193
205
  }
194
206
  for (const target of targets) {
207
+ if (!isGlob && options.projectsWithTargets !== undefined) {
208
+ const missingProjects = selectedProjects
209
+ .filter((project) => !project.targets.includes(target))
210
+ .map((project) => project.project);
211
+ if (missingProjects.length > 0) {
212
+ throw new Error(
213
+ `Nx target ${target} is missing for project(s) selected by --projects-with-targets ${options.projectsWithTargets}: ${missingProjects.join(', ')}.`,
214
+ );
215
+ }
216
+ }
195
217
  if (addedTargets.has(target)) {
196
218
  continue;
197
219
  }
@@ -262,7 +284,7 @@ export function nxRunManyArgs(run: NxTargetRun, configuration?: string): string[
262
284
  if (configuration) {
263
285
  nxArgs.push(`--configuration=${configuration}`);
264
286
  }
265
- nxArgs.push('--parallel=5');
287
+ nxArgs.push(`--parallel=${NX_PARALLEL}`);
266
288
  return nxArgs;
267
289
  }
268
290
 
@@ -318,6 +340,16 @@ function selectProjects(projects: ProjectTargets[], selectors: string | undefine
318
340
  .sort((left, right) => left.project.localeCompare(right.project));
319
341
  }
320
342
 
343
+ function selectProjectsWithTargets(projects: ProjectTargets[], selectors: string | undefined): ProjectTargets[] {
344
+ if (selectors === undefined) {
345
+ return projects;
346
+ }
347
+ const patterns = commaSeparatedValues(selectors);
348
+ return projects.filter((project) =>
349
+ project.targets.some((target) => patterns.some((pattern) => new Bun.Glob(pattern).match(target))),
350
+ );
351
+ }
352
+
321
353
  function commaSeparatedValues(value: string): string[] {
322
354
  return value
323
355
  .split(',')
@@ -343,7 +375,7 @@ export async function githubCiNxDeploy(
343
375
  const projectList = projects.join(',');
344
376
  const targets = options.verify === true ? ['build', 'lint', 'test', 'deploy'] : ['deploy'];
345
377
  for (const target of targets) {
346
- const nxArgs = ['run-many', '-t', target, `--projects=${projectList}`, '--parallel=5'];
378
+ const nxArgs = ['run-many', '-t', target, `--projects=${projectList}`, `--parallel=${NX_PARALLEL}`];
347
379
  if (target === 'deploy') {
348
380
  nxArgs.push(`--configuration=${options.configuration}`);
349
381
  }
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 {
@@ -78,7 +80,7 @@ export type NxDependsOn = string | NxDependsOnObject;
78
80
  /** Nx target options are executor-specific open bags. */
79
81
  export type NxTargetOptions = Record<string, unknown>;
80
82
 
81
- export type NxInput = string | Record<string, unknown>;
83
+ export type NxInput = string | object;
82
84
 
83
85
  export interface NxTargetConfig {
84
86
  executor?: string;
@@ -129,6 +129,7 @@ function listWorkspacePackageJsonPaths(root: string, workspacePatterns: string[]
129
129
  const paths: string[] = [];
130
130
  for (const pattern of workspacePatterns) {
131
131
  if (!pattern.endsWith('/*')) {
132
+ paths.push(join(root, pattern, 'package.json'));
132
133
  continue;
133
134
  }
134
135
  const parent = join(root, pattern.slice(0, -2));
@@ -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(
@@ -95,7 +99,7 @@ describe('publish workflow definition', () => {
95
99
 
96
100
  expect(stepAnchorNumbers(singleJob)).toEqual(Array.from({ length: 15 }, (_, index) => index + 1));
97
101
  expect(stepAnchorNumbers(linuxCandidate)).toEqual(Array.from({ length: 19 }, (_, index) => index + 1));
98
- expect(stepAnchorNumbers(macosPlatform)).toEqual(Array.from({ length: 7 }, (_, index) => index + 1));
102
+ expect(stepAnchorNumbers(macosPlatform)).toEqual(Array.from({ length: 8 }, (_, index) => index + 1));
99
103
  expect(stepAnchorNumbers(finalJob)).toEqual(Array.from({ length: 14 }, (_, index) => index + 1));
100
104
  expect(singleJob).toContain('# Step 14\n - name: ๐Ÿ“ฆ Publish release (${{ steps.version.outputs.mode }})');
101
105
  expect(singleJob).toContain('# Step 15\n - name: ๐Ÿงน Cleanup and cache Nix/devenv');
@@ -105,7 +109,7 @@ describe('publish workflow definition', () => {
105
109
  );
106
110
  expect(linuxCandidate).toContain('# Step 19\n - name: ๐Ÿงน Cleanup and cache Nix/devenv');
107
111
  expect(macosPlatform).toContain('# Step 5\n - name: ๐ŸŽ Build selected macOS and iOS release outputs');
108
- expect(macosPlatform).toContain('# Step 7\n - name: ๐Ÿงน Cleanup and cache Nix/devenv');
112
+ expect(macosPlatform).toContain('# Step 8\n - name: ๐Ÿงน Cleanup and cache Nix/devenv');
109
113
  expect(finalJob).toContain('# Step 7\n - name: ๐Ÿงฏ Repair pending releases');
110
114
  expect(finalJob).toContain('# Step 8\n - name: โ™ป๏ธ Restore validated release state');
111
115
  expect(finalJob).toContain('# Step 9\n - name: ๐Ÿ“ฆ Apply verified Linux outputs');
@@ -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:');
@@ -150,12 +156,20 @@ describe('publish workflow definition', () => {
150
156
  expect(linuxCandidate).toContain(
151
157
  `smoo github-ci nx-run-many --targets "${LINUX_PLATFORM_TARGET_GLOBS.join(',')}" --projects`,
152
158
  );
153
- expect(macosPlatform).not.toContain('smoo github-ci nx-run-many');
154
159
  expect(foldedRunCommand(macosPlatform, '๐ŸŽ Build selected macOS and iOS release outputs')).toBe(
155
160
  `smoo release build-platform-outputs --bump "\${{ inputs.bump }}" --ref "\${{ github.sha }}" --targets "${MACOS_PLATFORM_TARGET_GLOBS.join(
156
161
  ',',
157
162
  )}" --output "\${{ runner.temp }}/macos-platform-outputs"`,
158
163
  );
164
+ expect(macosPlatform).toContain(
165
+ `run: smoo github-ci nx-run-many --targets test --projects-with-targets "${MACOS_PLATFORM_TARGET_GLOBS.join(',')}"`,
166
+ );
167
+ expect(macosPlatform.indexOf('- name: ๐ŸŽ Build selected macOS and iOS release outputs')).toBeLessThan(
168
+ macosPlatform.indexOf('- name: ๐Ÿงช Unit test macOS and iOS packages'),
169
+ );
170
+ expect(macosPlatform.indexOf('- name: ๐Ÿงช Unit test macOS and iOS packages')).toBeLessThan(
171
+ macosPlatform.indexOf('- name: ๐Ÿ“ค Upload macOS platform outputs'),
172
+ );
159
173
  expect(finalJob).not.toContain('smoo release version');
160
174
  expect(finalJob).not.toContain('nx-run-many');
161
175
  expect(foldedRunCommand(finalJob, '๐Ÿงฏ Repair pending releases')).toBe(
@@ -612,3 +626,24 @@ function packageNameFromTag(tag: string): string {
612
626
  }
613
627
  return tag.slice(0, versionSeparator);
614
628
  }
629
+
630
+ it('linux publish jobs use smoo.github.runsOn; macOS stays macos-latest', () => {
631
+ const rendered = renderPublishWorkflowYaml({
632
+ repoName: '@smoothbricks/codebase',
633
+ platformTargetGlobs: PLATFORM_TARGET_GLOBS,
634
+ runsOn: [...nixosRunsOn],
635
+ });
636
+ expect(rendered).toContain(
637
+ `runs-on:
638
+ \${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
639
+ fromJSON('["nixos-latest-x64","self-hosted"]') || 'ubuntu-latest' }}`,
640
+ );
641
+ expect(rendered).toContain(' macos-platform:\n runs-on: macos-latest');
642
+ // Only the pre-publish linux job runs on self-hosted
643
+ expect(rendered.split('fromJSON(\'["nixos-latest-x64","self-hosted"]\')').length - 1).toBe(1);
644
+ expect(rendered).not.toContain(' linux-release-candidate:\n runs-on: ubuntu-latest');
645
+ // NPM publishing happens from GitHub runner
646
+ expect(rendered).toContain(
647
+ ' publish-on-linux:\n needs: [linux-release-candidate, macos-platform]\n runs-on: ubuntu-latest',
648
+ );
649
+ });