@smoothbricks/cli 0.10.1 → 0.10.3

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 (77) hide show
  1. package/README.md +25 -13
  2. package/dist/cli.js +24 -0
  3. package/dist/github-ci/index.d.ts +17 -2
  4. package/dist/github-ci/index.d.ts.map +1 -1
  5. package/dist/github-ci/index.js +145 -14
  6. package/dist/github-ci/outputs.d.ts +22 -0
  7. package/dist/github-ci/outputs.d.ts.map +1 -0
  8. package/dist/github-ci/outputs.js +649 -0
  9. package/dist/lib/conflict-markers.d.ts.map +1 -1
  10. package/dist/lib/workspace.d.ts +1 -1
  11. package/dist/lib/workspace.d.ts.map +1 -1
  12. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  13. package/dist/monorepo/ci-workflow.js +2 -1
  14. package/dist/monorepo/managed-files.d.ts +2 -0
  15. package/dist/monorepo/managed-files.d.ts.map +1 -1
  16. package/dist/monorepo/managed-files.js +50 -5
  17. package/dist/monorepo/package-policy.d.ts.map +1 -1
  18. package/dist/monorepo/package-policy.js +26 -0
  19. package/dist/monorepo/packs/index.d.ts +3 -0
  20. package/dist/monorepo/packs/index.d.ts.map +1 -1
  21. package/dist/monorepo/packs/index.js +5 -2
  22. package/dist/monorepo/publish-workflow.d.ts +1 -0
  23. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  24. package/dist/monorepo/publish-workflow.js +240 -2
  25. package/dist/monorepo/tool-validation.d.ts.map +1 -1
  26. package/dist/monorepo/tool-validation.js +20 -7
  27. package/dist/nx/index.d.ts +6 -0
  28. package/dist/nx/index.d.ts.map +1 -1
  29. package/dist/nx/index.js +55 -5
  30. package/dist/release/github-release.d.ts +5 -2
  31. package/dist/release/github-release.d.ts.map +1 -1
  32. package/dist/release/github-release.js +11 -0
  33. package/dist/release/index.d.ts +19 -1
  34. package/dist/release/index.d.ts.map +1 -1
  35. package/dist/release/index.js +140 -31
  36. package/dist/release/orchestration.d.ts +8 -1
  37. package/dist/release/orchestration.d.ts.map +1 -1
  38. package/dist/release/orchestration.js +38 -1
  39. package/dist/wrangler/prepare-env.d.ts.map +1 -1
  40. package/dist/wrangler/prepare-env.js +1 -1
  41. package/dist/wrangler/scaffold.d.ts.map +1 -1
  42. package/dist/wrangler/scaffold.js +1 -1
  43. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +5 -0
  44. package/managed/raw/tooling/direnv/setup-environment.ts +32 -1
  45. package/managed/templates/github/actions/cache-nix-devenv/action.yml +12 -10
  46. package/managed/templates/github/actions/cache-node-modules/action.yml +3 -2
  47. package/managed/templates/github/actions/cache-nx/action.yml +2 -2
  48. package/managed/templates/github/actions/save-nix-devenv/action.yml +11 -3
  49. package/package.json +7 -14
  50. package/src/cli.ts +30 -4
  51. package/src/github-ci/index.test.ts +486 -0
  52. package/src/github-ci/index.ts +177 -16
  53. package/src/github-ci/outputs.ts +506 -0
  54. package/src/monorepo/__tests__/ci-workflow.test.ts +13 -0
  55. package/src/monorepo/__tests__/publish-workflow.test.ts +234 -5
  56. package/src/monorepo/ci-workflow.ts +3 -1
  57. package/src/monorepo/managed-files.test.ts +75 -0
  58. package/src/monorepo/managed-files.ts +61 -5
  59. package/src/monorepo/package-policy.test.ts +57 -38
  60. package/src/monorepo/package-policy.ts +28 -0
  61. package/src/monorepo/packs/index.test.ts +18 -1
  62. package/src/monorepo/packs/index.ts +7 -3
  63. package/src/monorepo/publish-workflow.ts +481 -2
  64. package/src/monorepo/tool-validation.test.ts +59 -24
  65. package/src/monorepo/tool-validation.ts +21 -7
  66. package/src/monorepo/wrangler.test.ts +9 -2
  67. package/src/nx/index.test.ts +39 -0
  68. package/src/nx/index.ts +65 -5
  69. package/src/release/__tests__/fixture-repo.test.ts +18 -16
  70. package/src/release/__tests__/github-release.test.ts +11 -0
  71. package/src/release/__tests__/orchestration.test.ts +57 -2
  72. package/src/release/__tests__/trust-publisher.test.ts +91 -2
  73. package/src/release/github-release.ts +12 -0
  74. package/src/release/index.ts +218 -40
  75. package/src/release/orchestration.ts +55 -3
  76. package/src/wrangler/prepare-env.ts +14 -4
  77. package/src/wrangler/scaffold.ts +8 -3
@@ -1,6 +1,14 @@
1
+ /* biome-ignore-all lint/suspicious/noTemplateCurlyInString: Assertions cover literal GitHub Actions expressions. */
2
+
1
3
  import { describe, expect, it } from 'bun:test';
2
4
  import { readFile } from 'node:fs/promises';
3
5
  import { join } from 'node:path';
6
+ import {
7
+ LINUX_PLATFORM_TARGET_GLOBS,
8
+ MACOS_PLATFORM_TARGET_GLOBS,
9
+ PLATFORM_TARGET_GLOBS,
10
+ } from '@smoothbricks/nx-plugin/workspace-config-policy';
11
+ import { format } from 'prettier';
4
12
  import {
5
13
  definePublishWorkflow,
6
14
  type PublishWorkflowBump,
@@ -13,13 +21,208 @@ import {
13
21
 
14
22
  describe('publish workflow definition', () => {
15
23
  it('renders the checked-in local publish workflow copy', async () => {
16
- const rendered = renderPublishWorkflowYaml({ repoName: '@smoothbricks/codebase' });
24
+ const rendered = renderPublishWorkflowYaml({
25
+ repoName: '@smoothbricks/codebase',
26
+ platformTargetGlobs: PLATFORM_TARGET_GLOBS,
27
+ });
17
28
  const packageRoot = join(import.meta.dir, '..', '..', '..');
18
29
  await expect(readFile(join(packageRoot, '..', '..', '.github/workflows/publish.yml'), 'utf8')).resolves.toBe(
19
30
  rendered,
20
31
  );
21
32
  });
22
33
 
34
+ it('renders workflow bytes that are stable under the repository Prettier config', async () => {
35
+ const rendered = renderPublishWorkflowYaml({
36
+ repoName: '@smoothbricks/codebase',
37
+ platformTargetGlobs: PLATFORM_TARGET_GLOBS,
38
+ });
39
+
40
+ await expect(
41
+ format(rendered, {
42
+ parser: 'yaml',
43
+ printWidth: 120,
44
+ proseWrap: 'always',
45
+ }),
46
+ ).resolves.toBe(rendered);
47
+ });
48
+
49
+ it('preserves the single Ubuntu job and renders no artifact transfer when no Apple targets exist', () => {
50
+ const current = renderPublishWorkflowYaml({ repoName: '@smoothbricks/codebase' });
51
+ const explicitlyEmpty = renderPublishWorkflowYaml({
52
+ repoName: '@smoothbricks/codebase',
53
+ platformTargetGlobs: [],
54
+ });
55
+ const linuxOnly = renderPublishWorkflowYaml({
56
+ repoName: '@smoothbricks/codebase',
57
+ platformTargetGlobs: LINUX_PLATFORM_TARGET_GLOBS,
58
+ });
59
+
60
+ expect(explicitlyEmpty).toBe(current);
61
+ for (const rendered of [current, linuxOnly]) {
62
+ const jobsYaml = rendered.slice(rendered.indexOf('jobs:\n') + 'jobs:\n'.length);
63
+ expect(jobsYaml).toContain(' publish:\n runs-on: ubuntu-latest');
64
+ expect(jobsYaml.match(/^ {2}[a-z][a-z-]+:\n/gm)).toHaveLength(1);
65
+ expect(rendered).not.toContain('linux-release-candidate:');
66
+ expect(rendered).not.toContain('macos-platform:');
67
+ expect(rendered).not.toContain('publish-on-linux:');
68
+ expect(rendered).not.toContain('--collect-outputs');
69
+ expect(rendered).not.toContain('Upload validated release state');
70
+ expect(rendered).not.toContain('Upload validated build outputs');
71
+ expect(rendered).not.toContain('Upload supplemental Linux outputs');
72
+ expect(rendered).not.toContain('Upload macOS platform outputs');
73
+ expect(rendered).not.toContain('actions/download-artifact');
74
+ expect(rendered).not.toContain('apply-outputs');
75
+ expect(rendered).not.toContain('git bundle');
76
+ }
77
+ });
78
+
79
+ it('keeps job-local deeplink step anchors exact for single-job and parallel platform workflows', () => {
80
+ const singleJob = renderPublishWorkflowYaml({ repoName: '@smoothbricks/codebase' });
81
+ const platform = renderPublishWorkflowYaml({
82
+ deploy: true,
83
+ repoName: '@smoothbricks/codebase',
84
+ platformTargetGlobs: PLATFORM_TARGET_GLOBS,
85
+ });
86
+ const linuxCandidate = platform.slice(
87
+ platform.indexOf(' linux-release-candidate:'),
88
+ platform.indexOf(' macos-platform:'),
89
+ );
90
+ const macosPlatform = platform.slice(
91
+ platform.indexOf(' macos-platform:'),
92
+ platform.indexOf(' publish-on-linux:'),
93
+ );
94
+ const finalJob = platform.slice(platform.indexOf(' publish-on-linux:'));
95
+
96
+ expect(stepAnchorNumbers(singleJob)).toEqual(Array.from({ length: 15 }, (_, index) => index + 1));
97
+ expect(stepAnchorNumbers(linuxCandidate)).toEqual(Array.from({ length: 19 }, (_, index) => index + 1));
98
+ expect(stepAnchorNumbers(macosPlatform)).toEqual(Array.from({ length: 7 }, (_, index) => index + 1));
99
+ expect(stepAnchorNumbers(finalJob)).toEqual(Array.from({ length: 14 }, (_, index) => index + 1));
100
+ expect(singleJob).toContain('# Step 14\n - name: 📦 Publish release (${{ steps.version.outputs.mode }})');
101
+ expect(singleJob).toContain('# Step 15\n - name: 🧹 Cleanup and cache Nix/devenv');
102
+ expect(linuxCandidate).toContain('# Step 7\n - name: 🔒 Capture candidate release SHA');
103
+ expect(linuxCandidate).toContain(
104
+ '# Step 8\n - name: ✅ Check managed monorepo files (${{ steps.version.outputs.mode }})',
105
+ );
106
+ expect(linuxCandidate).toContain('# Step 19\n - name: 🧹 Cleanup and cache Nix/devenv');
107
+ 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');
109
+ expect(finalJob).toContain('# Step 7\n - name: 🧯 Repair pending releases');
110
+ expect(finalJob).toContain('# Step 8\n - name: ♻️ Restore validated release state');
111
+ expect(finalJob).toContain('# Step 9\n - name: 📦 Apply verified Linux outputs');
112
+ expect(finalJob).toContain('# Step 10\n - name: 🍎 Apply verified macOS outputs');
113
+ expect(finalJob).toContain(
114
+ '# Step 12\n - name: 📦 Publish release (${{ needs.linux-release-candidate.outputs.mode }})',
115
+ );
116
+ expect(finalJob).toContain('# Step 13\n - name: 🚀 Deploy production');
117
+ expect(finalJob).toContain('# Step 14\n - name: 🧹 Cleanup and cache Nix/devenv');
118
+ });
119
+
120
+ it('renders parallel native producers and a dependent publish-only final job', () => {
121
+ const linuxOnly = renderPublishWorkflowYaml({
122
+ repoName: '@smoothbricks/codebase',
123
+ platformTargetGlobs: LINUX_PLATFORM_TARGET_GLOBS,
124
+ });
125
+ const native = renderPublishWorkflowYaml({
126
+ repoName: '@smoothbricks/codebase',
127
+ platformTargetGlobs: PLATFORM_TARGET_GLOBS,
128
+ });
129
+ const linuxCandidate = native.slice(
130
+ native.indexOf(' linux-release-candidate:'),
131
+ native.indexOf(' macos-platform:'),
132
+ );
133
+ const macosPlatform = native.slice(native.indexOf(' macos-platform:'), native.indexOf(' publish-on-linux:'));
134
+ const finalJob = native.slice(native.indexOf(' publish-on-linux:'));
135
+
136
+ expect(linuxOnly).not.toContain('macos-platform:');
137
+ expect(linuxOnly).toContain(
138
+ `smoo github-ci nx-run-many --targets "${LINUX_PLATFORM_TARGET_GLOBS.join(',')}" --projects`,
139
+ );
140
+ expect(native).toContain(' linux-release-candidate:\n runs-on: ubuntu-latest');
141
+ expect(native).toContain(' macos-platform:\n runs-on: macos-latest');
142
+ expect(native).toContain(' publish-on-linux:\n needs: [linux-release-candidate, macos-platform]');
143
+ expect(linuxCandidate).not.toContain('needs:');
144
+ expect(macosPlatform).not.toContain('needs:');
145
+ expect(linuxCandidate).not.toContain('smoo release repair-pending');
146
+ expect(linuxCandidate).toContain('smoo release version');
147
+ expect(linuxCandidate).toContain('smoo github-ci nx-run-many --targets build --projects');
148
+ expect(linuxCandidate).toContain('smoo github-ci nx-run-many --targets lint --projects');
149
+ expect(linuxCandidate).toContain('smoo github-ci nx-run-many --targets test --projects');
150
+ expect(linuxCandidate).toContain(
151
+ `smoo github-ci nx-run-many --targets "${LINUX_PLATFORM_TARGET_GLOBS.join(',')}" --projects`,
152
+ );
153
+ expect(macosPlatform).not.toContain('smoo github-ci nx-run-many');
154
+ expect(foldedRunCommand(macosPlatform, '🍎 Build selected macOS and iOS release outputs')).toBe(
155
+ `smoo release build-platform-outputs --bump "\${{ inputs.bump }}" --ref "\${{ github.sha }}" --targets "${MACOS_PLATFORM_TARGET_GLOBS.join(
156
+ ',',
157
+ )}" --output "\${{ runner.temp }}/macos-platform-outputs"`,
158
+ );
159
+ expect(finalJob).not.toContain('smoo release version');
160
+ expect(finalJob).not.toContain('nx-run-many');
161
+ expect(foldedRunCommand(finalJob, '🧯 Repair pending releases')).toBe(
162
+ 'smoo release repair-pending --ref "${{ github.sha }}" --platform-outputs ' +
163
+ '"${{ runner.temp }}/publish-artifacts/publish-macos-outputs-${{ github.run_id }}/repairs" ' +
164
+ '--dry-run "${{ inputs.dry_run }}"',
165
+ );
166
+ expect(native).toContain('uses: actions/upload-artifact@v7.0.1');
167
+ expect(native).toContain('uses: actions/download-artifact@v8.0.1');
168
+ expect(native).toContain('name: publish-release-state-${{ github.run_id }}');
169
+ expect(native).toContain('name: publish-release-outputs-${{ github.run_id }}');
170
+ expect(native).toContain('name: publish-linux-outputs-${{ github.run_id }}');
171
+ expect(native.match(/include-hidden-files: true/g)).toHaveLength(4);
172
+ expect(native).toContain('name: publish-macos-outputs-${{ github.run_id }}');
173
+ expect(native).toContain('git bundle create');
174
+ expect(native).toContain('git fetch "${{ runner.temp }}/publish-artifacts/publish-release-state-');
175
+ expect(finalJob).toContain(
176
+ 'smoo github-ci apply-outputs --source-sha "${{ needs.linux-release-candidate.outputs.release-sha }}"',
177
+ );
178
+ expect(finalJob).toContain('smoo github-ci apply-outputs --source-sha "${{ github.sha }}"');
179
+ expect(foldedRunCommand(finalJob, '📦 Apply verified Linux outputs')).toBe(
180
+ 'smoo github-ci apply-outputs --source-sha "${{ needs.linux-release-candidate.outputs.release-sha }}" ' +
181
+ '"${{ runner.temp }}/publish-artifacts/publish-release-outputs-${{ github.run_id }}" ' +
182
+ '"${{ runner.temp }}/publish-artifacts/publish-linux-outputs-${{ github.run_id }}"',
183
+ );
184
+ expect(foldedRunCommand(finalJob, '🍎 Apply verified macOS outputs')).toBe(
185
+ 'smoo github-ci apply-outputs --source-sha "${{ github.sha }}" ' +
186
+ '"${{ runner.temp }}/publish-artifacts/publish-macos-outputs-${{ github.run_id }}/current"',
187
+ );
188
+ });
189
+
190
+ it('limits trusted publishing permission to final publish and preserves mode, deploy, and dry-run gates', () => {
191
+ const rendered = renderPublishWorkflowYaml({
192
+ deploy: true,
193
+ deployProvider: 'cloudflare',
194
+ repoName: '@smoothbricks/codebase',
195
+ platformTargetGlobs: PLATFORM_TARGET_GLOBS,
196
+ });
197
+ const linuxCandidate = rendered.slice(
198
+ rendered.indexOf(' linux-release-candidate:'),
199
+ rendered.indexOf(' macos-platform:'),
200
+ );
201
+ const macosPlatform = rendered.slice(
202
+ rendered.indexOf(' macos-platform:'),
203
+ rendered.indexOf(' publish-on-linux:'),
204
+ );
205
+ const finalJob = rendered.slice(rendered.indexOf(' publish-on-linux:'));
206
+
207
+ expect(linuxCandidate).toContain('id-token: none');
208
+ expect(macosPlatform).toContain('id-token: none');
209
+ expect(finalJob).toContain('id-token: write');
210
+ expect(rendered.match(/id-token: write/g)).toHaveLength(1);
211
+ expect(finalJob.indexOf('🧯 Repair pending releases')).toBeLessThan(
212
+ finalJob.indexOf('♻️ Restore validated release state'),
213
+ );
214
+ expect(finalJob.indexOf('♻️ Restore validated release state')).toBeGreaterThan(
215
+ finalJob.indexOf('🧱 Setup Nix/devenv'),
216
+ );
217
+ expect(rendered).not.toContain('GITHUB_SHA:');
218
+ expect(finalJob).toContain("needs.linux-release-candidate.outputs.mode != 'none'");
219
+ expect(finalJob).toContain("inputs.deploy_environment == 'production'");
220
+ expect(finalJob).toContain("inputs.dry_run != 'true'");
221
+ expect(finalJob).toContain('smoo release publish --bump "${{ inputs.bump }}" --dry-run "${{ inputs.dry_run }}"');
222
+ expect(finalJob).toContain('CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}');
223
+ expect(finalJob).toContain('CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}');
224
+ });
225
+
23
226
  it('does not wire npm token secrets into repair or publish steps', () => {
24
227
  const rendered = renderPublishWorkflowYaml({ repoName: '@smoothbricks/codebase' });
25
228
 
@@ -250,11 +453,17 @@ class WorkflowScenarioState {
250
453
  private readonly repairBuilds = new Set<string>();
251
454
  private readonly publishedCurrent = new Set<string>();
252
455
  private readonly durableGaps = new Set<string>();
253
- private readonly validationState = {
456
+ private readonly validationState: {
457
+ checks: number;
458
+ builds: string[];
459
+ lints: string[];
460
+ tests: string[];
461
+ validates: number;
462
+ } = {
254
463
  checks: 0,
255
- builds: [] as string[],
256
- lints: [] as string[],
257
- tests: [] as string[],
464
+ builds: [],
465
+ lints: [],
466
+ tests: [],
258
467
  validates: 0,
259
468
  };
260
469
 
@@ -376,6 +585,26 @@ class WorkflowScenarioState {
376
585
  }
377
586
  }
378
587
 
588
+ function stepAnchorNumbers(yaml: string): number[] {
589
+ return [...yaml.matchAll(/^\s*# Step (\d+)/gm)].map((match) => Number(match[1]));
590
+ }
591
+
592
+ function foldedRunCommand(yaml: string, stepName: string): string {
593
+ const stepStart = yaml.indexOf(`- name: ${stepName}`);
594
+ expect(stepStart).toBeGreaterThanOrEqual(0);
595
+ const runMarker = ' run:\n';
596
+ const runStart = yaml.indexOf(runMarker, stepStart);
597
+ expect(runStart).toBeGreaterThan(stepStart);
598
+ const commandLines: string[] = [];
599
+ for (const line of yaml.slice(runStart + runMarker.length).split('\n')) {
600
+ if (!line.startsWith(' ')) {
601
+ break;
602
+ }
603
+ commandLines.push(line.trim());
604
+ }
605
+ return commandLines.join(' ');
606
+ }
607
+
379
608
  function packageNameFromTag(tag: string): string {
380
609
  const versionSeparator = tag.lastIndexOf('@');
381
610
  if (versionSeparator <= 0) {
@@ -1,3 +1,5 @@
1
+ /* biome-ignore-all lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions are emitted literally. */
2
+
1
3
  export enum CiWorkflowStepKind {
2
4
  Checkout = 'checkout',
3
5
  SetupDevenv = 'setup-devenv',
@@ -179,7 +181,7 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
179
181
  ' path: |',
180
182
  ' .nx/cache',
181
183
  ' .nx/workspace-data/*.db*',
182
- ' key: ${{ runner.os }}-nx-db-v1-${{ github.sha }}',
184
+ ' key: ${{ runner.os }}-${{ runner.arch }}-nx-db-v1-${{ github.sha }}',
183
185
  ];
184
186
  case CiWorkflowStepKind.UploadTraceDbs:
185
187
  return [
@@ -1,16 +1,30 @@
1
+ /* biome-ignore-all lint/suspicious/noTemplateCurlyInString: GitHub Actions expressions are asserted literally. */
1
2
  import { describe, expect, it } from 'bun:test';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { LINUX_PLATFORM_TARGET_GLOBS, PLATFORM_TARGET_GLOBS } from '@smoothbricks/nx-plugin/workspace-config-policy';
2
6
  import fc from 'fast-check';
3
7
  import {
4
8
  extractInlineLocalBlocksForTest,
5
9
  INLINE_LOCAL_BEGIN,
6
10
  INLINE_LOCAL_END,
7
11
  LOCAL_SECTION_MARKER,
12
+ platformTargetGlobsForTest,
8
13
  reinsertInlineLocalBlocksForTest,
9
14
  splitLocalSectionForTest,
10
15
  } from './managed-files.js';
11
16
 
12
17
  const MANAGED = '# managed content\npath merge=driver\n';
13
18
 
19
+ const REPO_ROOT = join(import.meta.dir, '..', '..', '..', '..');
20
+ const ARCHITECTURE_SCOPED_PREFIX = '${{ runner.os }}-${{ runner.arch }}-';
21
+ const NODE_MODULES_CACHE_KEY = "${{ hashFiles('bun.lock', 'package.json', 'packages/*/package.json') }}";
22
+ const CACHE_ACTIONS = [
23
+ { name: 'cache-nix-devenv', osKeyLines: 6 },
24
+ { name: 'cache-node-modules', osKeyLines: 2 },
25
+ { name: 'cache-nx', osKeyLines: 2 },
26
+ ] as const;
27
+
14
28
  describe('managed-file local sections', () => {
15
29
  it('content without a marker is entirely managed', () => {
16
30
  const { managed, localTail } = splitLocalSectionForTest(MANAGED);
@@ -93,6 +107,19 @@ describe('managed-file inline local blocks', () => {
93
107
  );
94
108
  });
95
109
 
110
+ it('preserves marker indentation when refreshing nested configuration', () => {
111
+ const markerIndent = ' ';
112
+ const current = [
113
+ 'patterns:',
114
+ " - '*.html'",
115
+ `${markerIndent}${INLINE_LOCAL_BEGIN}`,
116
+ " - '!**/templates/*.html'",
117
+ `${markerIndent}${INLINE_LOCAL_END}`,
118
+ ].join('\n');
119
+ const { withoutInline, blocks } = extractInlineLocalBlocksForTest(current);
120
+ expect(reinsertInlineLocalBlocksForTest(withoutInline, blocks)).toBe(current);
121
+ });
122
+
96
123
  it("reinserting refuses when the anchor no longer appears — never silently drops the repo's customization", () => {
97
124
  const fresh = ['a:', ' - one', 'b:'].join('\n'); // ' - two' is gone
98
125
  expect(() => reinsertInlineLocalBlocksForTest(fresh, [{ anchor: ' - two', lines: ' - repo-owned' }])).toThrow(
@@ -126,3 +153,51 @@ describe('managed-file inline local blocks', () => {
126
153
  );
127
154
  });
128
155
  });
156
+
157
+ describe('managed publish platform discovery', () => {
158
+ it('returns canonical target families from resolved target names without leaking project names', () => {
159
+ const discovered = platformTargetGlobsForTest(['build', 'bundle-linux', 'package-macos', 'simulator-ios', 'test']);
160
+
161
+ expect(discovered).toEqual([...PLATFORM_TARGET_GLOBS]);
162
+ expect(discovered).not.toContain('native-app');
163
+ });
164
+
165
+ it('selects only supplemental Linux when resolved metadata has no Apple targets', () => {
166
+ expect(platformTargetGlobsForTest(['build', 'bundle-linux', 'test'])).toEqual([...LINUX_PLATFORM_TARGET_GLOBS]);
167
+ });
168
+
169
+ it('returns no platform families for ordinary Nx targets', () => {
170
+ expect(platformTargetGlobsForTest(['build', 'lint', 'test', 'typecheck'])).toEqual([]);
171
+ });
172
+ });
173
+
174
+ describe('managed cache actions', () => {
175
+ it('renders the checked-in action copies from their managed templates', async () => {
176
+ for (const action of CACHE_ACTIONS) {
177
+ const [template, generated] = await Promise.all([
178
+ readFile(
179
+ join(REPO_ROOT, 'packages', 'cli', 'managed', 'templates', 'github', 'actions', action.name, 'action.yml'),
180
+ 'utf8',
181
+ ),
182
+ readFile(join(REPO_ROOT, '.github', 'actions', action.name, 'action.yml'), 'utf8'),
183
+ ]);
184
+
185
+ expect(generated).toBe(template.replace('{{NODE_MODULES_CACHE_KEY}}', NODE_MODULES_CACHE_KEY));
186
+ }
187
+ });
188
+
189
+ it('scopes every primary, restore, and save key to the runner OS and architecture', async () => {
190
+ for (const action of CACHE_ACTIONS) {
191
+ for (const actionRoot of [
192
+ join(REPO_ROOT, 'packages', 'cli', 'managed', 'templates', 'github', 'actions'),
193
+ join(REPO_ROOT, '.github', 'actions'),
194
+ ]) {
195
+ const content = await readFile(join(actionRoot, action.name, 'action.yml'), 'utf8');
196
+ const osKeyLines = content.split('\n').filter((line) => line.includes('${{ runner.os }}'));
197
+
198
+ expect(osKeyLines).toHaveLength(action.osKeyLines);
199
+ expect(osKeyLines.every((line) => line.includes(ARCHITECTURE_SCOPED_PREFIX))).toBe(true);
200
+ }
201
+ }
202
+ });
203
+ });
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
2
2
  import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { PLATFORM_TARGET_GLOBS } from '@smoothbricks/nx-plugin/workspace-config-policy';
5
6
  import { listReleasePackages, readPackageJson } from '../lib/workspace.js';
6
7
  import { renderCiWorkflowYaml } from './ci-workflow.js';
7
8
  import { renderPublishWorkflowYaml } from './publish-workflow.js';
@@ -50,6 +51,7 @@ export const INLINE_LOCAL_END = '# smoo-local-end';
50
51
  interface InlineLocalBlock {
51
52
  anchor: string;
52
53
  lines: string;
54
+ markerIndent?: string;
53
55
  }
54
56
 
55
57
  /** Pull inline local blocks out of a managed section, returning the section
@@ -76,7 +78,12 @@ function extractInlineLocalBlocks(managed: string): { withoutInline: string; blo
76
78
  if (i >= lines.length) {
77
79
  throw new Error(`${INLINE_LOCAL_BEGIN} anchored on "${anchor}" has no matching ${INLINE_LOCAL_END}`);
78
80
  }
79
- blocks.push({ anchor, lines: blockLines.join('\n') });
81
+ const markerIndent = line.slice(0, line.length - line.trimStart().length);
82
+ blocks.push({
83
+ anchor,
84
+ lines: blockLines.join('\n'),
85
+ ...(markerIndent === '' ? {} : { markerIndent }),
86
+ });
80
87
  i += 1; // skip the END marker line itself
81
88
  continue;
82
89
  }
@@ -102,7 +109,14 @@ function reinsertInlineLocalBlocks(content: string, blocks: InlineLocalBlock[]):
102
109
  'template — reconcile the repo-owned block manually',
103
110
  );
104
111
  }
105
- lines.splice(index + 1, 0, INLINE_LOCAL_BEGIN, ...block.lines.split('\n'), INLINE_LOCAL_END);
112
+ const markerIndent = block.markerIndent ?? '';
113
+ lines.splice(
114
+ index + 1,
115
+ 0,
116
+ `${markerIndent}${INLINE_LOCAL_BEGIN}`,
117
+ ...block.lines.split('\n'),
118
+ `${markerIndent}${INLINE_LOCAL_END}`,
119
+ );
106
120
  }
107
121
  return lines.join('\n');
108
122
  }
@@ -124,6 +138,7 @@ interface ManagedFileContext {
124
138
  ciPushBranches: string[];
125
139
  nodeModulesCacheKey: string;
126
140
  repoName: string;
141
+ platformTargetGlobs: string[];
127
142
  }
128
143
 
129
144
  interface DeployTargetInfo {
@@ -282,6 +297,7 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
282
297
  deploy: context.hasProductionDeployTargets,
283
298
  deployProvider: context.productionDeployProvider,
284
299
  repoName: context.repoName,
300
+ platformTargetGlobs: context.platformTargetGlobs,
285
301
  });
286
302
  }
287
303
  throw new Error(`Unknown generated managed file source ${file.source}`);
@@ -301,6 +317,7 @@ function getManagedFileContext(root: string): ManagedFileContext {
301
317
  const ciPushBranches = getCiPushBranches(packageJson?.json);
302
318
  const stagingDeploy = getDeployTargetInfo(root, 'staging');
303
319
  const productionDeploy = getDeployTargetInfo(root, 'production');
320
+ const platformTargetGlobs = platformTargetGlobsForTest(readResolvedNxTargetNames(root));
304
321
  const nodeModulesCacheKey = existsSync(join(root, 'bun.lock'))
305
322
  ? `$${"{{ hashFiles('bun.lock', 'package.json', 'packages/*/package.json') }}"}`
306
323
  : `$${"{{ hashFiles('bun.lockb', 'package.json', 'packages/*/package.json') }}"}`;
@@ -313,9 +330,46 @@ function getManagedFileContext(root: string): ManagedFileContext {
313
330
  ciPushBranches,
314
331
  nodeModulesCacheKey,
315
332
  repoName,
333
+ platformTargetGlobs,
316
334
  };
317
335
  }
318
336
 
337
+ export function platformTargetGlobsForTest(targetNames: Iterable<string>): string[] {
338
+ const names = [...targetNames];
339
+ return PLATFORM_TARGET_GLOBS.filter((glob) => {
340
+ const suffix = glob.startsWith('*') ? glob.slice(1) : glob;
341
+ return names.some((name) => name.endsWith(suffix));
342
+ });
343
+ }
344
+
345
+ function readResolvedNxTargetNames(root: string): string[] {
346
+ const output = execFileSync('nx', ['show', 'projects', '--json'], {
347
+ cwd: root,
348
+ encoding: 'utf8',
349
+ stdio: ['ignore', 'pipe', 'inherit'],
350
+ });
351
+ const projects: unknown = JSON.parse(output);
352
+ if (!Array.isArray(projects) || !projects.every((project) => typeof project === 'string')) {
353
+ return [];
354
+ }
355
+ const targetNames = new Set<string>();
356
+ for (const project of projects) {
357
+ const projectOutput = execFileSync('nx', ['show', 'project', project, '--json'], {
358
+ cwd: root,
359
+ encoding: 'utf8',
360
+ stdio: ['ignore', 'pipe', 'inherit'],
361
+ });
362
+ const projectJson: unknown = JSON.parse(projectOutput);
363
+ const targets = recordValue(recordValue(projectJson)?.targets);
364
+ if (targets) {
365
+ for (const targetName of Object.keys(targets)) {
366
+ targetNames.add(targetName);
367
+ }
368
+ }
369
+ }
370
+ return [...targetNames];
371
+ }
372
+
319
373
  function renderTemplate(context: ManagedFileContext, template: string): string {
320
374
  return template
321
375
  .replaceAll('{{REPO_NAME}}', context.repoName)
@@ -388,9 +442,11 @@ function readCiPushBranches(packageJson: unknown): string[] {
388
442
  }
389
443
 
390
444
  function recordValue(value: unknown): Record<string, unknown> | undefined {
391
- return value !== null && typeof value === 'object' && !Array.isArray(value)
392
- ? (value as Record<string, unknown>)
393
- : undefined;
445
+ return isRecord(value) ? value : undefined;
446
+ }
447
+
448
+ function isRecord(value: unknown): value is Record<string, unknown> {
449
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
394
450
  }
395
451
 
396
452
  function renderYamlFlowList(values: string[]): string {
@@ -29,7 +29,6 @@ const buildOutputDependencies = [
29
29
  '*-web',
30
30
  '*-html',
31
31
  '*-css',
32
- '*-ios',
33
32
  '*-android',
34
33
  '*-native',
35
34
  '*-napi',
@@ -70,21 +69,7 @@ describe('root smoo monorepo policy', () => {
70
69
  expect(rootPackage).toMatchObject({ nx: { includedScripts: [] } });
71
70
  expect(nxJson.namedInputs).toEqual(validNamedInputs());
72
71
  expect(nxJson.targetDefaults).toEqual(validTargetDefaults());
73
- expect(nxJson.plugins).toEqual([
74
- {
75
- plugin: '@nx/js/typescript',
76
- options: {
77
- typecheck: { targetName: 'typecheck' },
78
- build: {
79
- targetName: 'tsc-js',
80
- configName: 'tsconfig.lib.json',
81
- buildDepsName: 'build-deps',
82
- watchDepsName: 'watch-deps',
83
- },
84
- },
85
- },
86
- '@smoothbricks/nx-plugin',
87
- ]);
72
+ expect(nxJson.plugins).toEqual(['@smoothbricks/nx-plugin']);
88
73
  expect(validateRootPackagePolicy(root)).toBe(0);
89
74
  expect(validateNxReleaseConfig(root)).toBe(0);
90
75
  } finally {
@@ -167,7 +152,7 @@ describe('root smoo monorepo policy', () => {
167
152
  }
168
153
  });
169
154
 
170
- it('explains Nx plugin conventions when nx.json is not configured', async () => {
155
+ it('explains SmoothBricks Nx plugin conventions when nx.json is not configured', async () => {
171
156
  const root = await mkdtemp(join(tmpdir(), 'smoo-package-policy-'));
172
157
  try {
173
158
  await writeJson(join(root, 'package.json'), validRootPackage());
@@ -177,10 +162,8 @@ describe('root smoo monorepo policy', () => {
177
162
  });
178
163
  const errors = captureConsoleErrors();
179
164
 
180
- expect(validateNxReleaseConfig(root)).toBe(2);
165
+ expect(validateNxReleaseConfig(root)).toBe(1);
181
166
 
182
- expect(errors.join('\n')).toContain('Official Nx owns TypeScript library inference');
183
- expect(errors.join('\n')).toContain('tsconfig.lib.json produces tsc-js');
184
167
  expect(errors.join('\n')).toContain('Smoo relies on this plugin to infer convention targets');
185
168
  expect(errors.join('\n')).not.toContain('Fix:');
186
169
  } finally {
@@ -188,7 +171,7 @@ describe('root smoo monorepo policy', () => {
188
171
  }
189
172
  });
190
173
 
191
- it('explains why TypeScript build targetName is tsc-js', async () => {
174
+ it('rejects the official Nx TypeScript plugin because it bypasses ttsc transforms', async () => {
192
175
  const root = await mkdtemp(join(tmpdir(), 'smoo-package-policy-'));
193
176
  try {
194
177
  await writeJson(join(root, 'package.json'), validRootPackage());
@@ -206,8 +189,8 @@ describe('root smoo monorepo policy', () => {
206
189
 
207
190
  expect(validateNxReleaseConfig(root)).toBe(1);
208
191
 
209
- expect(errors.join('\n')).toContain('build.targetName must be tsc-js');
210
- expect(errors.join('\n')).toContain('build is reserved for aggregate targets');
192
+ expect(errors.join('\n')).toContain('plugins must not configure @nx/js/typescript');
193
+ expect(errors.join('\n')).toContain('Smoo owns transformer-aware TypeScript targets');
211
194
  expect(errors.join('\n')).not.toContain('Fix:');
212
195
  } finally {
213
196
  await rm(root, { recursive: true, force: true });
@@ -990,6 +973,56 @@ describe('workspace package script policy', () => {
990
973
  await rm(root, { recursive: true, force: true });
991
974
  }
992
975
  });
976
+ it('validates generic CI skip tags against resolved project targets', async () => {
977
+ const root = await createWorkspace({
978
+ rootName: '@fixture/root',
979
+ packages: [
980
+ {
981
+ dir: 'worker',
982
+ name: '@fixture/worker',
983
+ nx: { name: 'worker', tags: ['ci:skip:test', 'ci:skip:test.integration'], targets: {} },
984
+ },
985
+ ],
986
+ });
987
+ try {
988
+ const resolvedTargetsByProject = new Map([['worker', new Set(['build', 'test', 'test.integration'])]]);
989
+ expect(validateWorkspaceDependencies(root, { resolvedTargetsByProject })).toBe(0);
990
+ } finally {
991
+ await rm(root, { recursive: true, force: true });
992
+ }
993
+ });
994
+
995
+ it('rejects malformed and missing-target CI skip tags', async () => {
996
+ const root = await createWorkspace({
997
+ rootName: '@fixture/root',
998
+ packages: [
999
+ {
1000
+ dir: 'malformed',
1001
+ name: '@fixture/malformed',
1002
+ nx: { name: 'malformed', tags: ['ci:skip:'], targets: {} },
1003
+ },
1004
+ {
1005
+ dir: 'missing',
1006
+ name: '@fixture/missing',
1007
+ nx: { name: 'missing', tags: ['ci:skip:test'], targets: {} },
1008
+ },
1009
+ ],
1010
+ });
1011
+ const errors = captureConsoleErrors();
1012
+ try {
1013
+ const resolvedTargetsByProject = new Map([
1014
+ ['malformed', new Set(['test'])],
1015
+ ['missing', new Set(['build'])],
1016
+ ]);
1017
+ expect(validateWorkspaceDependencies(root, { resolvedTargetsByProject })).toBe(2);
1018
+ expect(errors).toContain(
1019
+ 'packages/malformed: nx tag ci:skip: must use ci:skip:<target> with one exact Nx target name',
1020
+ );
1021
+ expect(errors).toContain('packages/missing: nx tag ci:skip:test names missing Nx target test');
1022
+ } finally {
1023
+ await rm(root, { recursive: true, force: true });
1024
+ }
1025
+ });
993
1026
  });
994
1027
 
995
1028
  async function createWorkspace(input: {
@@ -1100,21 +1133,7 @@ function validConfiguredNxJson(): Record<string, unknown> {
1100
1133
  ...validNxJson(),
1101
1134
  namedInputs: validNamedInputs(),
1102
1135
  targetDefaults: validTargetDefaults(),
1103
- plugins: [
1104
- {
1105
- plugin: '@nx/js/typescript',
1106
- options: {
1107
- typecheck: { targetName: 'typecheck' },
1108
- build: {
1109
- targetName: 'tsc-js',
1110
- configName: 'tsconfig.lib.json',
1111
- buildDepsName: 'build-deps',
1112
- watchDepsName: 'watch-deps',
1113
- },
1114
- },
1115
- },
1116
- '@smoothbricks/nx-plugin',
1117
- ],
1136
+ plugins: ['@smoothbricks/nx-plugin'],
1118
1137
  };
1119
1138
  }
1120
1139