@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.
- 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 +145 -14
- 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/lib/conflict-markers.d.ts.map +1 -1
- package/dist/lib/workspace.d.ts +1 -1
- package/dist/lib/workspace.d.ts.map +1 -1
- package/dist/monorepo/ci-workflow.d.ts.map +1 -1
- package/dist/monorepo/ci-workflow.js +2 -1
- package/dist/monorepo/managed-files.d.ts +2 -0
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +50 -5
- 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 +240 -2
- package/dist/monorepo/tool-validation.d.ts.map +1 -1
- package/dist/monorepo/tool-validation.js +20 -7
- 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 +5 -2
- 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/dist/wrangler/prepare-env.d.ts.map +1 -1
- package/dist/wrangler/prepare-env.js +1 -1
- package/dist/wrangler/scaffold.d.ts.map +1 -1
- package/dist/wrangler/scaffold.js +1 -1
- package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +5 -0
- package/managed/raw/tooling/direnv/setup-environment.ts +32 -1
- 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 -14
- package/src/cli.ts +30 -4
- package/src/github-ci/index.test.ts +486 -0
- package/src/github-ci/index.ts +177 -16
- package/src/github-ci/outputs.ts +506 -0
- package/src/monorepo/__tests__/ci-workflow.test.ts +13 -0
- package/src/monorepo/__tests__/publish-workflow.test.ts +234 -5
- package/src/monorepo/ci-workflow.ts +3 -1
- package/src/monorepo/managed-files.test.ts +75 -0
- package/src/monorepo/managed-files.ts +61 -5
- package/src/monorepo/package-policy.test.ts +57 -38
- 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 +481 -2
- package/src/monorepo/tool-validation.test.ts +59 -24
- package/src/monorepo/tool-validation.ts +21 -7
- package/src/monorepo/wrangler.test.ts +9 -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
- package/src/wrangler/prepare-env.ts +14 -4
- package/src/wrangler/scaffold.ts +8 -3
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { $ } from 'bun';
|
|
6
|
+
import { decode } from '../lib/run.js';
|
|
7
|
+
import type { ProjectTargets } from '../nx/index.js';
|
|
8
|
+
import {
|
|
9
|
+
expandNxTargetDependencyRuns,
|
|
10
|
+
expandNxTargetRuns,
|
|
11
|
+
githubCiNxRunMany,
|
|
12
|
+
nxRunManyArgs,
|
|
13
|
+
nxSmartArgs,
|
|
14
|
+
readGitHeadSha,
|
|
15
|
+
} from './index.js';
|
|
16
|
+
import {
|
|
17
|
+
applyCollectedOutputs,
|
|
18
|
+
type CollectedOutputsManifest,
|
|
19
|
+
collectNxOutputs,
|
|
20
|
+
resolveDeclaredOutput,
|
|
21
|
+
} from './outputs.js';
|
|
22
|
+
|
|
23
|
+
const SOURCE_SHA = 'a'.repeat(40);
|
|
24
|
+
const OTHER_SHA = 'b'.repeat(40);
|
|
25
|
+
|
|
26
|
+
const projects: ProjectTargets[] = [
|
|
27
|
+
{ project: 'api', root: 'packages/api', targets: ['build', 'test'] },
|
|
28
|
+
{ project: 'desktop', root: 'packages/desktop', targets: ['build-macos', 'package-macos', 'test'] },
|
|
29
|
+
{ project: 'mobile', root: 'packages/mobile', targets: ['build-ios'] },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
describe('GitHub CI Nx target expansion', () => {
|
|
33
|
+
it('groups an exact target with only projects that own it', () => {
|
|
34
|
+
const expanded = expandNxTargetRuns(projects, { targets: 'test' });
|
|
35
|
+
|
|
36
|
+
expect(expanded.unmatchedGlobs).toEqual([]);
|
|
37
|
+
expect(expanded.runs.map((run) => [run.target, run.projects.map((project) => project.project)])).toEqual([
|
|
38
|
+
['test', ['api', 'desktop']],
|
|
39
|
+
]);
|
|
40
|
+
expect(expanded.runs.map((run) => nxRunManyArgs(run))).toEqual([
|
|
41
|
+
['run-many', '-t', 'test', '--projects=api,desktop', '--parallel=5'],
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('expands target globs across heterogeneous projects', () => {
|
|
46
|
+
const expanded = expandNxTargetRuns(projects, { targets: '*-macos,*-ios' });
|
|
47
|
+
|
|
48
|
+
expect(expanded.unmatchedGlobs).toEqual([]);
|
|
49
|
+
expect(expanded.runs.map((run) => [run.target, run.projects.map((project) => project.project)])).toEqual([
|
|
50
|
+
['build-macos', ['desktop']],
|
|
51
|
+
['package-macos', ['desktop']],
|
|
52
|
+
['build-ios', ['mobile']],
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('reports a zero-match glob as an empty run set', () => {
|
|
57
|
+
expect(expandNxTargetRuns(projects, { targets: '*-windows' })).toEqual({
|
|
58
|
+
runs: [],
|
|
59
|
+
unmatchedGlobs: ['*-windows'],
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('passes an unknown exact target to Nx so Nx retains failure behavior', () => {
|
|
64
|
+
const expanded = expandNxTargetRuns(projects, { targets: 'missing', projects: 'api' });
|
|
65
|
+
|
|
66
|
+
expect(expanded.unmatchedGlobs).toEqual([]);
|
|
67
|
+
expect(expanded.runs.map((run) => nxRunManyArgs(run))).toEqual([
|
|
68
|
+
['run-many', '-t', 'missing', '--projects=api', '--parallel=5'],
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('scopes glob candidates to selected projects and rejects empty project selections', () => {
|
|
73
|
+
expect(expandNxTargetRuns(projects, { targets: '*-macos', projects: 'api' })).toEqual({
|
|
74
|
+
runs: [],
|
|
75
|
+
unmatchedGlobs: ['*-macos'],
|
|
76
|
+
});
|
|
77
|
+
expect(() => expandNxTargetRuns(projects, { targets: 'test', projects: 'missing-*' })).toThrow(
|
|
78
|
+
'No Nx projects matched',
|
|
79
|
+
);
|
|
80
|
+
expect(expandNxTargetRuns([], { targets: 'missing' })).toEqual({ runs: [], unmatchedGlobs: [] });
|
|
81
|
+
expect(expandNxTargetRuns(projects, { targets: '*-macos', projects: '', allowEmptyProjects: true })).toEqual({
|
|
82
|
+
runs: [],
|
|
83
|
+
unmatchedGlobs: ['*-macos'],
|
|
84
|
+
});
|
|
85
|
+
expect(() => nxRunManyArgs({ target: 'test', projects: [] })).toThrow('has no selected projects');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('expands same-project target dependencies in dependency-first order exactly once', () => {
|
|
89
|
+
const project: ProjectTargets = {
|
|
90
|
+
project: 'native',
|
|
91
|
+
root: 'packages/native',
|
|
92
|
+
targets: ['build', 'compile-linux', 'package-linux'],
|
|
93
|
+
targetDependencies: new Map([
|
|
94
|
+
['build', ['compile-*', 'compile-linux']],
|
|
95
|
+
['compile-linux', ['package-linux', '^build']],
|
|
96
|
+
]),
|
|
97
|
+
targetOutputs: new Map([
|
|
98
|
+
['build', ['{projectRoot}/dist']],
|
|
99
|
+
['compile-linux', ['{projectRoot}/dist/**/*.bin']],
|
|
100
|
+
['package-linux', ['{projectRoot}/dist/**/*.tar']],
|
|
101
|
+
]),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
expect(
|
|
105
|
+
expandNxTargetDependencyRuns([{ target: 'build', projects: [project] }]).map(
|
|
106
|
+
(run) => `${run.projects.map((owner) => owner.project).join(',')}:${run.target}`,
|
|
107
|
+
),
|
|
108
|
+
).toEqual(['native:package-linux', 'native:compile-linux', 'native:build']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('adds the generic target skip tag only to nx-smart', () => {
|
|
112
|
+
expect(nxSmartArgs('test', 'affected')).toEqual([
|
|
113
|
+
'affected',
|
|
114
|
+
'-t',
|
|
115
|
+
'test',
|
|
116
|
+
'--exclude=tag:ci:skip:test',
|
|
117
|
+
'--parallel=5',
|
|
118
|
+
]);
|
|
119
|
+
expect(nxRunManyArgs({ target: 'test', projects: projects.slice(0, 1) })).not.toContain(
|
|
120
|
+
'--exclude=tag:ci:skip:test',
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('collected Nx outputs', () => {
|
|
126
|
+
it('derives artifact provenance from the checked-out HEAD', async () => {
|
|
127
|
+
const root = await mkdtemp(join(tmpdir(), 'smoo-output-head-'));
|
|
128
|
+
try {
|
|
129
|
+
await $`git init -q`.cwd(root).quiet();
|
|
130
|
+
await writeFile(join(root, 'artifact-source.txt'), 'release candidate');
|
|
131
|
+
await $`git add artifact-source.txt`.cwd(root).quiet();
|
|
132
|
+
await $`git -c user.name=Test -c user.email=test@example.com commit -q -m initial`.cwd(root).quiet();
|
|
133
|
+
const expected = decode((await $`git rev-parse HEAD`.cwd(root).quiet()).stdout).trim();
|
|
134
|
+
|
|
135
|
+
expect(await readGitHeadSha(root)).toBe(expected);
|
|
136
|
+
} finally {
|
|
137
|
+
await rm(root, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('collects an empty artifact when selected projects have no matching target', async () => {
|
|
142
|
+
await withNxRunManyFixture(async ({ root, artifact }) => {
|
|
143
|
+
await githubCiNxRunMany(root, {
|
|
144
|
+
targets: '*-linux',
|
|
145
|
+
projects: 'app',
|
|
146
|
+
collectOutputs: artifact,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const sourceSha = await readGitHeadSha(root);
|
|
150
|
+
expect(JSON.parse(await readFile(join(artifact, 'manifest.json'), 'utf8'))).toEqual({
|
|
151
|
+
version: 1,
|
|
152
|
+
sourceSha,
|
|
153
|
+
files: [],
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('applies an empty manifest after artifact transport omits its empty workspace directory', async () => {
|
|
159
|
+
await withOutputFixture(async ({ root, artifact }) => {
|
|
160
|
+
const manifest = await collectNxOutputs(root, artifact, [], SOURCE_SHA);
|
|
161
|
+
|
|
162
|
+
expect(manifest).toEqual({ version: 1, sourceSha: SOURCE_SHA, files: [] });
|
|
163
|
+
await rm(join(artifact, 'workspace'), { recursive: true });
|
|
164
|
+
await expect(applyCollectedOutputs(root, [artifact], SOURCE_SHA, [])).resolves.toBeUndefined();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('collects aggregate same-project dependency outputs once with dependency ownership', async () => {
|
|
169
|
+
await withOutputFixture(async ({ root, artifact, outputProject }) => {
|
|
170
|
+
outputProject.targets = ['build', 'compile-linux', 'package-linux'];
|
|
171
|
+
outputProject.targetDependencies = new Map([
|
|
172
|
+
['build', ['compile-linux']],
|
|
173
|
+
['compile-linux', ['package-linux']],
|
|
174
|
+
]);
|
|
175
|
+
outputProject.targetOutputs = new Map([
|
|
176
|
+
['build', ['{projectRoot}/dist']],
|
|
177
|
+
['compile-linux', ['{projectRoot}/dist/**/*.bin']],
|
|
178
|
+
['package-linux', ['{projectRoot}/dist/package/**/*.tar']],
|
|
179
|
+
]);
|
|
180
|
+
await mkdir(join(root, 'packages/app/dist/package'), { recursive: true });
|
|
181
|
+
await writeFile(join(root, 'packages/app/dist/result.bin'), 'linux binary');
|
|
182
|
+
await writeFile(join(root, 'packages/app/dist/package/release.tar'), 'linux package');
|
|
183
|
+
|
|
184
|
+
const runs = expandNxTargetDependencyRuns([{ target: 'build', projects: [outputProject] }]);
|
|
185
|
+
const manifest = await collectNxOutputs(root, artifact, runs, SOURCE_SHA);
|
|
186
|
+
|
|
187
|
+
expect(manifest.files.map((file) => `${file.project}:${file.target}:${file.path}`)).toEqual([
|
|
188
|
+
'app:package-linux:packages/app/dist/package/release.tar',
|
|
189
|
+
'app:compile-linux:packages/app/dist/result.bin',
|
|
190
|
+
]);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('collects brace-expanded TypeScript outputs without accepting unresolved Nx placeholders', async () => {
|
|
195
|
+
await withOutputFixture(async ({ root, artifact, outputProject }) => {
|
|
196
|
+
const declaredOutput = '{projectRoot}/dist/**/*.{js,cjs,mjs,jsx,d.ts,d.cts,d.mts}{,.map}';
|
|
197
|
+
outputProject.targetOutputs = new Map([['build-macos', [declaredOutput]]]);
|
|
198
|
+
await writeFile(join(root, 'packages/app/dist/index.js'), 'javascript');
|
|
199
|
+
await writeFile(join(root, 'packages/app/dist/index.d.ts'), 'declaration');
|
|
200
|
+
await writeFile(join(root, 'packages/app/dist/index.d.ts.map'), 'source map');
|
|
201
|
+
await writeFile(join(root, 'packages/app/dist/index.css'), 'not declared');
|
|
202
|
+
|
|
203
|
+
const manifest = await collectNxOutputs(
|
|
204
|
+
root,
|
|
205
|
+
artifact,
|
|
206
|
+
[{ target: 'build-macos', projects: [outputProject] }],
|
|
207
|
+
SOURCE_SHA,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
expect(resolveDeclaredOutput(declaredOutput, outputProject)).toBe(
|
|
211
|
+
'packages/app/dist/**/*.{js,cjs,mjs,jsx,d.ts,d.cts,d.mts}{,.map}',
|
|
212
|
+
);
|
|
213
|
+
expect(manifest.files.map((file) => file.path)).toEqual([
|
|
214
|
+
'packages/app/dist/index.d.ts',
|
|
215
|
+
'packages/app/dist/index.d.ts.map',
|
|
216
|
+
'packages/app/dist/index.js',
|
|
217
|
+
]);
|
|
218
|
+
expect(() => resolveDeclaredOutput('{options.outputPath}', outputProject)).toThrow(
|
|
219
|
+
'Unsupported Nx output placeholder',
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('rejects overlapping outputs from unrelated targets', async () => {
|
|
225
|
+
await withOutputFixture(async ({ root, artifact, outputProject }) => {
|
|
226
|
+
outputProject.targets = ['first-linux', 'second-linux'];
|
|
227
|
+
outputProject.targetOutputs = new Map([
|
|
228
|
+
['first-linux', ['{projectRoot}/dist/**/*.bin']],
|
|
229
|
+
['second-linux', ['{projectRoot}/dist/**/*.bin']],
|
|
230
|
+
]);
|
|
231
|
+
await writeFile(join(root, 'packages/app/dist/result.bin'), 'collision');
|
|
232
|
+
|
|
233
|
+
await expect(
|
|
234
|
+
collectNxOutputs(
|
|
235
|
+
root,
|
|
236
|
+
artifact,
|
|
237
|
+
[
|
|
238
|
+
{ target: 'first-linux', projects: [outputProject] },
|
|
239
|
+
{ target: 'second-linux', projects: [outputProject] },
|
|
240
|
+
],
|
|
241
|
+
SOURCE_SHA,
|
|
242
|
+
),
|
|
243
|
+
).rejects.toThrow('Output collision');
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('collects declared files and applies a verified overlay', async () => {
|
|
248
|
+
await withOutputFixture(async ({ root, artifact, outputProject }) => {
|
|
249
|
+
const source = join(root, 'packages/app/dist/result.bin');
|
|
250
|
+
await writeFile(source, 'native artifact');
|
|
251
|
+
|
|
252
|
+
const manifest = await collectNxOutputs(
|
|
253
|
+
root,
|
|
254
|
+
artifact,
|
|
255
|
+
[{ target: 'build-macos', projects: [outputProject] }],
|
|
256
|
+
SOURCE_SHA,
|
|
257
|
+
);
|
|
258
|
+
expect(manifest).toMatchObject({
|
|
259
|
+
version: 1,
|
|
260
|
+
sourceSha: SOURCE_SHA,
|
|
261
|
+
files: [
|
|
262
|
+
{
|
|
263
|
+
project: 'app',
|
|
264
|
+
target: 'build-macos',
|
|
265
|
+
output: '{projectRoot}/dist/**/*.bin',
|
|
266
|
+
path: 'packages/app/dist/result.bin',
|
|
267
|
+
size: 15,
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
});
|
|
271
|
+
expect(await readFile(join(artifact, 'workspace/packages/app/dist/result.bin'), 'utf8')).toBe('native artifact');
|
|
272
|
+
|
|
273
|
+
await rm(join(root, 'packages/app/dist'), { recursive: true, force: true });
|
|
274
|
+
await applyCollectedOutputs(root, [artifact], SOURCE_SHA, [outputProject]);
|
|
275
|
+
expect(await readFile(source, 'utf8')).toBe('native artifact');
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('rejects missing, escaping, and symlinked declared outputs', async () => {
|
|
280
|
+
await withOutputFixture(async ({ root, artifact, outputProject, temp }) => {
|
|
281
|
+
await expect(
|
|
282
|
+
collectNxOutputs(root, artifact, [{ target: 'build-macos', projects: [outputProject] }], SOURCE_SHA),
|
|
283
|
+
).rejects.toThrow('is missing');
|
|
284
|
+
|
|
285
|
+
outputProject.targetOutputs = new Map([['build-macos', ['../outside']]]);
|
|
286
|
+
await expect(
|
|
287
|
+
collectNxOutputs(root, artifact, [{ target: 'build-macos', projects: [outputProject] }], SOURCE_SHA),
|
|
288
|
+
).rejects.toThrow('escapes the workspace');
|
|
289
|
+
|
|
290
|
+
const outside = join(temp, 'outside-output');
|
|
291
|
+
await mkdir(join(outside, 'dist'), { recursive: true });
|
|
292
|
+
await writeFile(join(outside, 'dist/result.bin'), 'outside');
|
|
293
|
+
await rm(join(root, 'packages/app'), { recursive: true, force: true });
|
|
294
|
+
await symlink(outside, join(root, 'packages/app'), 'dir');
|
|
295
|
+
outputProject.targetOutputs = new Map([['build-macos', ['{projectRoot}/dist/**/*.bin']]]);
|
|
296
|
+
await expect(
|
|
297
|
+
collectNxOutputs(root, artifact, [{ target: 'build-macos', projects: [outputProject] }], SOURCE_SHA),
|
|
298
|
+
).rejects.toThrow('symbolic link');
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('rejects checksum corruption, source mismatches, and undeclared staged files', async () => {
|
|
303
|
+
await withOutputFixture(async ({ root, artifact, outputProject }) => {
|
|
304
|
+
await writeFile(join(root, 'packages/app/dist/result.bin'), 'native artifact');
|
|
305
|
+
await collectNxOutputs(root, artifact, [{ target: 'build-macos', projects: [outputProject] }], SOURCE_SHA);
|
|
306
|
+
|
|
307
|
+
await expect(applyCollectedOutputs(root, [artifact], OTHER_SHA, [outputProject])).rejects.toThrow(
|
|
308
|
+
'Source SHA mismatch',
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const staged = join(artifact, 'workspace/packages/app/dist/result.bin');
|
|
312
|
+
await writeFile(staged, 'corrupt artifact');
|
|
313
|
+
await expect(applyCollectedOutputs(root, [artifact], SOURCE_SHA, [outputProject])).rejects.toThrow(/mismatch/);
|
|
314
|
+
|
|
315
|
+
await writeFile(staged, 'native artifact');
|
|
316
|
+
await writeFile(join(artifact, 'workspace/undeclared.txt'), 'extra');
|
|
317
|
+
await expect(applyCollectedOutputs(root, [artifact], SOURCE_SHA, [outputProject])).rejects.toThrow(
|
|
318
|
+
'Undeclared staged output',
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('rejects path injection, exact-shape violations, and collisions before overlaying', async () => {
|
|
324
|
+
await withOutputFixture(async ({ root, artifact, outputProject, temp }) => {
|
|
325
|
+
await writeFile(join(root, 'packages/app/dist/result.bin'), 'native artifact');
|
|
326
|
+
const manifest = await collectNxOutputs(
|
|
327
|
+
root,
|
|
328
|
+
artifact,
|
|
329
|
+
[{ target: 'build-macos', projects: [outputProject] }],
|
|
330
|
+
SOURCE_SHA,
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
const malicious: CollectedOutputsManifest = {
|
|
334
|
+
...manifest,
|
|
335
|
+
files: manifest.files.map((file, index) => (index === 0 ? { ...file, path: '../escape.bin' } : file)),
|
|
336
|
+
};
|
|
337
|
+
await writeFile(join(artifact, 'manifest.json'), `${JSON.stringify(malicious)}\n`);
|
|
338
|
+
await expect(applyCollectedOutputs(root, [artifact], SOURCE_SHA, [outputProject])).rejects.toThrow(
|
|
339
|
+
'escapes the workspace',
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
await writeFile(join(artifact, 'manifest.json'), `${JSON.stringify({ ...manifest, unexpected: true })}\n`);
|
|
343
|
+
await expect(applyCollectedOutputs(root, [artifact], SOURCE_SHA, [outputProject])).rejects.toThrow(
|
|
344
|
+
'Invalid collected output manifest',
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
await writeFile(
|
|
348
|
+
join(artifact, 'manifest.json'),
|
|
349
|
+
`${JSON.stringify({
|
|
350
|
+
...manifest,
|
|
351
|
+
files: manifest.files.map((file) => ({ ...file, output: '{workspaceRoot}/**/*' })),
|
|
352
|
+
})}\n`,
|
|
353
|
+
);
|
|
354
|
+
await expect(applyCollectedOutputs(root, [artifact], SOURCE_SHA, [outputProject])).rejects.toThrow(
|
|
355
|
+
'is not declared by Nx target',
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
await writeFile(
|
|
359
|
+
join(artifact, 'manifest.json'),
|
|
360
|
+
`${JSON.stringify({
|
|
361
|
+
...manifest,
|
|
362
|
+
files: manifest.files.map((file) => ({ ...file, project: 'unknown-project' })),
|
|
363
|
+
})}\n`,
|
|
364
|
+
);
|
|
365
|
+
await expect(applyCollectedOutputs(root, [artifact], SOURCE_SHA, [outputProject])).rejects.toThrow(
|
|
366
|
+
'unknown Nx project',
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
await writeFile(
|
|
370
|
+
join(artifact, 'manifest.json'),
|
|
371
|
+
`${JSON.stringify({
|
|
372
|
+
...manifest,
|
|
373
|
+
files: manifest.files.map((file) => ({ ...file, target: '*-macos' })),
|
|
374
|
+
})}\n`,
|
|
375
|
+
);
|
|
376
|
+
await expect(applyCollectedOutputs(root, [artifact], SOURCE_SHA, [outputProject])).rejects.toThrow(
|
|
377
|
+
'must be an exact Nx name',
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
await writeFile(join(artifact, 'manifest.json'), `${JSON.stringify(manifest)}\n`);
|
|
381
|
+
const secondArtifact = join(temp, 'artifact-two');
|
|
382
|
+
await mkdir(secondArtifact, { recursive: true });
|
|
383
|
+
await Bun.write(join(secondArtifact, 'manifest.json'), JSON.stringify(manifest));
|
|
384
|
+
await mkdir(join(secondArtifact, 'workspace/packages/app/dist'), { recursive: true });
|
|
385
|
+
await Bun.write(join(secondArtifact, 'workspace/packages/app/dist/result.bin'), 'native artifact');
|
|
386
|
+
await expect(
|
|
387
|
+
applyCollectedOutputs(root, [artifact, secondArtifact], SOURCE_SHA, [outputProject]),
|
|
388
|
+
).rejects.toThrow('Output collision across collected trees');
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('rejects non-hex source SHAs', async () => {
|
|
393
|
+
await withOutputFixture(async ({ root, artifact, outputProject }) => {
|
|
394
|
+
await expect(
|
|
395
|
+
collectNxOutputs(root, artifact, [{ target: 'build-macos', projects: [outputProject] }], 'not-a-git-sha'),
|
|
396
|
+
).rejects.toThrow('40- or 64-character hexadecimal Git SHA');
|
|
397
|
+
await expect(applyCollectedOutputs(root, [artifact], 'not-a-git-sha', [outputProject])).rejects.toThrow(
|
|
398
|
+
'40- or 64-character hexadecimal Git SHA',
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('rejects symlinks in destination ancestors and destination files', async () => {
|
|
404
|
+
await withOutputFixture(async ({ root, artifact, outputProject, temp }) => {
|
|
405
|
+
await writeFile(join(root, 'packages/app/dist/result.bin'), 'native artifact');
|
|
406
|
+
await collectNxOutputs(root, artifact, [{ target: 'build-macos', projects: [outputProject] }], SOURCE_SHA);
|
|
407
|
+
|
|
408
|
+
const outside = join(temp, 'outside');
|
|
409
|
+
await mkdir(outside);
|
|
410
|
+
await rm(join(root, 'packages/app'), { recursive: true, force: true });
|
|
411
|
+
await symlink(outside, join(root, 'packages/app'), 'dir');
|
|
412
|
+
|
|
413
|
+
await expect(applyCollectedOutputs(root, [artifact], SOURCE_SHA, [outputProject])).rejects.toThrow(
|
|
414
|
+
'symbolic link',
|
|
415
|
+
);
|
|
416
|
+
expect(await Bun.file(join(outside, 'dist/result.bin')).exists()).toBe(false);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
await withOutputFixture(async ({ root, artifact, outputProject, temp }) => {
|
|
420
|
+
const output = join(root, 'packages/app/dist/result.bin');
|
|
421
|
+
await writeFile(output, 'native artifact');
|
|
422
|
+
await collectNxOutputs(root, artifact, [{ target: 'build-macos', projects: [outputProject] }], SOURCE_SHA);
|
|
423
|
+
|
|
424
|
+
const outside = join(temp, 'outside.bin');
|
|
425
|
+
await writeFile(outside, 'must remain unchanged');
|
|
426
|
+
await rm(output);
|
|
427
|
+
await symlink(outside, output, 'file');
|
|
428
|
+
|
|
429
|
+
await expect(applyCollectedOutputs(root, [artifact], SOURCE_SHA, [outputProject])).rejects.toThrow(
|
|
430
|
+
'symbolic link',
|
|
431
|
+
);
|
|
432
|
+
expect(await readFile(outside, 'utf8')).toBe('must remain unchanged');
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
async function withOutputFixture(
|
|
438
|
+
run: (fixture: { root: string; artifact: string; outputProject: ProjectTargets; temp: string }) => Promise<void>,
|
|
439
|
+
): Promise<void> {
|
|
440
|
+
const temp = await mkdtemp(join(tmpdir(), 'smoo-platform-output-'));
|
|
441
|
+
const root = join(temp, 'repo');
|
|
442
|
+
const artifact = join(temp, 'artifact');
|
|
443
|
+
const outputProject: ProjectTargets = {
|
|
444
|
+
project: 'app',
|
|
445
|
+
root: 'packages/app',
|
|
446
|
+
targets: ['build-macos'],
|
|
447
|
+
targetOutputs: new Map([['build-macos', ['{projectRoot}/dist/**/*.bin']]]),
|
|
448
|
+
};
|
|
449
|
+
try {
|
|
450
|
+
await mkdir(join(root, 'packages/app/dist'), { recursive: true });
|
|
451
|
+
await run({ root, artifact, outputProject, temp });
|
|
452
|
+
} finally {
|
|
453
|
+
await rm(temp, { recursive: true, force: true });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function withNxRunManyFixture(
|
|
458
|
+
run: (fixture: { root: string; artifact: string }) => Promise<void>,
|
|
459
|
+
): Promise<void> {
|
|
460
|
+
const temp = await mkdtemp(join(tmpdir(), 'smoo-empty-platform-output-'));
|
|
461
|
+
const root = join(temp, 'repo');
|
|
462
|
+
const artifact = join(temp, 'artifact');
|
|
463
|
+
try {
|
|
464
|
+
await mkdir(join(root, 'packages/app'), { recursive: true });
|
|
465
|
+
await symlink(join(import.meta.dir, '../../../../node_modules'), join(root, 'node_modules'), 'dir');
|
|
466
|
+
await writeFile(
|
|
467
|
+
join(root, 'package.json'),
|
|
468
|
+
JSON.stringify({ name: '@fixture/root', private: true, workspaces: ['packages/*'] }),
|
|
469
|
+
);
|
|
470
|
+
await writeFile(join(root, 'nx.json'), '{}');
|
|
471
|
+
await writeFile(
|
|
472
|
+
join(root, 'packages/app/package.json'),
|
|
473
|
+
JSON.stringify({
|
|
474
|
+
name: '@fixture/app',
|
|
475
|
+
nx: { name: 'app', targets: { build: { executor: 'nx:noop' } } },
|
|
476
|
+
}),
|
|
477
|
+
);
|
|
478
|
+
await $`git init --quiet`.cwd(root);
|
|
479
|
+
await $`git add .`.cwd(root).quiet();
|
|
480
|
+
await $`git -c user.name=Test -c user.email=test@example.com commit --quiet -m fixture`.cwd(root);
|
|
481
|
+
|
|
482
|
+
await run({ root, artifact });
|
|
483
|
+
} finally {
|
|
484
|
+
await rm(temp, { recursive: true, force: true });
|
|
485
|
+
}
|
|
486
|
+
}
|