@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.
Files changed (35) hide show
  1. package/dist/lib/json.d.ts +9 -0
  2. package/dist/lib/json.d.ts.map +1 -1
  3. package/dist/lib/json.js +65 -53
  4. package/dist/monorepo/managed-files.d.ts +9 -0
  5. package/dist/monorepo/managed-files.d.ts.map +1 -1
  6. package/dist/monorepo/managed-files.js +28 -2
  7. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  8. package/dist/monorepo/publish-workflow.js +52 -2
  9. package/dist/release/github-release.d.ts +16 -0
  10. package/dist/release/github-release.d.ts.map +1 -1
  11. package/dist/release/github-release.js +11 -2
  12. package/dist/release/index.d.ts +1 -1
  13. package/dist/release/index.d.ts.map +1 -1
  14. package/dist/release/index.js +29 -7
  15. package/dist/release/private-npm.d.ts +5 -0
  16. package/dist/release/private-npm.d.ts.map +1 -1
  17. package/dist/release/private-npm.js +26 -5
  18. package/managed/raw/tooling/direnv/devenv.smoo.nix +4 -2
  19. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +38 -0
  20. package/managed/raw/tooling/direnv/secret-references.ts +66 -0
  21. package/managed/raw/tooling/direnv/setup-environment.ts +81 -26
  22. package/managed/raw/tooling/direnv/toolchain-stamp.ts +88 -0
  23. package/managed/templates/github/actions/setup-devenv/action.yml +4 -2
  24. package/package.json +2 -2
  25. package/src/lib/json.ts +9 -0
  26. package/src/monorepo/__tests__/publish-workflow.test.ts +54 -0
  27. package/src/monorepo/managed-files.test.ts +26 -0
  28. package/src/monorepo/managed-files.ts +36 -2
  29. package/src/monorepo/publish-workflow.ts +70 -2
  30. package/src/monorepo/secret-references.test.ts +68 -0
  31. package/src/release/__tests__/private-npm-status.test.ts +37 -0
  32. package/src/release/__tests__/private-npm-workflow.test.ts +20 -0
  33. package/src/release/github-release.ts +31 -2
  34. package/src/release/index.ts +30 -4
  35. 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-v5-${{ hashFiles('tooling/direnv/devenv.yaml', 'tooling/direnv/devenv.nix', 'tooling/direnv/devenv.lock') }}
204
- restore-prefixes-first-match: ${{ runner.os }}-${{ runner.arch }}-nix-v5-
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.14",
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.8",
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.platformTargetGlobs,
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: macosPlatformArchitecturesForTest(targetNames),
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 import('nx/src/command-line/release/changelog.js');
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 import('nx/src/utils/workspace-root.js');
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();