@smoothbricks/cli 0.11.14 → 0.11.16
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/dist/lib/json.d.ts +9 -0
- package/dist/lib/json.d.ts.map +1 -1
- package/dist/lib/json.js +65 -53
- package/dist/monorepo/managed-files.d.ts +9 -0
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +28 -2
- package/dist/monorepo/publish-workflow.d.ts.map +1 -1
- package/dist/monorepo/publish-workflow.js +52 -2
- package/dist/release/github-release.d.ts +16 -0
- package/dist/release/github-release.d.ts.map +1 -1
- package/dist/release/github-release.js +11 -2
- package/dist/release/index.d.ts +1 -1
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +29 -7
- package/dist/release/private-npm.d.ts +5 -0
- package/dist/release/private-npm.d.ts.map +1 -1
- package/dist/release/private-npm.js +26 -5
- package/managed/raw/tooling/direnv/devenv.smoo.nix +4 -2
- package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +38 -0
- package/managed/raw/tooling/direnv/secret-references.ts +66 -0
- package/managed/raw/tooling/direnv/setup-environment.ts +81 -26
- package/managed/raw/tooling/direnv/toolchain-stamp.ts +88 -0
- package/managed/templates/github/actions/setup-devenv/action.yml +4 -2
- package/package.json +2 -2
- package/src/lib/json.ts +9 -0
- package/src/monorepo/__tests__/publish-workflow.test.ts +54 -0
- package/src/monorepo/managed-files.test.ts +26 -0
- package/src/monorepo/managed-files.ts +36 -2
- package/src/monorepo/publish-workflow.ts +70 -2
- package/src/monorepo/secret-references.test.ts +68 -0
- package/src/release/__tests__/private-npm-status.test.ts +37 -0
- package/src/release/__tests__/private-npm-workflow.test.ts +20 -0
- package/src/release/github-release.ts +31 -2
- package/src/release/index.ts +30 -4
- package/src/release/private-npm.ts +30 -5
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Invalidate cmake build-script caches when the toolchain changes.
|
|
4
|
+
*
|
|
5
|
+
* cmake-rs keeps CMAKE_OSX_SYSROOT and the compilers in CMakeCache.txt from
|
|
6
|
+
* the FIRST configure and only clears a build directory when the source path
|
|
7
|
+
* moves. Every C/C++ build script's cache under target/ therefore encodes
|
|
8
|
+
* whichever toolchain the shell had when it was first configured, and it
|
|
9
|
+
* outlives every later fix to that shell: a nix-store sysroot cached on one
|
|
10
|
+
* day kept failing `ld: library 'c++' not found` after the shell stopped
|
|
11
|
+
* exporting it. Nx does not see this state either — it hashes inputs, not
|
|
12
|
+
* what a cache under target/ remembers.
|
|
13
|
+
*
|
|
14
|
+
* Runs at the end of shell entry, after the project's own enterShell resolved
|
|
15
|
+
* SDKROOT and the compilers. It records the toolchain identity the shell
|
|
16
|
+
* settled on and, when that identity differs from the recorded one, removes
|
|
17
|
+
* exactly the cargo build-script directories that hold a CMakeCache.txt, so
|
|
18
|
+
* cargo re-runs those scripts against the current toolchain. Nothing else
|
|
19
|
+
* under target/ is touched; a checkout without target/ has nothing to do.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { join, resolve } from 'node:path';
|
|
23
|
+
import { $ } from 'bun';
|
|
24
|
+
|
|
25
|
+
const root = resolve(`${process.env.DEVENV_ROOT ?? process.cwd()}/../..`);
|
|
26
|
+
const target = join(root, 'target');
|
|
27
|
+
if (existsSync(target)) {
|
|
28
|
+
const stamp = join(target, '.toolchain-stamp');
|
|
29
|
+
const identity = await toolchainIdentity();
|
|
30
|
+
const recorded = existsSync(stamp) ? readFileSync(stamp, 'utf8') : null;
|
|
31
|
+
if (recorded !== identity) {
|
|
32
|
+
const removed = removeCmakeBuildScriptDirs(target);
|
|
33
|
+
if (removed > 0) {
|
|
34
|
+
console.error(`devenv: toolchain identity changed; removed ${removed} cmake build-script cache(s) under target/`);
|
|
35
|
+
}
|
|
36
|
+
writeFileSync(stamp, identity);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function toolchainIdentity(): Promise<string> {
|
|
41
|
+
const rustc = await $`rustc -Vv`.quiet().nothrow().text();
|
|
42
|
+
const cc = (await $`cc --version`.quiet().nothrow().text()).split('\n')[0] ?? '';
|
|
43
|
+
return [
|
|
44
|
+
rustc.trim(),
|
|
45
|
+
`SDKROOT=${process.env.SDKROOT ?? ''}`,
|
|
46
|
+
`DEVELOPER_DIR=${process.env.DEVELOPER_DIR ?? ''}`,
|
|
47
|
+
cc,
|
|
48
|
+
].join('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Removes every cargo build-script directory (`…/build/<crate>-<hash>`) whose
|
|
53
|
+
* `out/build/CMakeCache.txt` exists; returns the count. Only the `build/`
|
|
54
|
+
* directories cargo creates under a profile are scanned - never the compiled
|
|
55
|
+
* artifacts beside them - and the scan completes before anything is removed.
|
|
56
|
+
*/
|
|
57
|
+
function removeCmakeBuildScriptDirs(target: string): number {
|
|
58
|
+
const stale: string[] = [];
|
|
59
|
+
for (const buildDir of cargoBuildDirs(target, 0)) {
|
|
60
|
+
for (const entry of readdirSync(buildDir, { withFileTypes: true })) {
|
|
61
|
+
if (entry.isDirectory() && existsSync(join(buildDir, entry.name, 'out', 'build', 'CMakeCache.txt'))) {
|
|
62
|
+
stale.push(join(buildDir, entry.name));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const dir of stale) {
|
|
67
|
+
rmSync(dir, { recursive: true, force: true });
|
|
68
|
+
}
|
|
69
|
+
return stale.length;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** `target/[<lane>/][<triple>/]<profile>/build` - at most four levels below target/. */
|
|
73
|
+
function* cargoBuildDirs(dir: string, depth: number): Generator<string> {
|
|
74
|
+
if (depth > 4) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
78
|
+
if (!entry.isDirectory()) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const path = join(dir, entry.name);
|
|
82
|
+
if (entry.name === 'build' && depth > 0) {
|
|
83
|
+
yield path;
|
|
84
|
+
} else if (entry.name !== 'deps' && entry.name !== 'incremental' && entry.name !== '.fingerprint') {
|
|
85
|
+
yield* cargoBuildDirs(path, depth + 1);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -200,8 +200,8 @@ runs:
|
|
|
200
200
|
/nix
|
|
201
201
|
~/.cache/nix
|
|
202
202
|
# prettier-ignore
|
|
203
|
-
primary-key: ${{ runner.os }}-${{ runner.arch }}-nix-
|
|
204
|
-
restore-prefixes-first-match: ${{ runner.os }}-${{ runner.arch }}-nix-
|
|
203
|
+
primary-key: ${{ runner.os }}-${{ runner.arch }}-nix-v6-${{ hashFiles('tooling/direnv/devenv.yaml', 'tooling/direnv/devenv.nix', 'tooling/direnv/devenv.lock') }}
|
|
204
|
+
restore-prefixes-first-match: ${{ runner.os }}-${{ runner.arch }}-nix-v6-
|
|
205
205
|
gc-max-store-size: 1G
|
|
206
206
|
|
|
207
207
|
- name: ⚡ Enable Cachix
|
|
@@ -236,4 +236,6 @@ runs:
|
|
|
236
236
|
- name: 🐚 Build devenv shell
|
|
237
237
|
shell: bash
|
|
238
238
|
working-directory: ${{ env.DEVENV_WORKDIR }}/tooling/direnv
|
|
239
|
+
env:
|
|
240
|
+
SMOO_ROOT_BUILD_INPUTS: ${{ steps.runner-kind.outputs.host != 'true' }}
|
|
239
241
|
run: ./github-actions-bootstrap.sh build-shell
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smoothbricks/cli",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.16",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SmoothBricks monorepo automation CLI",
|
|
6
6
|
"bin": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
],
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@arethetypeswrong/core": "^0.18.2",
|
|
67
|
-
"@smoothbricks/nx-plugin": "0.4.
|
|
67
|
+
"@smoothbricks/nx-plugin": "0.4.10",
|
|
68
68
|
"@smoothbricks/validation": "0.1.8",
|
|
69
69
|
"commander": "^14.0.3",
|
|
70
70
|
"make-synchronized": "^0.8.0",
|
package/src/lib/json.ts
CHANGED
|
@@ -77,6 +77,15 @@ export interface PackageSmooGithub {
|
|
|
77
77
|
/** Toolchain environment on the Linux foreign-platform producer, such as an SDK path. */
|
|
78
78
|
env?: StringMap;
|
|
79
79
|
};
|
|
80
|
+
/**
|
|
81
|
+
* Platform target families the RELEASE workflow must not produce, as target
|
|
82
|
+
* globs (`*-macos`, `*-ios`, `*-linux`). A repository whose Nx graph carries
|
|
83
|
+
* a family it does not release from `publish.yml` — because another workflow
|
|
84
|
+
* owns it, or because the leg is not production yet — names it here, and the
|
|
85
|
+
* publish workflow renders without that job instead of carrying a leg whose
|
|
86
|
+
* failure blocks every release.
|
|
87
|
+
*/
|
|
88
|
+
releasePlatformFamiliesExcluded?: string[];
|
|
80
89
|
/**
|
|
81
90
|
* Sibling source checkouts a repo path-depends on (Cargo path
|
|
82
91
|
* dependencies, nix path inputs). The CI workflow clones each entry beside
|
|
@@ -101,6 +101,60 @@ describe('publish workflow definition', () => {
|
|
|
101
101
|
expect(rendered).toContain("default: ''");
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
it('a private forge release with cross-built Apple targets publishes from one job', () => {
|
|
105
|
+
const rendered = renderPublishWorkflowYaml({
|
|
106
|
+
repoName: 'axe.sc/axe',
|
|
107
|
+
actionsProvider: 'forgejo',
|
|
108
|
+
runsOn: ['nixos-latest-x64', 'self-hosted'],
|
|
109
|
+
platformTargetGlobs: ['*-macos', '*-linux'],
|
|
110
|
+
macosPlatformArchitectures: ['arm64'],
|
|
111
|
+
platformProducer: { kind: 'linux-cross', preflight: 'sh scripts/prepare-macos-sdk.sh', env: { AXE_CROSS: '1' } },
|
|
112
|
+
privateNpm: { scope: '@axe.sc', readTokenEnv: 'READ_ENV', publishTokenEnv: 'PUBLISH_ENV' },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// One job: no producer job to hand outputs over from, so no transfer at all.
|
|
116
|
+
const jobsYaml = rendered.slice(rendered.indexOf('jobs:\n') + 'jobs:\n'.length);
|
|
117
|
+
expect(jobsYaml.match(/^ {2}[a-z][a-z0-9-]*:$/gm)).toEqual([' publish:']);
|
|
118
|
+
// The hand-off steps specifically; the failure-only trace-DB upload stays.
|
|
119
|
+
expect(rendered).not.toContain('Upload validated release state');
|
|
120
|
+
expect(rendered).not.toContain('Upload validated build outputs');
|
|
121
|
+
expect(rendered).not.toContain('Upload macOS platform outputs');
|
|
122
|
+
expect(rendered).not.toContain('Download candidate artifacts');
|
|
123
|
+
expect(rendered).not.toContain('git bundle');
|
|
124
|
+
// It still builds the Apple targets, with the producer's toolchain env and
|
|
125
|
+
// its preflight ahead of setup.
|
|
126
|
+
expect(rendered).toContain('--targets "*-macos"');
|
|
127
|
+
expect(rendered).toMatch(/^ {6}AXE_CROSS: ['"]1['"]$/m);
|
|
128
|
+
expect(rendered).toContain('Check cross-platform toolchain prerequisites');
|
|
129
|
+
expect(rendered.indexOf('Check cross-platform toolchain prerequisites')).toBeLessThan(
|
|
130
|
+
rendered.indexOf('Setup Nix/devenv'),
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('keeps the publishing hand-off when provenance needs the hosted runner', () => {
|
|
135
|
+
const publicWithMac = renderPublishWorkflowYaml({
|
|
136
|
+
repoName: '@smoothbricks/codebase',
|
|
137
|
+
platformTargetGlobs: ['*-macos', '*-linux'],
|
|
138
|
+
macosPlatformArchitectures: ['arm64'],
|
|
139
|
+
platformProducer: { kind: 'linux-cross', preflight: 'sh prepare.sh' },
|
|
140
|
+
});
|
|
141
|
+
const forgePublicWithMac = renderPublishWorkflowYaml({
|
|
142
|
+
repoName: '@smoothbricks/codebase',
|
|
143
|
+
actionsProvider: 'forgejo',
|
|
144
|
+
runsOn: ['nixos-latest-x64', 'self-hosted'],
|
|
145
|
+
platformTargetGlobs: ['*-macos', '*-linux'],
|
|
146
|
+
macosPlatformArchitectures: ['arm64'],
|
|
147
|
+
platformProducer: { kind: 'linux-cross', preflight: 'sh prepare.sh' },
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// No private registry declared: packages go to npmjs, which mints
|
|
151
|
+
// provenance from the publishing job and refuses a self-hosted runner.
|
|
152
|
+
for (const rendered of [publicWithMac, forgePublicWithMac]) {
|
|
153
|
+
expect(rendered).toContain('publish-on-linux:');
|
|
154
|
+
expect(rendered).toContain('Download candidate artifacts');
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
104
158
|
it('preserves the single Ubuntu job and renders no artifact transfer when no Apple targets exist', () => {
|
|
105
159
|
const current = renderPublishWorkflowYaml({ repoName: '@smoothbricks/codebase' });
|
|
106
160
|
const explicitlyEmpty = renderPublishWorkflowYaml({
|
|
@@ -20,10 +20,12 @@ import {
|
|
|
20
20
|
managedFileTargetsForTest,
|
|
21
21
|
platformTargetGlobsForTest,
|
|
22
22
|
reinsertInlineLocalBlocksForTest,
|
|
23
|
+
releasePlatformTargetGlobsFor,
|
|
23
24
|
renderManagedWorkflowForTest,
|
|
24
25
|
splitLocalSectionForTest,
|
|
25
26
|
validateDevenvModuleImport,
|
|
26
27
|
} from './managed-files.js';
|
|
28
|
+
import { renderPublishWorkflowYaml } from './publish-workflow.js';
|
|
27
29
|
|
|
28
30
|
const MANAGED = '# managed content\npath merge=driver\n';
|
|
29
31
|
|
|
@@ -457,6 +459,7 @@ const context = (overrides: Partial<ManagedFileContext>): ManagedFileContext =>
|
|
|
457
459
|
nodeModulesCacheKey: 'key',
|
|
458
460
|
repoName: '@scope/repo',
|
|
459
461
|
platformTargetGlobs: [],
|
|
462
|
+
releasePlatformTargetGlobs: [],
|
|
460
463
|
macosPlatformArchitectures: [],
|
|
461
464
|
...overrides,
|
|
462
465
|
});
|
|
@@ -565,3 +568,26 @@ describe('CI workflow rendering by repo shape', () => {
|
|
|
565
568
|
expect(rendered).toContain('GIT_CRYPT_KEY_B64: ${{ secrets.GIT_CRYPT_KEY_B64 }}');
|
|
566
569
|
});
|
|
567
570
|
});
|
|
571
|
+
|
|
572
|
+
describe('release platform families', () => {
|
|
573
|
+
it('an excluded family is not produced by the release workflow, and the rest still are', () => {
|
|
574
|
+
expect(releasePlatformTargetGlobsFor(['*-macos', '*-ios', '*-linux'], ['*-macos', '*-ios'])).toEqual(['*-linux']);
|
|
575
|
+
expect(releasePlatformTargetGlobsFor(PLATFORM_TARGET_GLOBS, undefined)).toEqual([...PLATFORM_TARGET_GLOBS]);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('excluding every Apple family renders the single-job Linux publish shape', () => {
|
|
579
|
+
const globs = releasePlatformTargetGlobsFor(['*-macos', '*-linux'], ['*-macos']);
|
|
580
|
+
const rendered = renderPublishWorkflowYaml({
|
|
581
|
+
repoName: 'axe.sc/axe',
|
|
582
|
+
platformTargetGlobs: globs,
|
|
583
|
+
macosPlatformArchitectures: [],
|
|
584
|
+
runsOn: ['nixos-latest-x64', 'self-hosted'],
|
|
585
|
+
actionsProvider: 'forgejo',
|
|
586
|
+
});
|
|
587
|
+
expect(rendered).not.toContain('cross-platform:');
|
|
588
|
+
expect(rendered).not.toContain('macos-platform:');
|
|
589
|
+
expect(rendered).not.toContain('SDKROOT');
|
|
590
|
+
expect(rendered).not.toContain('--targets "*-macos"');
|
|
591
|
+
expect(rendered).toContain(' publish:');
|
|
592
|
+
});
|
|
593
|
+
});
|
|
@@ -170,6 +170,8 @@ export interface ManagedFileContext {
|
|
|
170
170
|
nodeModulesCacheKey: string;
|
|
171
171
|
repoName: string;
|
|
172
172
|
platformTargetGlobs: string[];
|
|
173
|
+
/** Platform families the release workflow produces; excluded families are absent. */
|
|
174
|
+
releasePlatformTargetGlobs: string[];
|
|
173
175
|
macosPlatformArchitectures: string[];
|
|
174
176
|
/** Declared private-npm opt-in from the root smoo config; absent means fully public. */
|
|
175
177
|
privateNpm?: PackagePrivateNpmConfig;
|
|
@@ -221,6 +223,12 @@ const managedFiles: ManagedFile[] = [
|
|
|
221
223
|
source: 'tooling/direnv/secret-references.ts',
|
|
222
224
|
target: 'tooling/direnv/secret-references.ts',
|
|
223
225
|
},
|
|
226
|
+
{
|
|
227
|
+
kind: 'raw',
|
|
228
|
+
source: 'tooling/direnv/toolchain-stamp.ts',
|
|
229
|
+
target: 'tooling/direnv/toolchain-stamp.ts',
|
|
230
|
+
executable: true,
|
|
231
|
+
},
|
|
224
232
|
{
|
|
225
233
|
kind: 'raw',
|
|
226
234
|
source: 'tooling/direnv/devenv.smoo.nix',
|
|
@@ -409,7 +417,7 @@ function getManagedContent(file: ManagedFile, context: ManagedFileContext): stri
|
|
|
409
417
|
release: context.hasReleasePackages,
|
|
410
418
|
deployProvider: context.productionDeployProvider,
|
|
411
419
|
repoName: context.repoName,
|
|
412
|
-
platformTargetGlobs: context.
|
|
420
|
+
platformTargetGlobs: context.releasePlatformTargetGlobs,
|
|
413
421
|
macosPlatformArchitectures: context.macosPlatformArchitectures,
|
|
414
422
|
runsOn: context.ciRunsOn,
|
|
415
423
|
macosRunsOn: context.macosRunsOn,
|
|
@@ -451,6 +459,14 @@ async function getManagedFileContext(root: string): Promise<ManagedFileContext>
|
|
|
451
459
|
const productionDeploy = deployTargetInfoFromProjects(nxProjects, 'production');
|
|
452
460
|
const targetNames = targetNamesFromProjects(nxProjects);
|
|
453
461
|
const platformTargetGlobs = platformTargetGlobsForTest(targetNames);
|
|
462
|
+
// The release workflow produces every platform family the graph carries
|
|
463
|
+
// EXCEPT the ones the repository excludes. An excluded family renders no job
|
|
464
|
+
// at all rather than a job whose failure blocks every release; the workflow
|
|
465
|
+
// that does own it says so itself.
|
|
466
|
+
const releasePlatformTargetGlobs = releasePlatformTargetGlobsFor(
|
|
467
|
+
platformTargetGlobs,
|
|
468
|
+
github?.releasePlatformFamiliesExcluded,
|
|
469
|
+
);
|
|
454
470
|
const privateNpm = resolvePrivateNpmWorkflowConfig(root);
|
|
455
471
|
// Cache registry identity plus the lockfile, never token values: a scope
|
|
456
472
|
// URL change in the committed .npmrc must invalidate the dependency cache,
|
|
@@ -479,10 +495,13 @@ async function getManagedFileContext(root: string): Promise<ManagedFileContext>
|
|
|
479
495
|
nodeModulesCacheKey,
|
|
480
496
|
repoName,
|
|
481
497
|
platformTargetGlobs,
|
|
498
|
+
releasePlatformTargetGlobs,
|
|
482
499
|
sourceCheckouts,
|
|
483
500
|
cargoCredentials,
|
|
484
501
|
remoteCache: manifest?.smoo?.remoteCache,
|
|
485
|
-
macosPlatformArchitectures:
|
|
502
|
+
macosPlatformArchitectures: MACOS_PLATFORM_TARGET_GLOBS.some((glob) => releasePlatformTargetGlobs.includes(glob))
|
|
503
|
+
? macosPlatformArchitecturesForTest(targetNames)
|
|
504
|
+
: [],
|
|
486
505
|
privateNpm,
|
|
487
506
|
};
|
|
488
507
|
}
|
|
@@ -491,6 +510,21 @@ export function hasExactTargetForTest(targetNames: Iterable<string>, target: str
|
|
|
491
510
|
return [...targetNames].includes(target);
|
|
492
511
|
}
|
|
493
512
|
|
|
513
|
+
/**
|
|
514
|
+
* The platform families the release workflow produces: the graph's families
|
|
515
|
+
* minus the ones the repository excludes. Excluding a family the graph does not
|
|
516
|
+
* have is not an error — a repository states what it does not release, and the
|
|
517
|
+
* statement stays true when the target is added later.
|
|
518
|
+
*/
|
|
519
|
+
export function releasePlatformTargetGlobsFor(
|
|
520
|
+
platformTargetGlobs: readonly string[],
|
|
521
|
+
excluded: readonly string[] | undefined,
|
|
522
|
+
): string[] {
|
|
523
|
+
if (!excluded || excluded.length === 0) return [...platformTargetGlobs];
|
|
524
|
+
const drop = new Set(excluded);
|
|
525
|
+
return platformTargetGlobs.filter((glob) => !drop.has(glob));
|
|
526
|
+
}
|
|
527
|
+
|
|
494
528
|
export function platformTargetGlobsForTest(targetNames: Iterable<string>): string[] {
|
|
495
529
|
const names = [...targetNames];
|
|
496
530
|
return PLATFORM_TARGET_GLOBS.filter((glob) => {
|
|
@@ -404,8 +404,17 @@ export function renderPublishWorkflowYaml(options: PublishWorkflowDefinitionOpti
|
|
|
404
404
|
if (options.release === false) {
|
|
405
405
|
const steps = definePublishWorkflow(options).steps;
|
|
406
406
|
workflow = `${renderPublishWorkflowHeader(options)}${renderPublishWorkflowSteps(steps, options)}`;
|
|
407
|
-
} else if (hasMacosPlatformTargets(options)) {
|
|
407
|
+
} else if (hasMacosPlatformTargets(options) && !skipsPublishHandoff(options)) {
|
|
408
408
|
workflow = renderPlatformPublishWorkflowYaml(options);
|
|
409
|
+
} else if (hasMacosPlatformTargets(options)) {
|
|
410
|
+
// A linux-cross producer runs the Apple targets on the publisher's own
|
|
411
|
+
// runner. Splitting jobs then buys nothing and costs a real transfer: the
|
|
412
|
+
// outputs get tarred, uploaded, downloaded into a second checkout, and two
|
|
413
|
+
// more devenv setups pay for themselves twice. One job, no artifacts.
|
|
414
|
+
workflow = `${renderPublishWorkflowHeader(options)}${renderSingleJobPublishWorkflowSteps(
|
|
415
|
+
definePublishWorkflow(options).steps,
|
|
416
|
+
options,
|
|
417
|
+
)}`;
|
|
409
418
|
} else if (hasLinuxPlatformTargets(options)) {
|
|
410
419
|
workflow = `${renderPublishWorkflowHeader(options)}${renderSingleJobPublishWorkflowSteps(
|
|
411
420
|
definePublishWorkflow(options).steps,
|
|
@@ -475,7 +484,7 @@ jobs:
|
|
|
475
484
|
publish:
|
|
476
485
|
${options.release === false ? renderRunsOnLine(options.runsOn) : publishJobRunsOnLine(options)}
|
|
477
486
|
env:
|
|
478
|
-
GH_TOKEN: ${githubExpression('github.token')}${remoteCacheJobEnv(options)}${cargoCredentialsJobEnv(options)}${privateNpmInstallJobEnv(options)}
|
|
487
|
+
GH_TOKEN: ${githubExpression('github.token')}${remoteCacheJobEnv(options)}${cargoCredentialsJobEnv(options)}${privateNpmInstallJobEnv(options)}${platformProducerJobEnv(options)}
|
|
479
488
|
steps:
|
|
480
489
|
`;
|
|
481
490
|
}
|
|
@@ -749,11 +758,25 @@ function renderSingleJobPublishWorkflowSteps(
|
|
|
749
758
|
options: PublishWorkflowDefinitionOptions,
|
|
750
759
|
): string {
|
|
751
760
|
const lines: string[] = [];
|
|
761
|
+
const producer = options.platformProducer;
|
|
762
|
+
const crossOnThisRunner = skipsPublishHandoff(options) && hasMacosPlatformTargets(options);
|
|
752
763
|
for (const step of steps) {
|
|
753
764
|
lines.push(...sectionLinesBefore(step));
|
|
754
765
|
lines.push(...commentLinesForStep(step));
|
|
755
766
|
lines.push(...yamlLinesForStep(step, options));
|
|
756
767
|
lines.push('');
|
|
768
|
+
if (crossOnThisRunner && producer && step.kind === PublishWorkflowStepKind.Checkout) {
|
|
769
|
+
// Before any toolchain setup or build, exactly as the dedicated producer
|
|
770
|
+
// job ordered it: the SDK has to be on disk before devenv resolves it.
|
|
771
|
+
lines.push(
|
|
772
|
+
' - name: Check cross-platform toolchain prerequisites',
|
|
773
|
+
' working-directory: .',
|
|
774
|
+
' run: |',
|
|
775
|
+
' set -euo pipefail',
|
|
776
|
+
...producer.preflight.split('\n').map((line) => ` ${line}`),
|
|
777
|
+
'',
|
|
778
|
+
);
|
|
779
|
+
}
|
|
757
780
|
if (step.kind === PublishWorkflowStepKind.Build) {
|
|
758
781
|
lines.push(
|
|
759
782
|
' - name: 🐧 Build supplemental Linux targets',
|
|
@@ -763,6 +786,16 @@ function renderSingleJobPublishWorkflowSteps(
|
|
|
763
786
|
)}"`,
|
|
764
787
|
'',
|
|
765
788
|
);
|
|
789
|
+
if (crossOnThisRunner) {
|
|
790
|
+
lines.push(
|
|
791
|
+
' - name: 🍎 Build selected macOS and iOS release outputs',
|
|
792
|
+
" if: steps.version.outputs.mode != 'none'",
|
|
793
|
+
` run: smoo github-ci nx-run-many --targets "${macosPlatformFamilies(options).join(',')}" --projects "${githubExpression(
|
|
794
|
+
'steps.version.outputs.projects',
|
|
795
|
+
)}"`,
|
|
796
|
+
'',
|
|
797
|
+
);
|
|
798
|
+
}
|
|
766
799
|
}
|
|
767
800
|
}
|
|
768
801
|
return `${lines.join('\n').trimEnd()}\n`;
|
|
@@ -1423,6 +1456,41 @@ function githubExpression(expression: string): string {
|
|
|
1423
1456
|
return ['$', '{{ ', expression, ' }}'].join('');
|
|
1424
1457
|
}
|
|
1425
1458
|
|
|
1459
|
+
/**
|
|
1460
|
+
* Whether the separate publishing job can be dropped, so the release runs as
|
|
1461
|
+
* one job that builds every artifact and publishes them.
|
|
1462
|
+
*
|
|
1463
|
+
* The hand-off exists for ONE reason: npmjs mints provenance from the
|
|
1464
|
+
* publishing job's OIDC token and refuses a self-hosted runner for it, so a
|
|
1465
|
+
* release with public packages must publish from the GitHub-hosted runner and
|
|
1466
|
+
* therefore has to receive its self-hosted artifacts through an artifact.
|
|
1467
|
+
* A release that publishes only to the declared private registry has no such
|
|
1468
|
+
* constraint; when its Apple targets are cross-built on the same runner (or it
|
|
1469
|
+
* has none), one job holds everything.
|
|
1470
|
+
*/
|
|
1471
|
+
function skipsPublishHandoff(options: PublishWorkflowDefinitionOptions): boolean {
|
|
1472
|
+
if (options.actionsProvider !== 'forgejo' || options.privateNpm === undefined) {
|
|
1473
|
+
return false;
|
|
1474
|
+
}
|
|
1475
|
+
return !hasMacosPlatformTargets(options) || options.platformProducer?.kind === 'linux-cross';
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/**
|
|
1479
|
+
* Toolchain environment for a foreign-platform producer, rendered onto whichever
|
|
1480
|
+
* job runs its builds. In the flattened single-job shape that is the publish job
|
|
1481
|
+
* itself, so the SDK path and cross flags have to reach it the same way the
|
|
1482
|
+
* dedicated producer job received them.
|
|
1483
|
+
*/
|
|
1484
|
+
function platformProducerJobEnv(options: PublishWorkflowDefinitionOptions): string {
|
|
1485
|
+
const producer = options.platformProducer;
|
|
1486
|
+
if (!producer || !skipsPublishHandoff(options)) {
|
|
1487
|
+
return '';
|
|
1488
|
+
}
|
|
1489
|
+
return Object.entries(producer.env ?? {})
|
|
1490
|
+
.map(([name, value]) => `\n ${name}: ${JSON.stringify(value)}`)
|
|
1491
|
+
.join('');
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1426
1494
|
function privateNpmInstallJobEnv(options: PublishWorkflowDefinitionOptions): string {
|
|
1427
1495
|
const tokenEnv = options.privateNpm?.readTokenEnv;
|
|
1428
1496
|
if (!tokenEnv) {
|
|
@@ -387,3 +387,71 @@ describe('resolveSecretEnvironment', () => {
|
|
|
387
387
|
}
|
|
388
388
|
});
|
|
389
389
|
});
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Redaction is a byte operation on output the caller already captured, so the
|
|
393
|
+
* harness carries both directions as base64: a text-only round trip would
|
|
394
|
+
* hide exactly the invalid-UTF-8 bytes a failing install can emit.
|
|
395
|
+
*/
|
|
396
|
+
const MASK_SCRIPT = `
|
|
397
|
+
const resolver = await import(Bun.argv[1]);
|
|
398
|
+
const { output, values } = JSON.parse(Bun.argv[2]);
|
|
399
|
+
const captured = Buffer.from(output, 'base64');
|
|
400
|
+
const masked = resolver.maskSecretValues(captured, values);
|
|
401
|
+
process.stdout.write(JSON.stringify({
|
|
402
|
+
masked: Buffer.from(masked).toString('base64'),
|
|
403
|
+
captured: captured.toString('base64'),
|
|
404
|
+
}));
|
|
405
|
+
`;
|
|
406
|
+
|
|
407
|
+
describe('maskSecretValues', () => {
|
|
408
|
+
const mask = async (output: Uint8Array, values: readonly string[]): Promise<{ masked: Buffer; captured: Buffer }> => {
|
|
409
|
+
const { stdout, stderr, exitCode } = await runInBootstrap(
|
|
410
|
+
MASK_SCRIPT,
|
|
411
|
+
JSON.stringify({ output: Buffer.from(output).toString('base64'), values }),
|
|
412
|
+
);
|
|
413
|
+
expect(stderr).toBe('');
|
|
414
|
+
expect(exitCode).toBe(0);
|
|
415
|
+
const value: unknown = JSON.parse(stdout);
|
|
416
|
+
if (
|
|
417
|
+
typeof value !== 'object' ||
|
|
418
|
+
value === null ||
|
|
419
|
+
!('masked' in value) ||
|
|
420
|
+
typeof value.masked !== 'string' ||
|
|
421
|
+
!('captured' in value) ||
|
|
422
|
+
typeof value.captured !== 'string'
|
|
423
|
+
) {
|
|
424
|
+
throw new Error(`bootstrap harness returned a malformed envelope: ${stdout}`);
|
|
425
|
+
}
|
|
426
|
+
return { masked: Buffer.from(value.masked, 'base64'), captured: Buffer.from(value.captured, 'base64') };
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
it('replaces every occurrence and leaves the surrounding failure legible', async () => {
|
|
430
|
+
const output = Buffer.from('error: 401 for https://x:hunter2@registry/pkg\nretrying with hunter2\n');
|
|
431
|
+
const { masked, captured } = await mask(output, ['hunter2']);
|
|
432
|
+
expect(masked.toString()).toBe('error: 401 for https://x:*******@registry/pkg\nretrying with *******\n');
|
|
433
|
+
expect(masked.length).toBe(output.length);
|
|
434
|
+
// The caller keeps its captured bytes: redaction returns a copy, and a
|
|
435
|
+
// node Buffer's `slice` would have handed back a view of these.
|
|
436
|
+
expect(captured.toString()).toBe(output.toString());
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('redacts each declared value even when one is a prefix of another', async () => {
|
|
440
|
+
expect((await mask(Buffer.from('AB ABCD AB'), ['ABCD', 'AB'])).masked.toString()).toBe('** **** **');
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('survives a false start on the first byte', async () => {
|
|
444
|
+
expect((await mask(Buffer.from('aab aaab'), ['aab'])).masked.toString()).toBe('*** a***');
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('masks whole bytes and carries invalid UTF-8 through untouched', async () => {
|
|
448
|
+
const output = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from('kå'), Buffer.from([0x00])]);
|
|
449
|
+
const { masked } = await mask(output, ['kå']);
|
|
450
|
+
expect([...masked]).toEqual([0xff, 0xfe, 0x2a, 0x2a, 0x2a, 0x00]);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('leaves output alone when nothing is declared or a value is empty', async () => {
|
|
454
|
+
expect((await mask(Buffer.from('nothing to hide'), [])).masked.toString()).toBe('nothing to hide');
|
|
455
|
+
expect((await mask(Buffer.from('nothing to hide'), [''])).masked.toString()).toBe('nothing to hide');
|
|
456
|
+
});
|
|
457
|
+
});
|
|
@@ -433,6 +433,43 @@ describe('private npm published-version status', () => {
|
|
|
433
433
|
});
|
|
434
434
|
});
|
|
435
435
|
|
|
436
|
+
it('reads through a repository .npmrc whose own auth line names an unset env', async () => {
|
|
437
|
+
// The shape that cost a day: AxE commits
|
|
438
|
+
// `//host/path:_authToken=${AXE_NPM_PUBLISH_TOKEN}` at the workspace root.
|
|
439
|
+
// npm ranks that project file ABOVE the userconfig this CLI writes, so with
|
|
440
|
+
// the publish env unset it sent the unexpanded value and the registry
|
|
441
|
+
// answered 401 — with a perfectly good read credential in hand.
|
|
442
|
+
await withStatusFixture(async ({ fixture, root, userconfig }) => {
|
|
443
|
+
await fixture.privateRegistry.publishPackage({ name: PRIVATE_PACKAGE, version: PRIVATE_VERSION });
|
|
444
|
+
const resolved = loopbackRegistry(fixture.privateRegistry);
|
|
445
|
+
await writeFile(
|
|
446
|
+
join(root, '.npmrc'),
|
|
447
|
+
`${resolved.scope}:registry=${resolved.registry}\n${resolved.authKey}=\${NOT_SET_ANYWHERE}\n`,
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
// Control: with the userconfig alone the project file wins, so the token
|
|
451
|
+
// npm sends is the unexpanded reference — the registry sees no usable
|
|
452
|
+
// credential. Asserted on the wire, because a fixture that answers
|
|
453
|
+
// anonymous reads would let the server's verdict hide it.
|
|
454
|
+
await npmPublishedVersionExists(root, PRIVATE_PACKAGE, PRIVATE_VERSION, {
|
|
455
|
+
registry: fixture.privateRegistry.registry,
|
|
456
|
+
userconfig,
|
|
457
|
+
});
|
|
458
|
+
const overridden = fixture.privateRegistry.requestsFor('probe').at(-1);
|
|
459
|
+
expect(overridden?.authorization ?? '').not.toContain(FIXTURE_READ_TOKEN);
|
|
460
|
+
|
|
461
|
+
await expect(
|
|
462
|
+
npmPublishedVersionExists(root, PRIVATE_PACKAGE, PRIVATE_VERSION, {
|
|
463
|
+
registry: fixture.privateRegistry.registry,
|
|
464
|
+
userconfig,
|
|
465
|
+
credential: { authKey: resolved.authKey, tokenEnv: FIXTURE_READ_TOKEN_ENV },
|
|
466
|
+
}),
|
|
467
|
+
).resolves.toBe(true);
|
|
468
|
+
const authenticated = fixture.privateRegistry.requestsFor('probe').at(-1);
|
|
469
|
+
expect(authenticated?.authorization ?? '').toContain(FIXTURE_READ_TOKEN);
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
436
473
|
it('reports a genuine not-found as absent', async () => {
|
|
437
474
|
await withStatusFixture(async ({ fixture, root, userconfig }) => {
|
|
438
475
|
fixture.privateRegistry.failWith(404);
|
|
@@ -86,17 +86,37 @@ describe('private npm workflow token selection', () => {
|
|
|
86
86
|
});
|
|
87
87
|
|
|
88
88
|
it('ignores workspace and link specs so a producer install does not look like a registry consume', async () => {
|
|
89
|
+
// Without a declared read token, the .npmrc credential is a publish
|
|
90
|
+
// credential and must never be promoted into the read role by inference.
|
|
89
91
|
await withRepo(
|
|
90
92
|
{
|
|
91
93
|
privatePackage: `${SCOPE}/sdk`,
|
|
92
94
|
rootDeps: { [`${SCOPE}/sdk`]: 'workspace:*' },
|
|
93
95
|
extraDeps: { [`${SCOPE}/other`]: 'link:@priv.test/other' },
|
|
94
96
|
npmrcAuthEnv: PUBLISH_ENV,
|
|
97
|
+
declared: { scope: SCOPE, publishTokenEnv: PUBLISH_ENV },
|
|
98
|
+
},
|
|
99
|
+
(root) => {
|
|
100
|
+
expect(resolvePrivateNpmWorkflowConfig(root)).toEqual({
|
|
101
|
+
scope: SCOPE,
|
|
102
|
+
publishTokenEnv: PUBLISH_ENV,
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('renders a declared read token for a producer, whose later releases read the previous tag state', async () => {
|
|
109
|
+
await withRepo(
|
|
110
|
+
{
|
|
111
|
+
privatePackage: `${SCOPE}/sdk`,
|
|
112
|
+
rootDeps: { [`${SCOPE}/sdk`]: 'workspace:*' },
|
|
113
|
+
npmrcAuthEnv: PUBLISH_ENV,
|
|
95
114
|
declared: { scope: SCOPE, readTokenEnv: READ_ENV, publishTokenEnv: PUBLISH_ENV },
|
|
96
115
|
},
|
|
97
116
|
(root) => {
|
|
98
117
|
expect(resolvePrivateNpmWorkflowConfig(root)).toEqual({
|
|
99
118
|
scope: SCOPE,
|
|
119
|
+
readTokenEnv: READ_ENV,
|
|
100
120
|
publishTokenEnv: PUBLISH_ENV,
|
|
101
121
|
});
|
|
102
122
|
},
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
2
3
|
import { tmpdir } from 'node:os';
|
|
3
4
|
import { join } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
4
6
|
import type { ChangelogOptions } from 'nx/src/command-line/release/command-object.js';
|
|
5
7
|
import type { NxReleaseConfiguration } from 'nx/src/config/nx-json.js';
|
|
6
8
|
import { type ReleasePackageInfo, releaseTag } from './core.js';
|
|
@@ -34,7 +36,7 @@ export interface GithubReleaseWriteShell {
|
|
|
34
36
|
|
|
35
37
|
export async function renderNxProjectChangelogContents(input: RenderNxProjectChangelogInput): Promise<string> {
|
|
36
38
|
return withNxWorkspaceRoot(input.root, async () => {
|
|
37
|
-
const { createAPI } = await
|
|
39
|
+
const { createAPI } = await importWorkspaceNx(input.root, 'src/command-line/release/changelog.js');
|
|
38
40
|
const result = await createAPI(
|
|
39
41
|
nxRenderOnlyReleaseConfig,
|
|
40
42
|
false,
|
|
@@ -124,8 +126,35 @@ function isPrereleaseVersion(version: string): boolean {
|
|
|
124
126
|
return version.includes('-');
|
|
125
127
|
}
|
|
126
128
|
|
|
129
|
+
/**
|
|
130
|
+
* A module of the WORKSPACE's Nx, not of smoo's own dependency. smoo installs
|
|
131
|
+
* with its own `nx` in the global virtual store, where the workspace's
|
|
132
|
+
* node_modules is invisible: that Nx cannot resolve the workspace's
|
|
133
|
+
* `versionActions` plugin (`Unable to resolve the "versionActions"
|
|
134
|
+
* implementation ... "@smoothbricks/nx-plugin/version-actions"`), and it is
|
|
135
|
+
* a second Nx version driving one release. Every in-process Nx call for a
|
|
136
|
+
* workspace resolves from that workspace's root, exactly as `nx` on its PATH
|
|
137
|
+
* would.
|
|
138
|
+
*/
|
|
139
|
+
interface WorkspaceNxModules {
|
|
140
|
+
'src/utils/workspace-root.js': typeof import('nx/src/utils/workspace-root.js');
|
|
141
|
+
'src/command-line/release/version.js': typeof import('nx/src/command-line/release/version.js');
|
|
142
|
+
'src/command-line/release/changelog.js': typeof import('nx/src/command-line/release/changelog.js');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function importWorkspaceNx<Subpath extends keyof WorkspaceNxModules>(
|
|
146
|
+
root: string,
|
|
147
|
+
subpath: Subpath,
|
|
148
|
+
): Promise<WorkspaceNxModules[Subpath]> {
|
|
149
|
+
const resolved = createRequire(join(root, 'package.json')).resolve(`nx/${subpath}`);
|
|
150
|
+
// A dynamic specifier types as `any`; the map above is the declared shape of
|
|
151
|
+
// the module the workspace's Nx serves at that subpath.
|
|
152
|
+
const module: WorkspaceNxModules[Subpath] = await import(pathToFileURL(resolved).href);
|
|
153
|
+
return module;
|
|
154
|
+
}
|
|
155
|
+
|
|
127
156
|
export async function withNxWorkspaceRoot<T>(root: string, run: () => Promise<T>): Promise<T> {
|
|
128
|
-
const workspaceRootModule = await
|
|
157
|
+
const workspaceRootModule = await importWorkspaceNx(root, 'src/utils/workspace-root.js');
|
|
129
158
|
const previousWorkspaceRoot = workspaceRootModule.workspaceRoot;
|
|
130
159
|
const previousEnvWorkspaceRoot = process.env.NX_WORKSPACE_ROOT_PATH;
|
|
131
160
|
const previousCwd = process.cwd();
|