@smoothbricks/cli 0.10.2 → 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.
- package/README.md +25 -13
- package/dist/cli.js +24 -0
- package/dist/github-ci/index.d.ts +17 -2
- package/dist/github-ci/index.d.ts.map +1 -1
- package/dist/github-ci/index.js +141 -11
- package/dist/github-ci/outputs.d.ts +22 -0
- package/dist/github-ci/outputs.d.ts.map +1 -0
- package/dist/github-ci/outputs.js +649 -0
- package/dist/monorepo/ci-workflow.js +1 -1
- package/dist/monorepo/managed-files.d.ts +1 -0
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +38 -0
- package/dist/monorepo/package-policy.d.ts.map +1 -1
- package/dist/monorepo/package-policy.js +26 -0
- package/dist/monorepo/packs/index.d.ts +3 -0
- package/dist/monorepo/packs/index.d.ts.map +1 -1
- package/dist/monorepo/packs/index.js +5 -2
- package/dist/monorepo/publish-workflow.d.ts +1 -0
- package/dist/monorepo/publish-workflow.d.ts.map +1 -1
- package/dist/monorepo/publish-workflow.js +239 -2
- package/dist/nx/index.d.ts +6 -0
- package/dist/nx/index.d.ts.map +1 -1
- package/dist/nx/index.js +55 -5
- package/dist/release/github-release.d.ts +3 -0
- package/dist/release/github-release.d.ts.map +1 -1
- package/dist/release/github-release.js +11 -0
- package/dist/release/index.d.ts +19 -1
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +140 -31
- package/dist/release/orchestration.d.ts +8 -1
- package/dist/release/orchestration.d.ts.map +1 -1
- package/dist/release/orchestration.js +38 -1
- package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +5 -0
- package/managed/raw/tooling/direnv/setup-environment.ts +40 -3
- package/managed/templates/github/actions/cache-nix-devenv/action.yml +12 -10
- package/managed/templates/github/actions/cache-node-modules/action.yml +3 -2
- package/managed/templates/github/actions/cache-nx/action.yml +2 -2
- package/managed/templates/github/actions/save-nix-devenv/action.yml +11 -3
- package/package.json +7 -4
- package/src/cli.ts +30 -4
- package/src/github-ci/index.test.ts +486 -0
- package/src/github-ci/index.ts +172 -13
- package/src/github-ci/outputs.ts +506 -0
- package/src/monorepo/__tests__/ci-workflow.test.ts +11 -0
- package/src/monorepo/__tests__/publish-workflow.test.ts +222 -1
- package/src/monorepo/ci-workflow.ts +1 -1
- package/src/monorepo/managed-files.test.ts +62 -0
- package/src/monorepo/managed-files.ts +41 -0
- package/src/monorepo/package-policy.test.ts +50 -1
- package/src/monorepo/package-policy.ts +28 -0
- package/src/monorepo/packs/index.test.ts +18 -1
- package/src/monorepo/packs/index.ts +7 -3
- package/src/monorepo/publish-workflow.ts +479 -2
- package/src/nx/index.test.ts +39 -0
- package/src/nx/index.ts +65 -5
- package/src/release/__tests__/fixture-repo.test.ts +18 -16
- package/src/release/__tests__/github-release.test.ts +11 -0
- package/src/release/__tests__/orchestration.test.ts +57 -2
- package/src/release/__tests__/trust-publisher.test.ts +91 -2
- package/src/release/github-release.ts +12 -0
- package/src/release/index.ts +218 -40
- package/src/release/orchestration.ts +55 -3
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
import { describe, expect, it } from 'bun:test';
|
|
4
4
|
import { readFile } from 'node:fs/promises';
|
|
5
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';
|
|
6
12
|
import {
|
|
7
13
|
definePublishWorkflow,
|
|
8
14
|
type PublishWorkflowBump,
|
|
@@ -15,13 +21,208 @@ import {
|
|
|
15
21
|
|
|
16
22
|
describe('publish workflow definition', () => {
|
|
17
23
|
it('renders the checked-in local publish workflow copy', async () => {
|
|
18
|
-
const rendered = renderPublishWorkflowYaml({
|
|
24
|
+
const rendered = renderPublishWorkflowYaml({
|
|
25
|
+
repoName: '@smoothbricks/codebase',
|
|
26
|
+
platformTargetGlobs: PLATFORM_TARGET_GLOBS,
|
|
27
|
+
});
|
|
19
28
|
const packageRoot = join(import.meta.dir, '..', '..', '..');
|
|
20
29
|
await expect(readFile(join(packageRoot, '..', '..', '.github/workflows/publish.yml'), 'utf8')).resolves.toBe(
|
|
21
30
|
rendered,
|
|
22
31
|
);
|
|
23
32
|
});
|
|
24
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
|
+
|
|
25
226
|
it('does not wire npm token secrets into repair or publish steps', () => {
|
|
26
227
|
const rendered = renderPublishWorkflowYaml({ repoName: '@smoothbricks/codebase' });
|
|
27
228
|
|
|
@@ -384,6 +585,26 @@ class WorkflowScenarioState {
|
|
|
384
585
|
}
|
|
385
586
|
}
|
|
386
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
|
+
|
|
387
608
|
function packageNameFromTag(tag: string): string {
|
|
388
609
|
const versionSeparator = tag.lastIndexOf('@');
|
|
389
610
|
if (versionSeparator <= 0) {
|
|
@@ -181,7 +181,7 @@ function yamlLinesForStep(step: CiWorkflowStep, options: CiWorkflowDefinitionOpt
|
|
|
181
181
|
' path: |',
|
|
182
182
|
' .nx/cache',
|
|
183
183
|
' .nx/workspace-data/*.db*',
|
|
184
|
-
' key: ${{ runner.os }}-nx-db-v1-${{ github.sha }}',
|
|
184
|
+
' key: ${{ runner.os }}-${{ runner.arch }}-nx-db-v1-${{ github.sha }}',
|
|
185
185
|
];
|
|
186
186
|
case CiWorkflowStepKind.UploadTraceDbs:
|
|
187
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);
|
|
@@ -139,3 +153,51 @@ describe('managed-file inline local blocks', () => {
|
|
|
139
153
|
);
|
|
140
154
|
});
|
|
141
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';
|
|
@@ -137,6 +138,7 @@ interface ManagedFileContext {
|
|
|
137
138
|
ciPushBranches: string[];
|
|
138
139
|
nodeModulesCacheKey: string;
|
|
139
140
|
repoName: string;
|
|
141
|
+
platformTargetGlobs: string[];
|
|
140
142
|
}
|
|
141
143
|
|
|
142
144
|
interface DeployTargetInfo {
|
|
@@ -295,6 +297,7 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
|
|
|
295
297
|
deploy: context.hasProductionDeployTargets,
|
|
296
298
|
deployProvider: context.productionDeployProvider,
|
|
297
299
|
repoName: context.repoName,
|
|
300
|
+
platformTargetGlobs: context.platformTargetGlobs,
|
|
298
301
|
});
|
|
299
302
|
}
|
|
300
303
|
throw new Error(`Unknown generated managed file source ${file.source}`);
|
|
@@ -314,6 +317,7 @@ function getManagedFileContext(root: string): ManagedFileContext {
|
|
|
314
317
|
const ciPushBranches = getCiPushBranches(packageJson?.json);
|
|
315
318
|
const stagingDeploy = getDeployTargetInfo(root, 'staging');
|
|
316
319
|
const productionDeploy = getDeployTargetInfo(root, 'production');
|
|
320
|
+
const platformTargetGlobs = platformTargetGlobsForTest(readResolvedNxTargetNames(root));
|
|
317
321
|
const nodeModulesCacheKey = existsSync(join(root, 'bun.lock'))
|
|
318
322
|
? `$${"{{ hashFiles('bun.lock', 'package.json', 'packages/*/package.json') }}"}`
|
|
319
323
|
: `$${"{{ hashFiles('bun.lockb', 'package.json', 'packages/*/package.json') }}"}`;
|
|
@@ -326,9 +330,46 @@ function getManagedFileContext(root: string): ManagedFileContext {
|
|
|
326
330
|
ciPushBranches,
|
|
327
331
|
nodeModulesCacheKey,
|
|
328
332
|
repoName,
|
|
333
|
+
platformTargetGlobs,
|
|
329
334
|
};
|
|
330
335
|
}
|
|
331
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
|
+
|
|
332
373
|
function renderTemplate(context: ManagedFileContext, template: string): string {
|
|
333
374
|
return template
|
|
334
375
|
.replaceAll('{{REPO_NAME}}', context.repoName)
|
|
@@ -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',
|
|
@@ -974,6 +973,56 @@ describe('workspace package script policy', () => {
|
|
|
974
973
|
await rm(root, { recursive: true, force: true });
|
|
975
974
|
}
|
|
976
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
|
+
});
|
|
977
1026
|
});
|
|
978
1027
|
|
|
979
1028
|
async function createWorkspace(input: {
|
|
@@ -371,6 +371,7 @@ export function validatePublicPackageMetadata(root: string): number {
|
|
|
371
371
|
|
|
372
372
|
export function validateWorkspaceDependencies(root: string, options: PackageTargetPolicyOptions = {}): number {
|
|
373
373
|
let failures = 0;
|
|
374
|
+
failures += validateCiSkipTags(root, options);
|
|
374
375
|
const workspaceNames = new Set(getWorkspacePackages(root).map((pkg) => pkg.name));
|
|
375
376
|
for (const pkg of listPackageJsonRecords(root)) {
|
|
376
377
|
for (const field of workspaceDependencyFields) {
|
|
@@ -404,6 +405,33 @@ export function validateWorkspaceDependencies(root: string, options: PackageTarg
|
|
|
404
405
|
return failures;
|
|
405
406
|
}
|
|
406
407
|
|
|
408
|
+
const ciSkipTagPrefix = 'ci:skip:';
|
|
409
|
+
const invalidCiSkipTargetSyntax = /[,*?[\]{}]/;
|
|
410
|
+
|
|
411
|
+
function validateCiSkipTags(root: string, options: PackageTargetPolicyOptions): number {
|
|
412
|
+
let failures = 0;
|
|
413
|
+
for (const pkg of getWorkspacePackages(root)) {
|
|
414
|
+
const resolved = options.resolvedTargetsByProject?.get(pkg.projectName);
|
|
415
|
+
const resolvedTargets = resolved && 'targets' in resolved ? resolved.targets : resolved;
|
|
416
|
+
for (const tag of pkg.tags) {
|
|
417
|
+
if (tag !== 'ci:skip' && !tag.startsWith(ciSkipTagPrefix)) {
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const target = tag.startsWith(ciSkipTagPrefix) ? tag.slice(ciSkipTagPrefix.length) : '';
|
|
421
|
+
if (!target || invalidCiSkipTargetSyntax.test(target)) {
|
|
422
|
+
console.error(`${pkg.path}: nx tag ${tag} must use ci:skip:<target> with one exact Nx target name`);
|
|
423
|
+
failures++;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (resolvedTargets && !resolvedTargets.has(target)) {
|
|
427
|
+
console.error(`${pkg.path}: nx tag ${tag} names missing Nx target ${target}`);
|
|
428
|
+
failures++;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return failures;
|
|
433
|
+
}
|
|
434
|
+
|
|
407
435
|
// ---------------------------------------------------------------------------
|
|
408
436
|
// Helpers kept in CLI (not Nx-specific)
|
|
409
437
|
// ---------------------------------------------------------------------------
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test';
|
|
2
2
|
import type { MonorepoPack } from './index.js';
|
|
3
|
-
import { runValidatePacks } from './index.js';
|
|
3
|
+
import { resolvedTargetsByProject, runValidatePacks } from './index.js';
|
|
4
4
|
|
|
5
5
|
const ctx = { root: '/workspace', syncRuntime: false };
|
|
6
6
|
|
|
@@ -186,4 +186,21 @@ describe('monorepo validation pack phases', () => {
|
|
|
186
186
|
expect(result).toEqual({ failures: 1, failedChecks: 1 });
|
|
187
187
|
expect(events).toEqual(['pre-fix', 'build']);
|
|
188
188
|
});
|
|
189
|
+
|
|
190
|
+
it('propagates parsed target dependencies through the production adapter', () => {
|
|
191
|
+
const targetDependencies = new Map([
|
|
192
|
+
['build', ['compile-linux']],
|
|
193
|
+
['compile-linux', ['package-linux']],
|
|
194
|
+
]);
|
|
195
|
+
|
|
196
|
+
const resolved = resolvedTargetsByProject([
|
|
197
|
+
{
|
|
198
|
+
project: 'native',
|
|
199
|
+
targets: ['build', 'compile-linux', 'package-linux'],
|
|
200
|
+
targetDependencies,
|
|
201
|
+
},
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
expect(resolved.get('native')?.targetDependencies).toBe(targetDependencies);
|
|
205
|
+
});
|
|
189
206
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { chmodSync, existsSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { printCommandOutput, runResult, runStatus } from '../../lib/run.js';
|
|
4
|
-
import { readProjectTargets } from '../../nx/index.js';
|
|
4
|
+
import { type ProjectTargets, readProjectTargets } from '../../nx/index.js';
|
|
5
5
|
import { syncBunLockfileVersions, validateBunLockfileVersions } from '../lockfile.js';
|
|
6
6
|
import { validateManagedFiles } from '../managed-files.js';
|
|
7
7
|
import { fixNxSync, validateNxSync } from '../nx-sync.js';
|
|
@@ -400,14 +400,14 @@ function printCapturedOutput(output: CapturedOutput): void {
|
|
|
400
400
|
}
|
|
401
401
|
}
|
|
402
402
|
|
|
403
|
-
|
|
404
|
-
const projects = await readProjectTargets(ctx.root);
|
|
403
|
+
export function resolvedTargetsByProject(projects: ProjectTargets[]): Map<string, ResolvedProjectTargets> {
|
|
405
404
|
return new Map(
|
|
406
405
|
projects.map((project) => [
|
|
407
406
|
project.project,
|
|
408
407
|
{
|
|
409
408
|
targets: new Set(project.targets),
|
|
410
409
|
...(project.buildDependsOn ? { buildDependsOn: project.buildDependsOn } : {}),
|
|
410
|
+
...(project.targetDependencies ? { targetDependencies: project.targetDependencies } : {}),
|
|
411
411
|
...(project.targetExecutors ? { targetExecutors: project.targetExecutors } : {}),
|
|
412
412
|
...(project.targetScripts ? { targetScripts: project.targetScripts } : {}),
|
|
413
413
|
},
|
|
@@ -415,6 +415,10 @@ async function readResolvedTargetsByProject(ctx: MonorepoContext): Promise<Map<s
|
|
|
415
415
|
);
|
|
416
416
|
}
|
|
417
417
|
|
|
418
|
+
async function readResolvedTargetsByProject(ctx: MonorepoContext): Promise<Map<string, ResolvedProjectTargets>> {
|
|
419
|
+
return resolvedTargetsByProject(await readProjectTargets(ctx.root));
|
|
420
|
+
}
|
|
421
|
+
|
|
418
422
|
function ensureLocalSmooShim(root: string): void {
|
|
419
423
|
const shim = join(root, 'tooling', 'smoo');
|
|
420
424
|
if (!existsSync(shim)) {
|