@ontrails/trails 1.0.0-beta.43 → 1.0.0-beta.46

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.
@@ -1,6 +1,6 @@
1
1
  /* oxlint-disable eslint-plugin-jest/require-hook, max-statements, func-style -- release script with module-level flow */
2
2
  /**
3
- * Native Bun release binding for public `@ontrails/*` workspace publication.
3
+ * Built-in release flow for public `@ontrails/*` workspace publication.
4
4
  *
5
5
  * Auto-discovers workspaces from the root `package.json` `workspaces` field,
6
6
  * topo-sorts them by `workspace:` dependency edges, enforces manifest-range
@@ -14,6 +14,15 @@ import { mkdtemp, readdir, rm } from 'node:fs/promises';
14
14
  import { tmpdir } from 'node:os';
15
15
  import { join, relative, resolve } from 'node:path';
16
16
 
17
+ import {
18
+ checkRegistryPosture,
19
+ classifyPackageRegistryState,
20
+ factsFromRegistryResult,
21
+ npmRegistryVersionProofView,
22
+ npmRegistryView,
23
+ } from './native-bun-registry.js';
24
+ import type { PackageRegistryState } from './native-bun-registry.js';
25
+
17
26
  const REPO_ROOT = resolve(process.cwd());
18
27
 
19
28
  /** ANSI color helpers, disabled when stdout is not a TTY or `NO_COLOR` is set. */
@@ -34,6 +43,7 @@ export interface NativeBunPublishOptions {
34
43
  readonly tag: string | undefined;
35
44
  readonly otp: string | undefined;
36
45
  readonly only: readonly string[] | undefined;
46
+ readonly trustedPublishing?: boolean;
37
47
  }
38
48
 
39
49
  /** Minimal shape of a workspace `package.json` we care about. */
@@ -45,6 +55,14 @@ export interface NativeBunPublishPackageJson {
45
55
  devDependencies?: Record<string, string>;
46
56
  peerDependencies?: Record<string, string>;
47
57
  optionalDependencies?: Record<string, string>;
58
+ scripts?: Record<string, string>;
59
+ repository?:
60
+ | string
61
+ | {
62
+ directory?: string;
63
+ type?: string;
64
+ url?: string;
65
+ };
48
66
  }
49
67
 
50
68
  type DependencyField =
@@ -60,6 +78,8 @@ export interface NativeBunPublishWorkspace {
60
78
  readonly path: string;
61
79
  readonly isPrivate: boolean;
62
80
  readonly workspaceDeps: readonly string[];
81
+ readonly repository?: NativeBunPublishPackageJson['repository'];
82
+ readonly publishLifecycleScripts?: readonly string[];
63
83
  }
64
84
 
65
85
  const DEPENDENCY_FIELDS: readonly DependencyField[] = [
@@ -68,19 +88,32 @@ const DEPENDENCY_FIELDS: readonly DependencyField[] = [
68
88
  'peerDependencies',
69
89
  'optionalDependencies',
70
90
  ];
91
+ const TRUSTED_REPOSITORY_URL =
92
+ 'git+https://github.com/outfitter-dev/trails.git';
93
+ const MINIMUM_TRUSTED_NODE = [22, 14, 0] as const;
94
+ const MINIMUM_TRUSTED_NPM = [11, 5, 1] as const;
95
+ const PUBLISH_ONLY_LIFECYCLE_SCRIPTS = [
96
+ 'prepublishOnly',
97
+ 'publish',
98
+ 'postpublish',
99
+ ] as const;
71
100
 
72
101
  const USAGE = `Usage: bun scripts/publish.ts [options]
73
102
 
74
- Publish all public @ontrails/* workspaces in dep order using \`bun publish\`.
103
+ Publish all public @ontrails/* workspaces in dependency order. Bun packs and
104
+ validates each tarball; npm publishes the resolved tarball to the registry.
75
105
 
76
106
  Options:
77
107
  --check Pre-publish verification only. Runs \`bun pm pack --dry-run\`
78
108
  (required so \`catalog:\` resolves) and asserts the packed
79
- manifest has no \`workspace:\` or \`catalog:\` ranges. No publishing.
109
+ manifest has no \`workspace:\` or \`catalog:\` ranges and
110
+ validates trusted-publishing repository metadata. No publishing.
80
111
  --dry-run Alias for --check.
81
112
  --tag <tag> npm dist-tag. Defaults to .changeset/pre.json tag when in
82
113
  prerelease mode, otherwise "latest".
83
114
  --otp <code> Two-factor code. Also read from BUN_PUBLISH_OTP.
115
+ --trusted-publishing Require GitHub Actions OIDC and npm trusted-publishing
116
+ prerequisites before attempting the first package.
84
117
  --only <name[,name]> Restrict to the named packages (repeatable). Useful for
85
118
  partial reruns after a mid-matrix failure.
86
119
  -h, --help Show this help and exit.
@@ -96,6 +129,7 @@ const parseArgs = (argv: readonly string[]): NativeBunPublishOptions => {
96
129
  let mode: NativeBunPublishOptions['mode'] = 'publish';
97
130
  let tag: string | undefined;
98
131
  let otp: string | undefined = process.env['BUN_PUBLISH_OTP'] || undefined;
132
+ let trustedPublishing = false;
99
133
  const only: string[] = [];
100
134
 
101
135
  const needsValue = (flag: string, value: string | undefined): string => {
@@ -118,6 +152,8 @@ const parseArgs = (argv: readonly string[]): NativeBunPublishOptions => {
118
152
  } else if (arg === '--otp') {
119
153
  i += 1;
120
154
  otp = needsValue('--otp', argv[i]);
155
+ } else if (arg === '--trusted-publishing') {
156
+ trustedPublishing = true;
121
157
  } else if (arg === '--only') {
122
158
  i += 1;
123
159
  const value = needsValue('--only', argv[i]);
@@ -143,6 +179,7 @@ const parseArgs = (argv: readonly string[]): NativeBunPublishOptions => {
143
179
  only: only.length > 0 ? only : undefined,
144
180
  otp,
145
181
  tag,
182
+ trustedPublishing,
146
183
  };
147
184
  };
148
185
 
@@ -334,6 +371,10 @@ const discoverWorkspaces = async (): Promise<NativeBunPublishWorkspace[]> => {
334
371
  isPrivate: pkg.private === true,
335
372
  name: pkg.name,
336
373
  path: dir,
374
+ publishLifecycleScripts: PUBLISH_ONLY_LIFECYCLE_SCRIPTS.filter(
375
+ (name) => pkg.scripts?.[name] !== undefined
376
+ ),
377
+ repository: pkg.repository,
337
378
  version: pkg.version ?? '0.0.0',
338
379
  workspaceDeps: collectWorkspaceDeps(pkg),
339
380
  });
@@ -433,23 +474,19 @@ const spawnCapture = async (
433
474
  return { exitCode, stdout };
434
475
  };
435
476
 
436
- /**
437
- * Pack a package to a temp dir and assert the resulting tarball's
438
- * `package/package.json` contains no `workspace:` or `catalog:` ranges.
439
- *
440
- * @throws When packing fails or forbidden ranges are found.
441
- */
442
- const assertManifestClean = async (
443
- ws: NativeBunPublishWorkspace,
444
- workspacesByName: ReadonlyMap<string, NativeBunPublishWorkspace>
445
- ): Promise<void> => {
446
- const tmp = await mkdtemp(join(tmpdir(), 'trails-publish-'));
477
+ interface PackedWorkspaceTarball {
478
+ readonly directory: string;
479
+ readonly path: string;
480
+ }
481
+
482
+ /** Pack through Bun so workspace and catalog ranges resolve before npm sees the manifest. */
483
+ const packWorkspaceTarball = async (
484
+ ws: NativeBunPublishWorkspace
485
+ ): Promise<PackedWorkspaceTarball> => {
486
+ const directory = await mkdtemp(join(tmpdir(), 'trails-publish-'));
447
487
  try {
448
- // Use `bun pm pack` so the packed manifest reflects what `bun publish`
449
- // will upload: workspace: and catalog: ranges are resolved the same way.
450
- // npm pack does not resolve `catalog:` and would produce false positives.
451
488
  const pack = await spawnCapture(
452
- ['bun', 'pm', 'pack', '--destination', tmp],
489
+ ['bun', 'pm', 'pack', '--destination', directory],
453
490
  ws.path
454
491
  );
455
492
  if (pack.exitCode !== 0) {
@@ -457,78 +494,133 @@ const assertManifestClean = async (
457
494
  `bun pm pack failed for ${ws.name} (exit ${pack.exitCode})`
458
495
  );
459
496
  }
460
- const tarEntries = await readdir(tmp);
461
- const tarName = tarEntries.find((n) =>
462
- typeof n === 'string' ? n.endsWith('.tgz') : String(n).endsWith('.tgz')
497
+ const entries = await readdir(directory);
498
+ const tarName = entries.find((name) =>
499
+ typeof name === 'string'
500
+ ? name.endsWith('.tgz')
501
+ : String(name).endsWith('.tgz')
463
502
  );
464
503
  if (!tarName) {
465
504
  throw new Error(`bun pm pack produced no tarball for ${ws.name}`);
466
505
  }
467
- const tarPath = join(tmp, String(tarName));
506
+ return { directory, path: join(directory, String(tarName)) };
507
+ } catch (error) {
508
+ await rm(directory, { force: true, recursive: true });
509
+ throw error;
510
+ }
511
+ };
468
512
 
469
- const extract = Bun.spawn(
470
- ['tar', '-xOf', tarPath, 'package/package.json'],
471
- { stderr: 'pipe', stdin: 'ignore', stdout: 'pipe' }
513
+ /** Assert that a Bun-packed tarball contains publishable dependency ranges. */
514
+ const assertPackedManifestClean = async (
515
+ ws: NativeBunPublishWorkspace,
516
+ workspacesByName: ReadonlyMap<string, NativeBunPublishWorkspace>,
517
+ tarPath: string
518
+ ): Promise<void> => {
519
+ const extract = Bun.spawn(['tar', '-xOf', tarPath, 'package/package.json'], {
520
+ stderr: 'pipe',
521
+ stdin: 'ignore',
522
+ stdout: 'pipe',
523
+ });
524
+ const [manifestText, tarStderr, extractExit] = await Promise.all([
525
+ new Response(extract.stdout).text(),
526
+ new Response(extract.stderr).text(),
527
+ extract.exited,
528
+ ]);
529
+ if (extractExit !== 0) {
530
+ const detail = tarStderr.trim() || '(no stderr output)';
531
+ throw new Error(
532
+ `tar extraction failed for ${ws.name} (exit ${extractExit}): ${detail}`
472
533
  );
473
- const [manifestText, tarStderr, extractExit] = await Promise.all([
474
- new Response(extract.stdout).text(),
475
- new Response(extract.stderr).text(),
476
- extract.exited,
477
- ]);
478
- if (extractExit !== 0) {
479
- const detail = tarStderr.trim() || '(no stderr output)';
480
- throw new Error(
481
- `tar extraction failed for ${ws.name} (exit ${extractExit}): ${detail}`
482
- );
483
- }
534
+ }
484
535
 
485
- let packedPackage: NativeBunPublishPackageJson;
486
- try {
487
- packedPackage = JSON.parse(manifestText) as NativeBunPublishPackageJson;
488
- } catch (error) {
489
- throw new Error(
490
- `Invalid packed package.json for ${ws.name}: ${(error as Error).message}`,
491
- { cause: error }
492
- );
493
- }
536
+ let packedPackage: NativeBunPublishPackageJson;
537
+ try {
538
+ packedPackage = JSON.parse(manifestText) as NativeBunPublishPackageJson;
539
+ } catch (error) {
540
+ throw new Error(
541
+ `Invalid packed package.json for ${ws.name}: ${(error as Error).message}`,
542
+ { cause: error }
543
+ );
544
+ }
494
545
 
495
- const offenders: string[] = [];
496
- for (const [lineNo, line] of manifestText.split('\n').entries()) {
497
- if (line.includes('"workspace:') || line.includes('"catalog:')) {
498
- offenders.push(` line ${lineNo + 1}: ${line.trim()}`);
499
- }
500
- }
501
- if (offenders.length > 0) {
502
- const relPath = relative(REPO_ROOT, ws.path);
503
- const hint =
504
- ' Hint: `bun publish` rewrites these at pack time. Verify the package was packed via bun, not npm.';
505
- throw new Error(
506
- `Packed manifest for ${ws.name} (${relPath}) contains forbidden ranges:\n${offenders.join('\n')}\n${hint}`
507
- );
546
+ const offenders: string[] = [];
547
+ for (const [lineNo, line] of manifestText.split('\n').entries()) {
548
+ if (line.includes('"workspace:') || line.includes('"catalog:')) {
549
+ offenders.push(` line ${lineNo + 1}: ${line.trim()}`);
508
550
  }
509
- const sourcePackage = await readJson<NativeBunPublishPackageJson>(
510
- join(ws.path, 'package.json')
551
+ }
552
+ if (offenders.length > 0) {
553
+ const relPath = relative(REPO_ROOT, ws.path);
554
+ throw new Error(
555
+ `Packed manifest for ${ws.name} (${relPath}) contains forbidden ranges:\n${offenders.join('\n')}\n Hint: publish the Bun-packed tarball instead of packing through npm.`
511
556
  );
512
- const mismatches = findPackedFirstPartyDependencyMismatches({
513
- packageName: ws.name,
514
- packagePath: ws.path,
515
- packedPackage,
516
- sourcePackage,
517
- workspacesByName,
518
- });
519
- if (mismatches.length > 0) {
520
- throw new Error(mismatches.join('\n'));
521
- }
522
- } finally {
523
- await rm(tmp, { force: true, recursive: true });
557
+ }
558
+ const sourcePackage = await readJson<NativeBunPublishPackageJson>(
559
+ join(ws.path, 'package.json')
560
+ );
561
+ const mismatches = findPackedFirstPartyDependencyMismatches({
562
+ packageName: ws.name,
563
+ packagePath: ws.path,
564
+ packedPackage,
565
+ sourcePackage,
566
+ workspacesByName,
567
+ });
568
+ if (mismatches.length > 0) {
569
+ throw new Error(mismatches.join('\n'));
524
570
  }
525
571
  };
526
572
 
573
+ export const unsupportedPublishLifecycleScripts = (
574
+ workspaces: readonly NativeBunPublishWorkspace[]
575
+ ): string[] =>
576
+ workspaces.flatMap((workspace) =>
577
+ workspace.isPrivate
578
+ ? []
579
+ : (workspace.publishLifecycleScripts ?? []).map(
580
+ (script) => `${workspace.name} defines unsupported ${script}`
581
+ )
582
+ );
583
+
584
+ /** Validate repository metadata required by npm trusted publishing. */
585
+ export const publishRepositoryMetadataErrors = (
586
+ workspaces: readonly NativeBunPublishWorkspace[]
587
+ ): string[] =>
588
+ workspaces.flatMap((workspace) => {
589
+ if (workspace.isPrivate) {
590
+ return [];
591
+ }
592
+ const { repository } = workspace;
593
+ const expectedDirectory = relative(REPO_ROOT, workspace.path);
594
+ if (
595
+ typeof repository !== 'string' &&
596
+ repository?.type === 'git' &&
597
+ repository.url === TRUSTED_REPOSITORY_URL &&
598
+ repository.directory === expectedDirectory
599
+ ) {
600
+ return [];
601
+ }
602
+ return [
603
+ `${workspace.name} must declare repository ${TRUSTED_REPOSITORY_URL} with directory ${expectedDirectory}`,
604
+ ];
605
+ });
606
+
527
607
  /** Run `--check` flow: pack dry-run plus manifest-range assertion per package. */
528
608
  const runCheck = async (
529
609
  workspaces: readonly NativeBunPublishWorkspace[],
530
610
  allWorkspaces: readonly NativeBunPublishWorkspace[]
531
611
  ): Promise<number> => {
612
+ const repositoryErrors = publishRepositoryMetadataErrors(workspaces);
613
+ if (repositoryErrors.length > 0) {
614
+ throw new Error(
615
+ `Package repository metadata check failed:\n${repositoryErrors.map((error) => `- ${error}`).join('\n')}`
616
+ );
617
+ }
618
+ const unsupportedLifecycle = unsupportedPublishLifecycleScripts(workspaces);
619
+ if (unsupportedLifecycle.length > 0) {
620
+ throw new Error(
621
+ `Tarball publication cannot honor publish-only lifecycle scripts:\n${unsupportedLifecycle.map((error) => `- ${error}`).join('\n')}`
622
+ );
623
+ }
532
624
  const workspacesByName = new Map(allWorkspaces.map((ws) => [ws.name, ws]));
533
625
  for (const ws of workspaces) {
534
626
  if (ws.isPrivate) {
@@ -545,7 +637,12 @@ const runCheck = async (
545
637
  return 1;
546
638
  }
547
639
  try {
548
- await assertManifestClean(ws, workspacesByName);
640
+ const packed = await packWorkspaceTarball(ws);
641
+ try {
642
+ await assertPackedManifestClean(ws, workspacesByName, packed.path);
643
+ } finally {
644
+ await rm(packed.directory, { force: true, recursive: true });
645
+ }
549
646
  } catch (error) {
550
647
  fail((error as Error).message);
551
648
  return 1;
@@ -557,36 +654,246 @@ const runCheck = async (
557
654
  return 0;
558
655
  };
559
656
 
657
+ const compareNumericVersion = (
658
+ version: string,
659
+ minimum: readonly [number, number, number]
660
+ ): number => {
661
+ const actual = version
662
+ .split('.')
663
+ .slice(0, 3)
664
+ .map((part) => Number.parseInt(part, 10));
665
+ if (actual.length !== 3 || actual.some((part) => Number.isNaN(part))) {
666
+ return -1;
667
+ }
668
+ for (let index = 0; index < minimum.length; index += 1) {
669
+ const delta = (actual[index] ?? 0) - (minimum[index] ?? 0);
670
+ if (delta !== 0) {
671
+ return delta;
672
+ }
673
+ }
674
+ return 0;
675
+ };
676
+
677
+ export const trustedPublishingPreflightErrors = ({
678
+ env,
679
+ nodeVersion,
680
+ npmVersion,
681
+ workspaces,
682
+ }: {
683
+ readonly env: Readonly<Record<string, string | undefined>>;
684
+ readonly nodeVersion: string;
685
+ readonly npmVersion: string;
686
+ readonly workspaces: readonly NativeBunPublishWorkspace[];
687
+ }): string[] => {
688
+ const errors: string[] = [];
689
+ if (env['GITHUB_ACTIONS'] !== 'true') {
690
+ errors.push('trusted publishing requires a GitHub Actions runner');
691
+ }
692
+ if (!env['ACTIONS_ID_TOKEN_REQUEST_URL']) {
693
+ errors.push('GitHub OIDC request URL is unavailable (id-token: write)');
694
+ }
695
+ if (!env['ACTIONS_ID_TOKEN_REQUEST_TOKEN']) {
696
+ errors.push('GitHub OIDC request token is unavailable (id-token: write)');
697
+ }
698
+ if (compareNumericVersion(nodeVersion, MINIMUM_TRUSTED_NODE) < 0) {
699
+ errors.push(
700
+ `trusted publishing requires Node >= ${MINIMUM_TRUSTED_NODE.join('.')}; found ${nodeVersion}`
701
+ );
702
+ }
703
+ if (compareNumericVersion(npmVersion, MINIMUM_TRUSTED_NPM) < 0) {
704
+ errors.push(
705
+ `trusted publishing requires npm >= ${MINIMUM_TRUSTED_NPM.join('.')}; found ${npmVersion}`
706
+ );
707
+ }
708
+ errors.push(...publishRepositoryMetadataErrors(workspaces));
709
+ return errors;
710
+ };
711
+
712
+ export const createNpmPublishCommand = ({
713
+ otp,
714
+ tag,
715
+ tarballPath,
716
+ }: {
717
+ readonly otp: string | undefined;
718
+ readonly tag: string;
719
+ readonly tarballPath: string;
720
+ }): string[] => {
721
+ const command = [
722
+ 'npm',
723
+ 'publish',
724
+ tarballPath,
725
+ '--access',
726
+ 'public',
727
+ '--tag',
728
+ tag,
729
+ ];
730
+ if (otp) {
731
+ command.push('--otp', otp);
732
+ }
733
+ return command;
734
+ };
735
+
736
+ export const publicationActionForRegistryState = (
737
+ state: PackageRegistryState,
738
+ trustedPublishing = false
739
+ ): 'block' | 'publish' | 'skip' => {
740
+ if (state.kind === 'complete') {
741
+ return 'skip';
742
+ }
743
+ if (state.kind === 'first-time-package') {
744
+ return trustedPublishing ? 'block' : 'publish';
745
+ }
746
+ if (state.kind === 'needs-publish') {
747
+ return 'publish';
748
+ }
749
+ return 'block';
750
+ };
751
+
752
+ const registryPublicationBlocker = (
753
+ workspace: NativeBunPublishWorkspace,
754
+ state: PackageRegistryState,
755
+ trustedPublishing: boolean
756
+ ): string => {
757
+ if (state.kind === 'first-time-package' && trustedPublishing) {
758
+ return `${workspace.name} is not published yet; bootstrap it with credentialed local tooling, configure its npm trusted publisher, then retry`;
759
+ }
760
+ if (state.kind === 'needs-tag-repair') {
761
+ return `${workspace.name}@${workspace.version} is published but the requested dist-tag points at ${state.currentTagVersion ?? '(missing)'}; repair it with credentialed npm tooling before retrying`;
762
+ }
763
+ if (state.kind === 'tag-points-ahead') {
764
+ return `${workspace.name} dist-tag points ahead at ${state.currentTagVersion}; refusing to publish ${workspace.version}`;
765
+ }
766
+ if (state.kind === 'registry-inaccessible') {
767
+ return `Could not inspect ${workspace.name}: ${state.error}`;
768
+ }
769
+ return `Could not determine whether ${workspace.name}@${workspace.version} is ready`;
770
+ };
771
+
772
+ const assertTrustedPublishingReady = async (
773
+ workspaces: readonly NativeBunPublishWorkspace[]
774
+ ): Promise<void> => {
775
+ const npmVersionResult = await spawnCapture(['npm', '--version'], REPO_ROOT);
776
+ if (npmVersionResult.exitCode !== 0) {
777
+ throw new Error('Could not read npm version for trusted publishing');
778
+ }
779
+ const nodeVersionResult = await spawnCapture(
780
+ ['node', '--version'],
781
+ REPO_ROOT
782
+ );
783
+ if (nodeVersionResult.exitCode !== 0) {
784
+ throw new Error('Could not read Node version for trusted publishing');
785
+ }
786
+ const errors = trustedPublishingPreflightErrors({
787
+ env: process.env,
788
+ nodeVersion: nodeVersionResult.stdout.trim().replace(/^v/, ''),
789
+ npmVersion: npmVersionResult.stdout.trim(),
790
+ workspaces,
791
+ });
792
+ if (errors.length > 0) {
793
+ throw new Error(
794
+ `Trusted publishing preflight failed:\n${errors.map((error) => `- ${error}`).join('\n')}`
795
+ );
796
+ }
797
+ success('Trusted publishing prerequisites are available');
798
+ };
799
+
560
800
  /** Run the actual publish flow sequentially. Aborts on first failure. */
561
801
  const runPublish = async (
562
802
  workspaces: readonly NativeBunPublishWorkspace[],
803
+ allWorkspaces: readonly NativeBunPublishWorkspace[],
563
804
  tag: string,
564
- otp: string | undefined
805
+ otp: string | undefined,
806
+ trustedPublishing: boolean
565
807
  ): Promise<number> => {
808
+ if (trustedPublishing) {
809
+ await assertTrustedPublishingReady(workspaces);
810
+ }
811
+ const unsupportedLifecycle = unsupportedPublishLifecycleScripts(workspaces);
812
+ if (unsupportedLifecycle.length > 0) {
813
+ throw new Error(
814
+ `Tarball publication cannot honor publish-only lifecycle scripts:\n${unsupportedLifecycle.map((error) => `- ${error}`).join('\n')}`
815
+ );
816
+ }
817
+ const publicWorkspaces = workspaces.filter(
818
+ (workspace) => !workspace.isPrivate
819
+ );
820
+ const registryResults = await checkRegistryPosture(
821
+ publicWorkspaces,
822
+ npmRegistryView,
823
+ npmRegistryVersionProofView,
824
+ tag
825
+ );
826
+ const registryStates = new Map<string, PackageRegistryState>();
827
+ const registryBlockers: string[] = [];
828
+ for (const workspace of publicWorkspaces) {
829
+ const registryResult = registryResults.find(
830
+ (result) => result.name === workspace.name
831
+ );
832
+ if (!registryResult) {
833
+ registryBlockers.push(
834
+ `Could not read registry posture for ${workspace.name}`
835
+ );
836
+ continue;
837
+ }
838
+ const state = classifyPackageRegistryState(
839
+ factsFromRegistryResult(registryResult)
840
+ );
841
+ registryStates.set(workspace.name, state);
842
+ if (
843
+ publicationActionForRegistryState(state, trustedPublishing) === 'block'
844
+ ) {
845
+ registryBlockers.push(
846
+ registryPublicationBlocker(workspace, state, trustedPublishing)
847
+ );
848
+ }
849
+ }
850
+ if (registryBlockers.length > 0) {
851
+ throw new Error(
852
+ `Registry preflight blocked publication before any package was published:\n${registryBlockers.map((error) => `- ${error}`).join('\n')}`
853
+ );
854
+ }
855
+ const workspacesByName = new Map(allWorkspaces.map((ws) => [ws.name, ws]));
566
856
  for (const ws of workspaces) {
567
857
  if (ws.isPrivate) {
568
858
  info(`Skipping ${ws.name} (private)`);
569
859
  continue;
570
860
  }
571
- info(`Publishing ${ws.name}@${ws.version}... (tag=${tag})`);
572
- const cmd: string[] = [
573
- 'bun',
574
- 'publish',
575
- '--access',
576
- 'public',
577
- '--tag',
578
- tag,
579
- ];
580
- if (otp) {
581
- cmd.push('--otp', otp);
861
+ const registryState = registryStates.get(ws.name);
862
+ if (!registryState) {
863
+ fail(`Registry preflight did not produce a state for ${ws.name}`);
864
+ return 1;
582
865
  }
583
- const code = await spawnInherit(cmd, ws.path);
584
- if (code !== 0) {
585
- fail(
586
- `Failed to publish ${ws.name} (exit ${code}); aborting remaining publishes`
866
+ const publicationAction = publicationActionForRegistryState(
867
+ registryState,
868
+ trustedPublishing
869
+ );
870
+ if (publicationAction === 'skip') {
871
+ success(
872
+ `${ws.name}@${ws.version} already published with ${tag} at target; continuing`
587
873
  );
874
+ continue;
875
+ }
876
+ if (publicationAction === 'block') {
877
+ fail(registryPublicationBlocker(ws, registryState, trustedPublishing));
588
878
  return 1;
589
879
  }
880
+ info(`Publishing ${ws.name}@${ws.version}... (tag=${tag})`);
881
+ const packed = await packWorkspaceTarball(ws);
882
+ try {
883
+ await assertPackedManifestClean(ws, workspacesByName, packed.path);
884
+ const code = await spawnInherit(
885
+ createNpmPublishCommand({ otp, tag, tarballPath: packed.path }),
886
+ ws.path
887
+ );
888
+ if (code !== 0) {
889
+ fail(
890
+ `Failed to publish ${ws.name} (exit ${code}); aborting remaining publishes`
891
+ );
892
+ return 1;
893
+ }
894
+ } finally {
895
+ await rm(packed.directory, { force: true, recursive: true });
896
+ }
590
897
  success(`${ws.name}@${ws.version} published`);
591
898
  }
592
899
  console.log('');
@@ -635,7 +942,13 @@ export const runNativeBunPublishCli = async (
635
942
  fail('Could not resolve a dist-tag. Pass --tag <tag> explicitly.');
636
943
  return 1;
637
944
  }
638
- return await runPublish(selected, tag, opts.otp);
945
+ return await runPublish(
946
+ selected,
947
+ all,
948
+ tag,
949
+ opts.otp,
950
+ opts.trustedPublishing ?? false
951
+ );
639
952
  } catch (error) {
640
953
  const msg = error instanceof Error ? error.message : String(error);
641
954
  fail(msg);