@smoothbricks/cli 0.11.15 → 0.11.17

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 (70) hide show
  1. package/README.md +43 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +44 -0
  4. package/dist/github-ci/index.d.ts +1 -1
  5. package/dist/github-ci/index.d.ts.map +1 -1
  6. package/dist/github-ci/index.js +7 -10
  7. package/dist/lib/json.d.ts +9 -0
  8. package/dist/lib/json.d.ts.map +1 -1
  9. package/dist/lib/json.js +65 -53
  10. package/dist/lib/secret-names.d.ts +35 -0
  11. package/dist/lib/secret-names.d.ts.map +1 -0
  12. package/dist/lib/secret-names.js +61 -0
  13. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  14. package/dist/monorepo/ci-workflow.js +15 -6
  15. package/dist/monorepo/managed-files.d.ts +9 -0
  16. package/dist/monorepo/managed-files.d.ts.map +1 -1
  17. package/dist/monorepo/managed-files.js +28 -2
  18. package/dist/monorepo/publish-workflow.d.ts +10 -1
  19. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  20. package/dist/monorepo/publish-workflow.js +110 -22
  21. package/dist/release/index.d.ts +1 -1
  22. package/dist/release/index.d.ts.map +1 -1
  23. package/dist/release/index.js +26 -4
  24. package/dist/release/private-npm.d.ts +5 -0
  25. package/dist/release/private-npm.d.ts.map +1 -1
  26. package/dist/release/private-npm.js +26 -5
  27. package/dist/secrets/commands.d.ts +45 -0
  28. package/dist/secrets/commands.d.ts.map +1 -0
  29. package/dist/secrets/commands.js +189 -0
  30. package/dist/secrets/index.d.ts +83 -0
  31. package/dist/secrets/index.d.ts.map +1 -0
  32. package/dist/secrets/index.js +149 -0
  33. package/dist/wrangler/deploy-stage.d.ts +13 -2
  34. package/dist/wrangler/deploy-stage.d.ts.map +1 -1
  35. package/dist/wrangler/deploy-stage.js +39 -54
  36. package/dist/wrangler/deployed-version.d.ts +44 -0
  37. package/dist/wrangler/deployed-version.d.ts.map +1 -0
  38. package/dist/wrangler/deployed-version.js +87 -0
  39. package/dist/wrangler/live-version.d.ts +146 -0
  40. package/dist/wrangler/live-version.d.ts.map +1 -0
  41. package/dist/wrangler/live-version.js +326 -0
  42. package/managed/raw/tooling/direnv/devenv.smoo.nix +4 -2
  43. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +4 -1
  44. package/managed/raw/tooling/direnv/toolchain-stamp.ts +88 -0
  45. package/managed/templates/github/actions/setup-devenv/action.yml +1 -1
  46. package/package.json +2 -2
  47. package/src/cli.ts +46 -1
  48. package/src/github-ci/index.test.ts +92 -8
  49. package/src/github-ci/index.ts +7 -10
  50. package/src/lib/json.ts +9 -0
  51. package/src/lib/secret-names.ts +70 -0
  52. package/src/monorepo/__tests__/ci-workflow.test.ts +27 -2
  53. package/src/monorepo/__tests__/publish-workflow.test.ts +85 -13
  54. package/src/monorepo/ci-workflow.ts +17 -6
  55. package/src/monorepo/managed-files.test.ts +26 -0
  56. package/src/monorepo/managed-files.ts +36 -2
  57. package/src/monorepo/publish-workflow.ts +135 -21
  58. package/src/monorepo/secret-references.test.ts +1 -1
  59. package/src/release/__tests__/private-npm-status.test.ts +37 -0
  60. package/src/release/__tests__/private-npm-workflow.test.ts +20 -0
  61. package/src/release/index.ts +27 -2
  62. package/src/release/private-npm.ts +30 -5
  63. package/src/secrets/commands.ts +210 -0
  64. package/src/secrets/index.test.ts +92 -0
  65. package/src/secrets/index.ts +183 -0
  66. package/src/wrangler/deploy-stage.test.ts +190 -12
  67. package/src/wrangler/deploy-stage.ts +72 -45
  68. package/src/wrangler/deployed-version.test.ts +150 -0
  69. package/src/wrangler/deployed-version.ts +130 -0
  70. package/src/wrangler/live-version.ts +363 -0
@@ -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) => {
@@ -49,6 +49,7 @@ const synchronizedPrettier = makeModuleSynchronized<typeof PrettierModule>(impor
49
49
 
50
50
  export type PublishWorkflowBump = 'auto' | 'patch' | 'minor' | 'major' | 'prerelease';
51
51
  export type PublishWorkflowCondition =
52
+ | 'plan-mode-not-none'
52
53
  | 'version-mode-not-none'
53
54
  | 'deploy-production'
54
55
  | 'deploy-production-standalone'
@@ -65,6 +66,7 @@ export enum PublishWorkflowStepKind {
65
66
  ConfigureReleaseAuthor = 'configure-release-author',
66
67
  BuildNxVersionActions = 'build-nx-version-actions',
67
68
  RepairPendingReleases = 'repair-pending-releases',
69
+ PlanRelease = 'plan-release',
68
70
  VersionRelease = 'version-release',
69
71
  CheckManagedMonorepoFiles = 'check-managed-monorepo-files',
70
72
  Build = 'build',
@@ -159,6 +161,12 @@ export interface PublishWorkflowCallbacks {
159
161
  buildNxVersionActions(): Promise<void>;
160
162
  repairPendingReleases(input: { dryRun: boolean }): Promise<void>;
161
163
  versionRelease(input: { bump: PublishWorkflowBump; dryRun: boolean }): Promise<PublishWorkflowVersionOutputs>;
164
+ /**
165
+ * The selection the bump WILL make, computed without writing anything, so the
166
+ * gates that run before the version commit know what to run over. Defaults to
167
+ * a dry-run `versionRelease` when a caller supplies none.
168
+ */
169
+ planRelease?(input: { bump: PublishWorkflowBump }): Promise<PublishWorkflowVersionOutputs>;
162
170
  checkManagedMonorepoFiles(): Promise<void>;
163
171
  nxRunMany(input: { target: PublishWorkflowNxTarget; projects: string[] }): Promise<void>;
164
172
  uploadTraceDbs(): Promise<void>;
@@ -183,6 +191,9 @@ type PublishWorkflowStepInput = Omit<PublishWorkflowStep, 'number'>;
183
191
 
184
192
  export function definePublishWorkflow(options: PublishWorkflowDefinitionOptions = {}): PublishWorkflowDefinition {
185
193
  const versionMode = githubExpression('steps.version.outputs.mode');
194
+ // Steps that run before the bump must label themselves from the plan: the
195
+ // version step's outputs do not exist yet, so the interpolation renders empty.
196
+ const planMode = githubExpression('steps.plan.outputs.mode');
186
197
  if (options.release === false) {
187
198
  return { steps: defineDeployOnlyWorkflowSteps(options) };
188
199
  }
@@ -224,30 +235,37 @@ export function definePublishWorkflow(options: PublishWorkflowDefinitionOptions
224
235
  kind: PublishWorkflowStepKind.RepairPendingReleases,
225
236
  name: '🧯 Repair pending releases',
226
237
  },
227
- { kind: PublishWorkflowStepKind.VersionRelease, name: '🔢 Version release', id: 'version' },
238
+ // Plan first, gate second, bump third. Lint and test hash the source, so
239
+ // running them BEFORE the version commit means their task hashes match
240
+ // the ones ci already computed for the same source — the publish reuses
241
+ // that cache instead of re-running 189 tasks. Build stays after the bump
242
+ // on purpose: an artifact built from pre-bump sources would ship the
243
+ // previous version string.
244
+ { kind: PublishWorkflowStepKind.PlanRelease, name: '🧭 Plan release', id: 'plan' },
228
245
  {
229
246
  kind: PublishWorkflowStepKind.CheckManagedMonorepoFiles,
230
- name: `✅ Check managed monorepo files (${versionMode})`,
231
- condition: 'version-mode-not-none',
232
- },
233
- {
234
- kind: PublishWorkflowStepKind.Build,
235
- name: `🔨 Build (${versionMode})`,
236
- condition: 'version-mode-not-none',
237
- nxTarget: 'build',
247
+ name: `✅ Check managed monorepo files (${planMode})`,
248
+ condition: 'plan-mode-not-none',
238
249
  },
239
250
  {
240
251
  kind: PublishWorkflowStepKind.Lint,
241
- name: `🔍 Lint (${versionMode})`,
242
- condition: 'version-mode-not-none',
252
+ name: `🔍 Lint (${planMode})`,
253
+ condition: 'plan-mode-not-none',
243
254
  nxTarget: 'lint',
244
255
  },
245
256
  {
246
257
  kind: PublishWorkflowStepKind.UnitTests,
247
- name: `🧪 Unit Tests (${versionMode})`,
248
- condition: 'version-mode-not-none',
258
+ name: `🧪 Unit Tests (${planMode})`,
259
+ condition: 'plan-mode-not-none',
249
260
  nxTarget: 'test',
250
261
  },
262
+ { kind: PublishWorkflowStepKind.VersionRelease, name: '🔢 Version release', id: 'version' },
263
+ {
264
+ kind: PublishWorkflowStepKind.Build,
265
+ name: `🔨 Build (${versionMode})`,
266
+ condition: 'version-mode-not-none',
267
+ nxTarget: 'build',
268
+ },
251
269
  { kind: PublishWorkflowStepKind.UploadTraceDbs, name: '📎 Upload trace DBs', condition: 'failure' },
252
270
  {
253
271
  kind: PublishWorkflowStepKind.ValidateMonorepoConfig,
@@ -302,10 +320,11 @@ export async function runPublishWorkflow(
302
320
  ): Promise<PublishWorkflowRunResult> {
303
321
  let setupOutputs: PublishWorkflowSetupOutputs = { nixCacheHit: '', devenvCacheHit: '' };
304
322
  let version: PublishWorkflowVersionOutputs = { mode: 'none', projects: [] };
323
+ let plan: PublishWorkflowVersionOutputs = { mode: 'none', projects: [] };
305
324
  let failed = false;
306
325
  let failure: unknown;
307
326
  for (const step of workflow.steps) {
308
- if (!shouldRunStep(step, version, failed, context.inputs)) {
327
+ if (!shouldRunStep(step, version, failed, context.inputs, plan)) {
309
328
  continue;
310
329
  }
311
330
  try {
@@ -331,6 +350,11 @@ export async function runPublishWorkflow(
331
350
  dryRun: context.inputs.dryRun,
332
351
  });
333
352
  break;
353
+ case PublishWorkflowStepKind.PlanRelease:
354
+ plan = context.callbacks.planRelease
355
+ ? await context.callbacks.planRelease({ bump: context.inputs.bump })
356
+ : await context.callbacks.versionRelease({ bump: context.inputs.bump, dryRun: true });
357
+ break;
334
358
  case PublishWorkflowStepKind.VersionRelease:
335
359
  version = await context.callbacks.versionRelease(context.inputs);
336
360
  break;
@@ -343,7 +367,12 @@ export async function runPublishWorkflow(
343
367
  if (!step.nxTarget) {
344
368
  throw new Error(`Workflow step ${step.kind} is missing an Nx target.`);
345
369
  }
346
- await context.callbacks.nxRunMany({ target: step.nxTarget, projects: version.projects });
370
+ // Lint and test run before the bump exists, so they select from the
371
+ // plan; build runs after it and selects from the written version.
372
+ await context.callbacks.nxRunMany({
373
+ target: step.nxTarget,
374
+ projects: step.kind === PublishWorkflowStepKind.Build ? version.projects : plan.projects,
375
+ });
347
376
  break;
348
377
  case PublishWorkflowStepKind.UploadTraceDbs:
349
378
  await context.callbacks.uploadTraceDbs();
@@ -383,7 +412,11 @@ function shouldRunStep(
383
412
  version: PublishWorkflowVersionOutputs,
384
413
  failed: boolean,
385
414
  inputs: PublishWorkflowInputs,
415
+ plan: PublishWorkflowVersionOutputs = version,
386
416
  ): boolean {
417
+ if (step.condition === 'plan-mode-not-none') {
418
+ return plan.mode !== 'none';
419
+ }
387
420
  if (step.condition === 'version-mode-not-none') {
388
421
  return version.mode !== 'none';
389
422
  }
@@ -404,8 +437,17 @@ export function renderPublishWorkflowYaml(options: PublishWorkflowDefinitionOpti
404
437
  if (options.release === false) {
405
438
  const steps = definePublishWorkflow(options).steps;
406
439
  workflow = `${renderPublishWorkflowHeader(options)}${renderPublishWorkflowSteps(steps, options)}`;
407
- } else if (hasMacosPlatformTargets(options)) {
440
+ } else if (hasMacosPlatformTargets(options) && !skipsPublishHandoff(options)) {
408
441
  workflow = renderPlatformPublishWorkflowYaml(options);
442
+ } else if (hasMacosPlatformTargets(options)) {
443
+ // A linux-cross producer runs the Apple targets on the publisher's own
444
+ // runner. Splitting jobs then buys nothing and costs a real transfer: the
445
+ // outputs get tarred, uploaded, downloaded into a second checkout, and two
446
+ // more devenv setups pay for themselves twice. One job, no artifacts.
447
+ workflow = `${renderPublishWorkflowHeader(options)}${renderSingleJobPublishWorkflowSteps(
448
+ definePublishWorkflow(options).steps,
449
+ options,
450
+ )}`;
409
451
  } else if (hasLinuxPlatformTargets(options)) {
410
452
  workflow = `${renderPublishWorkflowHeader(options)}${renderSingleJobPublishWorkflowSteps(
411
453
  definePublishWorkflow(options).steps,
@@ -475,7 +517,7 @@ jobs:
475
517
  publish:
476
518
  ${options.release === false ? renderRunsOnLine(options.runsOn) : publishJobRunsOnLine(options)}
477
519
  env:
478
- GH_TOKEN: ${githubExpression('github.token')}${remoteCacheJobEnv(options)}${cargoCredentialsJobEnv(options)}${privateNpmInstallJobEnv(options)}
520
+ GH_TOKEN: ${githubExpression('github.token')}${remoteCacheJobEnv(options)}${cargoCredentialsJobEnv(options)}${privateNpmInstallJobEnv(options)}${platformProducerJobEnv(options)}
479
521
  steps:
480
522
  `;
481
523
  }
@@ -572,6 +614,16 @@ function yamlLinesForStep(step: PublishWorkflowStep, options: PublishWorkflowDef
572
614
  ...privateNpmPublisherStepEnv(options),
573
615
  ` run: smoo release repair-pending --dry-run "${githubExpression('inputs.dry_run')}"`,
574
616
  ];
617
+ case PublishWorkflowStepKind.PlanRelease:
618
+ // The same selection the bump will make, computed without writing: the
619
+ // gates need `mode` and `projects` before any version commit exists.
620
+ return [
621
+ ` - name: ${step.name}`,
622
+ ' id: plan',
623
+ ' run:',
624
+ ` smoo release version --bump "${githubExpression('inputs.bump')}" --projects "${githubExpression('inputs.projects')}" --dry-run "true" --github-output`,
625
+ ' "$GITHUB_OUTPUT"',
626
+ ];
575
627
  case PublishWorkflowStepKind.VersionRelease:
576
628
  return [
577
629
  ` - name: ${step.name}`,
@@ -592,12 +644,12 @@ function yamlLinesForStep(step: PublishWorkflowStep, options: PublishWorkflowDef
592
644
  case PublishWorkflowStepKind.Lint:
593
645
  return conditionalRunStep(
594
646
  step,
595
- `smoo github-ci nx-run-many --targets lint --projects "${githubExpression('steps.version.outputs.projects')}"`,
647
+ `smoo github-ci nx-run-many --targets lint --projects "${githubExpression('steps.plan.outputs.projects')}"`,
596
648
  );
597
649
  case PublishWorkflowStepKind.UnitTests:
598
650
  return conditionalRunStep(
599
651
  step,
600
- `smoo github-ci nx-run-many --targets test --projects "${githubExpression('steps.version.outputs.projects')}"`,
652
+ `smoo github-ci nx-run-many --targets test --projects "${githubExpression('steps.plan.outputs.projects')}"`,
601
653
  );
602
654
  case PublishWorkflowStepKind.UploadTraceDbs:
603
655
  return artifactStepLines(
@@ -686,8 +738,11 @@ function deployProductionStep(step: PublishWorkflowStep, options: PublishWorkflo
686
738
  }
687
739
 
688
740
  function conditionalRunStep(step: PublishWorkflowStep, run: string): string[] {
689
- const condition = "steps.version.outputs.mode != 'none'";
690
- return [` - name: ${step.name}`, ` if: ${condition}`, ` run: ${run}`];
741
+ // A step gated on the plan must read the PLAN's mode: it runs before the
742
+ // version step exists, and `steps.version.outputs.mode` is empty there, which
743
+ // silently skips the gate rather than running it.
744
+ const source = step.condition === 'plan-mode-not-none' ? 'plan' : 'version';
745
+ return [` - name: ${step.name}`, ` if: steps.${source}.outputs.mode != 'none'`, ` run: ${run}`];
691
746
  }
692
747
 
693
748
  function siblingSourceCheckoutStepLines(options: PublishWorkflowDefinitionOptions): string[] {
@@ -749,11 +804,25 @@ function renderSingleJobPublishWorkflowSteps(
749
804
  options: PublishWorkflowDefinitionOptions,
750
805
  ): string {
751
806
  const lines: string[] = [];
807
+ const producer = options.platformProducer;
808
+ const crossOnThisRunner = skipsPublishHandoff(options) && hasMacosPlatformTargets(options);
752
809
  for (const step of steps) {
753
810
  lines.push(...sectionLinesBefore(step));
754
811
  lines.push(...commentLinesForStep(step));
755
812
  lines.push(...yamlLinesForStep(step, options));
756
813
  lines.push('');
814
+ if (crossOnThisRunner && producer && step.kind === PublishWorkflowStepKind.Checkout) {
815
+ // Before any toolchain setup or build, exactly as the dedicated producer
816
+ // job ordered it: the SDK has to be on disk before devenv resolves it.
817
+ lines.push(
818
+ ' - name: Check cross-platform toolchain prerequisites',
819
+ ' working-directory: .',
820
+ ' run: |',
821
+ ' set -euo pipefail',
822
+ ...producer.preflight.split('\n').map((line) => ` ${line}`),
823
+ '',
824
+ );
825
+ }
757
826
  if (step.kind === PublishWorkflowStepKind.Build) {
758
827
  lines.push(
759
828
  ' - name: 🐧 Build supplemental Linux targets',
@@ -763,6 +832,16 @@ function renderSingleJobPublishWorkflowSteps(
763
832
  )}"`,
764
833
  '',
765
834
  );
835
+ if (crossOnThisRunner) {
836
+ lines.push(
837
+ ' - name: 🍎 Build selected macOS and iOS release outputs',
838
+ " if: steps.version.outputs.mode != 'none'",
839
+ ` run: smoo github-ci nx-run-many --targets "${macosPlatformFamilies(options).join(',')}" --projects "${githubExpression(
840
+ 'steps.version.outputs.projects',
841
+ )}"`,
842
+ '',
843
+ );
844
+ }
766
845
  }
767
846
  }
768
847
  return `${lines.join('\n').trimEnd()}\n`;
@@ -1423,6 +1502,41 @@ function githubExpression(expression: string): string {
1423
1502
  return ['$', '{{ ', expression, ' }}'].join('');
1424
1503
  }
1425
1504
 
1505
+ /**
1506
+ * Whether the separate publishing job can be dropped, so the release runs as
1507
+ * one job that builds every artifact and publishes them.
1508
+ *
1509
+ * The hand-off exists for ONE reason: npmjs mints provenance from the
1510
+ * publishing job's OIDC token and refuses a self-hosted runner for it, so a
1511
+ * release with public packages must publish from the GitHub-hosted runner and
1512
+ * therefore has to receive its self-hosted artifacts through an artifact.
1513
+ * A release that publishes only to the declared private registry has no such
1514
+ * constraint; when its Apple targets are cross-built on the same runner (or it
1515
+ * has none), one job holds everything.
1516
+ */
1517
+ function skipsPublishHandoff(options: PublishWorkflowDefinitionOptions): boolean {
1518
+ if (options.actionsProvider !== 'forgejo' || options.privateNpm === undefined) {
1519
+ return false;
1520
+ }
1521
+ return !hasMacosPlatformTargets(options) || options.platformProducer?.kind === 'linux-cross';
1522
+ }
1523
+
1524
+ /**
1525
+ * Toolchain environment for a foreign-platform producer, rendered onto whichever
1526
+ * job runs its builds. In the flattened single-job shape that is the publish job
1527
+ * itself, so the SDK path and cross flags have to reach it the same way the
1528
+ * dedicated producer job received them.
1529
+ */
1530
+ function platformProducerJobEnv(options: PublishWorkflowDefinitionOptions): string {
1531
+ const producer = options.platformProducer;
1532
+ if (!producer || !skipsPublishHandoff(options)) {
1533
+ return '';
1534
+ }
1535
+ return Object.entries(producer.env ?? {})
1536
+ .map(([name, value]) => `\n ${name}: ${JSON.stringify(value)}`)
1537
+ .join('');
1538
+ }
1539
+
1426
1540
  function privateNpmInstallJobEnv(options: PublishWorkflowDefinitionOptions): string {
1427
1541
  const tokenEnv = options.privateNpm?.readTokenEnv;
1428
1542
  if (!tokenEnv) {
@@ -206,7 +206,7 @@ describe('registryAuthEnvNames', () => {
206
206
 
207
207
  it('collects ${VAR} references from .npmrc text', async () => {
208
208
  expect(
209
- await names('@axe.sc:registry=https://npm.example.net\n//npm.example.net/:_authToken=${SMOO_READ_TOKEN}\n'),
209
+ await names('@acme:registry=https://npm.example.net\n//npm.example.net/:_authToken=${SMOO_READ_TOKEN}\n'),
210
210
  ).toEqual(['SMOO_READ_TOKEN']);
211
211
  });
212
212
 
@@ -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: a repository commits
438
+ // `//host/path:_authToken=${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
  },
@@ -48,6 +48,7 @@ export {
48
48
  type PrivateNpmRegistryConfigErrorKind,
49
49
  type PublishDestination,
50
50
  privateNpmPublishArgs,
51
+ privateNpmTokenEnvForMode,
51
52
  privateNpmUserconfigContent,
52
53
  publishPrivateWithDiagnostics,
53
54
  type Result as PrivateNpmResult,
@@ -58,6 +59,7 @@ export {
58
59
  withPrivateNpmUserconfig,
59
60
  } from './private-npm.js';
60
61
 
62
+ import { PLATFORM_TARGET_GLOBS } from '@smoothbricks/nx-plugin/workspace-config-policy';
61
63
  import { ciApiContext } from '../github-ci/api.js';
62
64
  import { readProjectTargets } from '../nx/index.js';
63
65
  import {
@@ -114,6 +116,7 @@ import {
114
116
  npmPublishedVersionExists as npmPublishedVersionExistsOnRegistry,
115
117
  type PrivateNpmRegistry,
116
118
  privateNpmPublishArgs,
119
+ privateNpmTokenEnvForMode,
117
120
  publishPrivateWithDiagnostics,
118
121
  selectPublishDestination,
119
122
  selectRegistryForPackage,
@@ -754,7 +757,13 @@ async function publishPrivatePackedPackage(
754
757
  await publishPrivateWithDiagnostics(
755
758
  pkg,
756
759
  {
757
- publish: () => runNpm(root, args, { NPM_CONFIG_USERCONFIG: userconfig }),
760
+ publish: () =>
761
+ runNpm(root, args, {
762
+ NPM_CONFIG_USERCONFIG: userconfig,
763
+ // Env config outranks a committed project `.npmrc` auth line; see
764
+ // npmStatusEnv in private-npm.ts for why that matters here.
765
+ [`npm_config_${registry.authKey}`]: process.env[privateNpmTokenEnvForMode(registry, 'publish')] ?? '',
766
+ }),
758
767
  // The open publish userconfig already authenticates this registry,
759
768
  // so the failure probe reuses it. Opening a second read-mode
760
769
  // userconfig here demanded a read credential the publishing job need
@@ -764,6 +773,7 @@ async function publishPrivatePackedPackage(
764
773
  npmPublishedVersionExistsOnRegistry(root, pkg.name, pkg.version, {
765
774
  registry: registry.registry,
766
775
  userconfig,
776
+ credential: { authKey: registry.authKey, tokenEnv: privateNpmTokenEnvForMode(registry, 'publish') },
767
777
  }),
768
778
  log: (message) => console.log(message),
769
779
  error: (message) => console.error(message),
@@ -1197,6 +1207,17 @@ function releaseRepairShell(root: string, platformOutputs: readonly string[]): R
1197
1207
  const sourceSha = await gitHead(root);
1198
1208
  const outputs = platformOutputs.map((base) => join(base, sourceSha));
1199
1209
  if (outputs.length === 0) {
1210
+ // No producer job handed outputs over, so this runner is the producer:
1211
+ // build the platform legs at the checked-out ref. `build` alone gives a
1212
+ // package only the legs its own graph depends on — the host one and
1213
+ // whatever it declares — and the release gate demands the whole declared
1214
+ // native closure, so a foreign leg it never asked for was simply absent.
1215
+ console.log('Repair pending releases: building cross-platform outputs on this runner.');
1216
+ await githubCiNxRunMany(root, {
1217
+ targets: PLATFORM_TARGET_GLOBS.join(','),
1218
+ projects: releasePackageProjects(packages),
1219
+ allowEmptyProjects: true,
1220
+ });
1200
1221
  return;
1201
1222
  }
1202
1223
  console.log(`Repair pending releases: applying cross-platform outputs from ${outputs.join(', ')}.`);
@@ -1865,7 +1886,11 @@ async function npmVersionExists(root: string, name: string, version: string): Pr
1865
1886
  return npmPublishedVersionExistsOnRegistry(root, name, version);
1866
1887
  }
1867
1888
  return withPrivateNpmUserconfig(registry, 'read', (userconfig) =>
1868
- npmPublishedVersionExistsOnRegistry(root, name, version, { registry: registry.registry, userconfig }),
1889
+ npmPublishedVersionExistsOnRegistry(root, name, version, {
1890
+ registry: registry.registry,
1891
+ userconfig,
1892
+ credential: { authKey: registry.authKey, tokenEnv: privateNpmTokenEnvForMode(registry, 'read') },
1893
+ }),
1869
1894
  );
1870
1895
  }
1871
1896
 
@@ -247,7 +247,12 @@ export function resolvePrivateNpmWorkflowConfig(root: string): PackagePrivateNpm
247
247
  return undefined;
248
248
  }
249
249
  const npmrcEnv = npmrcAuthTokenEnv(root, declared.scope);
250
- const readTokenEnv = consumes ? (declared.readTokenEnv ?? npmrcEnv) : undefined;
250
+ // A publishing workspace reads the registry too: every release after the
251
+ // first checks the previous tag's durable state, and that check runs in the
252
+ // producer jobs that never see the publish credential. A declared read token
253
+ // therefore always renders; only the .npmrc fallback stays consumer-only, so
254
+ // a publish token is never promoted into a read role by inference.
255
+ const readTokenEnv = declared.readTokenEnv ?? (consumes ? npmrcEnv : undefined);
251
256
  const publishTokenEnv = publishes ? (declared.publishTokenEnv ?? npmrcEnv) : undefined;
252
257
  if (!readTokenEnv && !publishTokenEnv) {
253
258
  return undefined;
@@ -412,10 +417,30 @@ export async function withPrivateNpmUserconfig<T>(
412
417
  export interface NpmStatusOptions {
413
418
  registry?: string;
414
419
  userconfig?: string;
420
+ /** Path-scoped auth key and token env, so the credential can ride env config. */
421
+ credential?: { authKey: string; tokenEnv: string };
415
422
  }
416
423
 
417
- function npmStatusEnv(userconfig: string | undefined): Record<string, string> | undefined {
418
- return userconfig ? { NPM_CONFIG_USERCONFIG: userconfig } : undefined;
424
+ /**
425
+ * npm config precedence is cli > env > project `.npmrc` > userconfig. A
426
+ * repository that commits its own `//host/path:_authToken=${SOME_ENV}` line
427
+ * therefore OVERRIDES the userconfig this module writes, and when that env is
428
+ * unset npm sends the unexpanded value: the registry answers 401 on reads with
429
+ * a perfectly good read credential, naming nothing. So the credential also
430
+ * rides as env config, which the project file cannot outrank, and the token
431
+ * value stays out of every file on disk.
432
+ */
433
+ function npmStatusEnv(
434
+ userconfig: string | undefined,
435
+ credential?: { authKey: string; tokenEnv: string },
436
+ ): Record<string, string> | undefined {
437
+ if (!userconfig) return undefined;
438
+ const env: Record<string, string> = { NPM_CONFIG_USERCONFIG: userconfig };
439
+ const token = credential ? process.env[credential.tokenEnv] : undefined;
440
+ if (credential && token) {
441
+ env[`npm_config_${credential.authKey}`] = token;
442
+ }
443
+ return env;
419
444
  }
420
445
 
421
446
  function npmCommandFailedMessage(npmArgs: string[], exitCode: number, stdout = '', stderr = ''): string {
@@ -439,7 +464,7 @@ export async function npmPublishedVersionExists(
439
464
  if (options.registry) {
440
465
  args.push('--registry', options.registry);
441
466
  }
442
- const result = await runResult('npm', args, root, npmStatusEnv(options.userconfig));
467
+ const result = await runResult('npm', args, root, npmStatusEnv(options.userconfig, options.credential));
443
468
  if (result.exitCode === 0) {
444
469
  return true;
445
470
  }
@@ -455,7 +480,7 @@ export async function npmPackageExists(root: string, name: string, options: NpmS
455
480
  if (options.registry) {
456
481
  args.push('--registry', options.registry);
457
482
  }
458
- const result = await runResult('npm', args, root, npmStatusEnv(options.userconfig));
483
+ const result = await runResult('npm', args, root, npmStatusEnv(options.userconfig, options.credential));
459
484
  if (result.exitCode === 0) {
460
485
  return true;
461
486
  }