@dbx-tools/projen 0.6.229 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/release.ts CHANGED
@@ -17,6 +17,7 @@ const NPM_REGISTRY_URL = "https://registry.npmjs.org";
17
17
  const nodeReleaseProjects = new WeakSet<DBXToolsJavaScriptProject>();
18
18
  const releaseTagPrefixes = new WeakMap<DBXToolsJavaScriptProject, string>();
19
19
  const releaseWorkflows = new WeakMap<DBXToolsJavaScriptProject, GithubWorkflow>();
20
+ const independentPublicationJobs = new WeakMap<GithubWorkflow, Set<string>>();
20
21
 
21
22
  /** Independently recoverable portions of the release workflow. */
22
23
  export type ReleaseStage = "all" | "node" | "python" | "docs";
@@ -55,6 +56,12 @@ export interface DBXToolsReleaseOptions {
55
56
  readonly pythonRoot?: string;
56
57
  /** Repository task names run in the release worktree before expensive validation and publication. */
57
58
  readonly validationTasks?: readonly string[];
59
+ /**
60
+ * Existing source branch safely fast-forwarded after publication.
61
+ *
62
+ * Missing or diverged branches are left untouched.
63
+ */
64
+ readonly syncBranch?: string | false;
58
65
  /**
59
66
  * Generate a versioned release summary locally. Defaults to enabled with the
60
67
  * standard Cursor, Codex, Claude fallback order.
@@ -74,6 +81,13 @@ export function tryReleaseWorkflow(project: DBXToolsJavaScriptProject): GithubWo
74
81
  return releaseWorkflows.get(project);
75
82
  }
76
83
 
84
+ /** Register one publication job for the independent release completion barrier. */
85
+ export function registerIndependentPublicationJob(workflow: GithubWorkflow, jobId: string): void {
86
+ const jobs = independentPublicationJobs.get(workflow) ?? new Set<string>();
87
+ jobs.add(jobId);
88
+ independentPublicationJobs.set(workflow, jobs);
89
+ }
90
+
77
91
  /** Whether the unified workflow publishes the normal npm workspace. */
78
92
  export function hasNodeRelease(project: DBXToolsJavaScriptProject): boolean {
79
93
  return nodeReleaseProjects.has(project);
@@ -336,10 +350,424 @@ function addDocsJobs(
336
350
  });
337
351
  }
338
352
 
353
+ function independentReleasePleaseJob(project: DBXToolsJavaScriptProject, branch: string): Job {
354
+ return {
355
+ if: "${{ github.event_name == 'push' }}",
356
+ runsOn: ["ubuntu-latest"],
357
+ permissions: {
358
+ contents: JobPermission.WRITE,
359
+ pullRequests: JobPermission.WRITE,
360
+ },
361
+ timeoutMinutes: 30,
362
+ env: { BUN_VERSION },
363
+ outputs: {
364
+ releases_created: { stepId: "release", outputName: "releases_created" },
365
+ prs_created: { stepId: "release", outputName: "prs_created" },
366
+ releases: { stepId: "release", outputName: "releases" },
367
+ prs: { stepId: "release", outputName: "prs" },
368
+ docs_changed: { stepId: "changes", outputName: "docs_changed" },
369
+ recovery_requested: { stepId: "recovery", outputName: "requested" },
370
+ recovery_component: { stepId: "recovery", outputName: "component" },
371
+ recovery_version: { stepId: "recovery", outputName: "version" },
372
+ },
373
+ steps: [
374
+ {
375
+ name: "Checkout source",
376
+ uses: "actions/checkout@v6",
377
+ with: { "fetch-depth": 2 },
378
+ },
379
+ ...bunCacheRestoreSteps(project, {
380
+ ignorePaths: project.workflowCacheIgnorePaths,
381
+ }),
382
+ { name: "Install dependencies", run: "bun install" },
383
+ {
384
+ name: "Refresh Release Please state",
385
+ id: "release",
386
+ env: { GITHUB_TOKEN: "${{ github.token }}" },
387
+ run: [
388
+ 'OWNER="${GITHUB_REPOSITORY%%/*}"',
389
+ 'REPO="${GITHUB_REPOSITORY#*/}"',
390
+ `bun node_modules/@dbx-tools/projen/tasks/release-please.ts --owner "$OWNER" --repo "$REPO" --target-branch ${JSON.stringify(branch)}`,
391
+ ].join("\n"),
392
+ },
393
+ {
394
+ name: "Detect documentation changes",
395
+ id: "changes",
396
+ shell: "bash",
397
+ run: [
398
+ "if git diff --quiet HEAD^ HEAD -- README.md AGENTS.md ':(glob)**/README.md' docs; then",
399
+ ' echo "docs_changed=false" >> "$GITHUB_OUTPUT"',
400
+ "else",
401
+ ' echo "docs_changed=true" >> "$GITHUB_OUTPUT"',
402
+ "fi",
403
+ ].join("\n"),
404
+ },
405
+ {
406
+ name: "Detect committed recovery request",
407
+ id: "recovery",
408
+ shell: "bash",
409
+ run: [
410
+ "if [ -f .release-recovery.json ]; then",
411
+ ' echo "requested=true" >> "$GITHUB_OUTPUT"',
412
+ ' echo "component=$(jq -r .component .release-recovery.json)" >> "$GITHUB_OUTPUT"',
413
+ ' echo "version=$(jq -r .version .release-recovery.json)" >> "$GITHUB_OUTPUT"',
414
+ "else",
415
+ ' echo "requested=false" >> "$GITHUB_OUTPUT"',
416
+ "fi",
417
+ ].join("\n"),
418
+ },
419
+ {
420
+ name: "Reconcile generated release PR files",
421
+ if: "${{ steps.release.outputs.prs_created == 'true' }}",
422
+ env: { RELEASE_PRS: "${{ steps.release.outputs.prs }}" },
423
+ shell: "bash",
424
+ run: [
425
+ 'BRANCH="$(jq -r \'.[0].headBranchName\' <<<"$RELEASE_PRS")"',
426
+ 'test -n "$BRANCH" && test "$BRANCH" != "null"',
427
+ 'git fetch origin "$BRANCH"',
428
+ 'git switch --force-create "$BRANCH" "origin/$BRANCH"',
429
+ "bun install",
430
+ "bunx projen",
431
+ "bun node_modules/@dbx-tools/projen/tasks/version-check.ts",
432
+ `bun node_modules/@dbx-tools/projen/tasks/release-plan.ts --base-ref origin/${branch}`,
433
+ `bun node_modules/@dbx-tools/projen/tasks/release-summary-units.ts --from-ref origin/${branch}`,
434
+ 'if ! git diff --quiet || test -n "$(git ls-files --others --exclude-standard)"; then',
435
+ ' git config user.name "github-actions[bot]"',
436
+ ' git config user.email "41898282+github-actions[bot]@users.noreply.github.com"',
437
+ " git add -A",
438
+ ' git commit -m "chore: reconcile release metadata"',
439
+ ' git push origin "HEAD:$BRANCH"',
440
+ "fi",
441
+ ].join("\n"),
442
+ },
443
+ ],
444
+ };
445
+ }
446
+
447
+ function independentReleasePlanJob(project: DBXToolsJavaScriptProject): Job {
448
+ return {
449
+ if: "${{ always() && (github.event_name == 'workflow_dispatch' || needs.release-please.outputs.releases_created == 'true' || needs.release-please.outputs.docs_changed == 'true' || needs.release-please.outputs.recovery_requested == 'true') }}",
450
+ needs: ["release-please"],
451
+ runsOn: ["ubuntu-latest"],
452
+ permissions: { contents: JobPermission.READ },
453
+ timeoutMinutes: 15,
454
+ env: { BUN_VERSION },
455
+ outputs: {
456
+ release_sha: { stepId: "plan", outputName: "release_sha" },
457
+ rust: { stepId: "plan", outputName: "rust" },
458
+ python: { stepId: "plan", outputName: "python" },
459
+ node: { stepId: "plan", outputName: "node" },
460
+ github: { stepId: "plan", outputName: "github" },
461
+ docs: { stepId: "plan", outputName: "docs" },
462
+ units: { stepId: "plan", outputName: "units" },
463
+ python_packages: { stepId: "plan", outputName: "python_packages" },
464
+ rust_packages: { stepId: "plan", outputName: "rust_packages" },
465
+ artifacts: { stepId: "plan", outputName: "artifacts" },
466
+ rust_targets: { stepId: "plan", outputName: "rust_targets" },
467
+ },
468
+ steps: [
469
+ {
470
+ name: "Checkout release source",
471
+ uses: "actions/checkout@v6",
472
+ with: { "fetch-depth": 2 },
473
+ },
474
+ ...bunCacheRestoreSteps(project, {
475
+ ignorePaths: project.workflowCacheIgnorePaths,
476
+ }),
477
+ { name: "Install dependencies", run: "bun install" },
478
+ {
479
+ name: "Build affected release plan",
480
+ id: "plan",
481
+ shell: "bash",
482
+ env: {
483
+ COMPONENT:
484
+ "${{ inputs.component || needs.release-please.outputs.recovery_component || '' }}",
485
+ VERSION: "${{ inputs.version || needs.release-please.outputs.recovery_version || '' }}",
486
+ DOCS_CHANGED: "${{ needs.release-please.outputs.docs_changed || 'false' }}",
487
+ },
488
+ run: [
489
+ "ARGS=()",
490
+ 'if [ -n "$COMPONENT" ]; then ARGS+=(--component "$COMPONENT" --version "$VERSION"); fi',
491
+ 'if [ "$DOCS_CHANGED" = "true" ]; then ARGS+=(--docs); fi',
492
+ 'bun node_modules/@dbx-tools/projen/tasks/release-plan.ts "${ARGS[@]}"',
493
+ ].join("\n"),
494
+ },
495
+ {
496
+ name: "Upload release plan",
497
+ uses: "actions/upload-artifact@v7",
498
+ with: {
499
+ name: "release-plan",
500
+ path: "dist/release-plan.json",
501
+ "if-no-files-found": "error",
502
+ },
503
+ },
504
+ ],
505
+ };
506
+ }
507
+
508
+ /** Checkout and install the immutable source selected by an independent release plan. */
509
+ export function independentReleaseSetupSteps(
510
+ project: DBXToolsJavaScriptProject,
511
+ ): readonly JobStep[] {
512
+ return [
513
+ {
514
+ name: "Checkout release source",
515
+ uses: "actions/checkout@v6",
516
+ with: { ref: "${{ needs.release-plan.outputs.release_sha }}", "fetch-depth": 1 },
517
+ },
518
+ ...bunCacheRestoreSteps(project, {
519
+ ignorePaths: project.workflowCacheIgnorePaths,
520
+ }),
521
+ { name: "Install dependencies", run: "bun install" },
522
+ {
523
+ name: "Download release plan",
524
+ uses: "actions/download-artifact@v8",
525
+ with: { name: "release-plan", path: "dist" },
526
+ },
527
+ ];
528
+ }
529
+
530
+ function independentNodePublishJob(project: DBXToolsJavaScriptProject): Job {
531
+ return {
532
+ if: "${{ needs.release-plan.outputs.node == 'true' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'node') }}",
533
+ needs: ["release-plan"],
534
+ runsOn: ["ubuntu-latest"],
535
+ permissions: { contents: JobPermission.READ, idToken: JobPermission.WRITE },
536
+ timeoutMinutes: 60,
537
+ env: { BUN_VERSION, CI: "true" },
538
+ steps: [
539
+ ...independentReleaseSetupSteps(project),
540
+ {
541
+ name: "Publish affected npm packages",
542
+ env: npmPublishEnvironment(),
543
+ run: "bun node_modules/@dbx-tools/projen/tasks/publish.ts --plan dist/release-plan.json",
544
+ },
545
+ ],
546
+ };
547
+ }
548
+
549
+ function independentReleaseNotesJob(project: DBXToolsJavaScriptProject): Job {
550
+ return {
551
+ if: "${{ needs.release-plan.outputs.units != '[]' }}",
552
+ needs: ["release-plan"],
553
+ runsOn: ["ubuntu-latest"],
554
+ permissions: { contents: JobPermission.WRITE },
555
+ env: { BUN_VERSION },
556
+ steps: [
557
+ ...independentReleaseSetupSteps(project),
558
+ {
559
+ name: "Publish component release notes",
560
+ env: { GH_TOKEN: "${{ github.token }}" },
561
+ shell: "bash",
562
+ run: [
563
+ "jq -c '.units[]' dist/release-plan.json | while read -r UNIT; do",
564
+ ' COMPONENT="$(jq -r .component <<<"$UNIT")"',
565
+ ' VERSION="$(jq -r .newVersion <<<"$UNIT")"',
566
+ ' gh release edit "$COMPONENT-v$VERSION" --notes-file "docs/releases/$COMPONENT-v$VERSION.md"',
567
+ "done",
568
+ ].join("\n"),
569
+ },
570
+ ],
571
+ };
572
+ }
573
+
574
+ function addIndependentDocsJobs(
575
+ workflow: GithubWorkflow,
576
+ project: DBXToolsJavaScriptProject,
577
+ options: ReleaseDocsOptions,
578
+ ): void {
579
+ workflow.addJob("build-docs", {
580
+ if: "${{ needs.release-plan.outputs.docs == 'true' && needs.publication-complete.result == 'success' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'docs') }}",
581
+ needs: ["release-plan", "publication-complete"],
582
+ runsOn: ["ubuntu-latest"],
583
+ permissions: {
584
+ contents: JobPermission.READ,
585
+ pages: JobPermission.WRITE,
586
+ idToken: JobPermission.WRITE,
587
+ },
588
+ timeoutMinutes: 30,
589
+ env: {
590
+ BUN_VERSION,
591
+ GITHUB_PAGES: "true",
592
+ SITE_URL: options.siteUrl,
593
+ BASE_PATH: options.base ?? "${{ github.event.repository.name }}",
594
+ },
595
+ steps: [
596
+ ...independentReleaseSetupSteps(project),
597
+ ...options.prepareSteps,
598
+ bunCacheSaveStep(),
599
+ ...options.buildSteps,
600
+ {
601
+ name: "Upload Pages artifact",
602
+ uses: "actions/upload-pages-artifact@v4",
603
+ with: { path: options.artifactPath },
604
+ },
605
+ ],
606
+ });
607
+ workflow.addJob("deploy-docs", {
608
+ needs: ["build-docs"],
609
+ environment: {
610
+ name: "github-pages",
611
+ url: "${{ steps.deployment.outputs.page_url }}",
612
+ },
613
+ runsOn: ["ubuntu-latest"],
614
+ permissions: { pages: JobPermission.WRITE, idToken: JobPermission.WRITE },
615
+ timeoutMinutes: 15,
616
+ steps: [
617
+ {
618
+ name: "Deploy to GitHub Pages",
619
+ id: "deployment",
620
+ uses: "actions/deploy-pages@v4",
621
+ },
622
+ ],
623
+ });
624
+ }
625
+
626
+ function addIndependentBranchSyncJob(workflow: GithubWorkflow, branch: string): void {
627
+ workflow.addJob("sync-release-branch", {
628
+ if: "${{ github.event_name == 'push' && needs.publication-complete.result == 'success' }}",
629
+ needs: ["publication-complete"],
630
+ runsOn: ["ubuntu-latest"],
631
+ permissions: { contents: JobPermission.WRITE },
632
+ steps: [
633
+ {
634
+ name: "Checkout released main",
635
+ uses: "actions/checkout@v6",
636
+ with: { "fetch-depth": 0 },
637
+ },
638
+ {
639
+ name: `Safely fast-forward ${branch}`,
640
+ shell: "bash",
641
+ env: { SOURCE_BRANCH: branch },
642
+ run: [
643
+ 'if ! git ls-remote --exit-code --heads origin "refs/heads/$SOURCE_BRANCH" >/dev/null 2>&1; then',
644
+ ' echo "source branch $SOURCE_BRANCH no longer exists; nothing to sync"',
645
+ " exit 0",
646
+ "fi",
647
+ 'git fetch origin "refs/heads/$SOURCE_BRANCH:refs/remotes/origin/$SOURCE_BRANCH"',
648
+ 'if git merge-base --is-ancestor "origin/$SOURCE_BRANCH" HEAD; then',
649
+ ' git push origin "HEAD:refs/heads/$SOURCE_BRANCH"',
650
+ "else",
651
+ ' echo "::warning::source branch $SOURCE_BRANCH diverged from released main; leaving it untouched"',
652
+ "fi",
653
+ ].join("\n"),
654
+ },
655
+ ],
656
+ });
657
+ }
658
+
659
+ class IndependentReleaseFinalizer extends Component {
660
+ private finalized = false;
661
+
662
+ constructor(
663
+ project: DBXToolsJavaScriptProject,
664
+ private readonly workflow: GithubWorkflow,
665
+ private readonly docs: ReleaseDocsOptions | undefined,
666
+ private readonly syncBranch: string | undefined,
667
+ ) {
668
+ super(project);
669
+ }
670
+
671
+ public override preSynthesize(): void {
672
+ if (this.finalized) return;
673
+ this.finalized = true;
674
+ const jobs = [...(independentPublicationJobs.get(this.workflow) ?? [])].sort();
675
+ const successful = jobs
676
+ .map((job) => `needs.${job}.result != 'failure' && needs.${job}.result != 'cancelled'`)
677
+ .join(" && ");
678
+ this.workflow.addJob("publication-complete", {
679
+ if: `\${{ always()${successful ? ` && ${successful}` : ""} }}`,
680
+ needs: jobs,
681
+ runsOn: ["ubuntu-latest"],
682
+ permissions: { contents: JobPermission.READ },
683
+ steps: [{ name: "Confirm publication stages", run: "true" }],
684
+ });
685
+ if (this.docs) {
686
+ addIndependentDocsJobs(this.workflow, this.project as DBXToolsJavaScriptProject, this.docs);
687
+ }
688
+ if (this.syncBranch) {
689
+ addIndependentBranchSyncJob(this.workflow, this.syncBranch);
690
+ }
691
+ }
692
+ }
693
+
694
+ function configureIndependentRelease(
695
+ project: DBXToolsJavaScriptProject,
696
+ options: DBXToolsReleaseOptions,
697
+ ): void {
698
+ const branch = projectReleaseBranch(project);
699
+ if (options.nodeRelease !== false) nodeReleaseProjects.add(project);
700
+ applyTasks(project, {
701
+ "version:check": {
702
+ exec: taskScript(project, "version-check.ts"),
703
+ description: "Verify every package against its release unit",
704
+ },
705
+ "release:plan": {
706
+ exec: taskScript(project, "release-plan.ts"),
707
+ receiveArgs: true,
708
+ description: "Build the affected release plan",
709
+ },
710
+ release: {
711
+ exec: taskScript(project, "release-please.ts", `--target-branch ${JSON.stringify(branch)}`),
712
+ receiveArgs: true,
713
+ description: "Refresh independent release PRs, tags, and GitHub Releases",
714
+ },
715
+ });
716
+ if (!project.github) return;
717
+ const workflow = new GithubWorkflow(project.github, "release", {
718
+ fileName: "release.yml",
719
+ limitConcurrency: true,
720
+ concurrencyOptions: { group: "release", cancelInProgress: false },
721
+ });
722
+ workflow.runName =
723
+ "release units " + "${{ github.event_name == 'push' && github.sha || inputs.component }}";
724
+ workflow.on({
725
+ push: { branches: [branch] },
726
+ workflowDispatch: {
727
+ inputs: {
728
+ component: {
729
+ description: "Component to recover",
730
+ type: "string",
731
+ default: "",
732
+ required: false,
733
+ },
734
+ version: {
735
+ description: "Component version to recover",
736
+ type: "string",
737
+ default: "",
738
+ required: false,
739
+ },
740
+ stage: {
741
+ description: "Publication target to recover",
742
+ type: "choice",
743
+ options: ["all", "node", "python", "rust", "github", "docs"],
744
+ default: "all",
745
+ required: true,
746
+ },
747
+ },
748
+ },
749
+ });
750
+ workflow.file?.addOverride("permissions.contents", "read");
751
+ workflow.addJob("release-please", independentReleasePleaseJob(project, branch));
752
+ workflow.addJob("release-plan", independentReleasePlanJob(project));
753
+ workflow.addJob("publish-release-notes", independentReleaseNotesJob(project));
754
+ registerIndependentPublicationJob(workflow, "publish-release-notes");
755
+ if (options.nodeRelease !== false) {
756
+ workflow.addJob("publish-node", independentNodePublishJob(project));
757
+ registerIndependentPublicationJob(workflow, "publish-node");
758
+ }
759
+ new IndependentReleaseFinalizer(project, workflow, options.docs, options.syncBranch || undefined);
760
+ releaseWorkflows.set(project, workflow);
761
+ }
762
+
339
763
  /** Owns the single release workflow and local release preparation tasks. */
340
764
  export class DBXToolsRelease extends Component {
341
765
  constructor(project: DBXToolsJavaScriptProject, options: DBXToolsReleaseOptions = {}) {
342
766
  super(project);
767
+ if (project.releaseCatalog.mode === "independent") {
768
+ configureIndependentRelease(project, options);
769
+ return;
770
+ }
343
771
  const tagPrefix = options.tagPrefix ?? "v";
344
772
  const releaseBranch = projectReleaseBranch(project);
345
773
  const summary = options.summary ?? true;
@@ -209,7 +209,7 @@ export async function publishNpmArchives(options: {
209
209
  readonly directory: string;
210
210
  readonly dryRun?: boolean;
211
211
  readonly registry?: string;
212
- readonly version: string;
212
+ readonly version?: string;
213
213
  }): Promise<void> {
214
214
  const directory = resolve(options.directory);
215
215
  const archives = readdirSync(directory)
@@ -220,7 +220,7 @@ export async function publishNpmArchives(options: {
220
220
 
221
221
  for (const archive of archives) {
222
222
  const local = readNpmArchiveIdentity(archive);
223
- if (local.version !== options.version) {
223
+ if (options.version && local.version !== options.version) {
224
224
  throw new Error(
225
225
  `npm archive ${archive} carries ${local.version}, expected ${options.version}`,
226
226
  );
@@ -257,11 +257,11 @@ if (import.meta.main) {
257
257
  const program = new Command();
258
258
  program
259
259
  .requiredOption("--directory <path>", "Directory containing npm archives")
260
- .requiredOption("--version <version>", "Exact npm release version")
260
+ .option("--version <version>", "Expected npm release version")
261
261
  .option("--registry <url>", "npm registry URL")
262
262
  .option("--dry-run", "Validate archives without publishing")
263
263
  .action(
264
- (options: { directory: string; dryRun?: boolean; registry?: string; version: string }) =>
264
+ (options: { directory: string; dryRun?: boolean; registry?: string; version?: string }) =>
265
265
  publishNpmArchives(options),
266
266
  );
267
267
  await program.parseAsync();
@@ -15,18 +15,22 @@ import { exec } from "@dbx-tools/core";
15
15
  import { Command } from "commander";
16
16
  import { parse, stringify } from "smol-toml";
17
17
  import { pythonProjectInfo, stampPythonProject } from "./uniffi-python.js";
18
+ import type { ReleasePlan } from "../src/release-plan.ts";
18
19
 
19
20
  interface PythonProjectFile {
20
21
  readonly directory: string;
21
22
  readonly mode: number;
22
23
  readonly name: string;
24
+ readonly version: string;
23
25
  readonly path: string;
24
26
  readonly private: boolean;
25
27
  readonly source: string;
28
+ readonly uniffi: boolean;
26
29
  }
27
30
 
28
31
  export interface StampPythonProjectsOptions {
29
32
  readonly rewriteDependencies?: boolean;
33
+ readonly versions?: ReadonlyMap<string, string>;
30
34
  }
31
35
 
32
36
  export interface RestorePythonProjects {
@@ -51,12 +55,16 @@ export function stampPythonProjects(
51
55
  directory: basename(resolve(path, "..")),
52
56
  mode: statSync(path).mode,
53
57
  name: info.name,
58
+ version: info.version,
54
59
  path,
55
60
  private: info.private,
56
61
  source,
62
+ uniffi: info.uniffi,
57
63
  };
58
64
  });
59
- const projects = allProjects.filter((project) => !project.private);
65
+ const projects = allProjects.filter(
66
+ (project) => !project.private && (!options.versions || options.versions.has(project.directory)),
67
+ );
60
68
  if (projects.length === 0) throw new Error(`No Python packages found under ${root}`);
61
69
 
62
70
  try {
@@ -64,8 +72,9 @@ export function stampPythonProjects(
64
72
  const stamped = stampPythonProject(project.source, {
65
73
  packages: allProjects,
66
74
  rewriteDependencies: options.rewriteDependencies,
75
+ usePackageVersions: options.versions !== undefined,
67
76
  toml: { parse, stringify },
68
- version,
77
+ version: options.versions?.get(project.directory) ?? version,
69
78
  });
70
79
  chmodSync(project.path, project.mode | 0o200);
71
80
  writeFileSync(project.path, stamped);
@@ -99,18 +108,37 @@ export function publishPythonProjects(options: {
99
108
  readonly publishUrl: string;
100
109
  readonly root: string;
101
110
  readonly version: string;
111
+ readonly plan?: ReleasePlan;
102
112
  }): void {
103
113
  const root = resolve(options.root);
104
114
  const output = mkdtempSync(join(tmpdir(), "dbx-tools-python-publish-"));
105
- const stamp = stampPythonProjects(root, options.version);
115
+ const planned = new Map(
116
+ options.plan?.pythonPackages.map((pkg) => [pkg.path.replace(/^.*\//, ""), pkg.version]) ?? [],
117
+ );
118
+ const stamp = stampPythonProjects(root, options.version, {
119
+ ...(options.plan ? { versions: planned } : {}),
120
+ });
106
121
  try {
107
- exec.spawnSync("uv", ["build", "--all-packages", "--out-dir", output], {
108
- cwd: process.cwd(),
109
- stdout: "inherit",
110
- stderr: "inherit",
111
- stdin: "ignore",
112
- check: true,
113
- });
122
+ const packages = options.plan?.pythonPackages ?? [];
123
+ if (packages.length > 0) {
124
+ for (const pkg of packages) {
125
+ exec.spawnSync("uv", ["build", "--package", pkg.identity, "--out-dir", output], {
126
+ cwd: process.cwd(),
127
+ stdout: "inherit",
128
+ stderr: "inherit",
129
+ stdin: "ignore",
130
+ check: true,
131
+ });
132
+ }
133
+ } else {
134
+ exec.spawnSync("uv", ["build", "--all-packages", "--out-dir", output], {
135
+ cwd: process.cwd(),
136
+ stdout: "inherit",
137
+ stderr: "inherit",
138
+ stdin: "ignore",
139
+ check: true,
140
+ });
141
+ }
114
142
  exec.spawnSync(
115
143
  "uvx",
116
144
  [
@@ -142,16 +170,30 @@ export function publishPythonProjects(options: {
142
170
  if (import.meta.main) {
143
171
  const program = new Command();
144
172
  program
145
- .argument("<version>", "Python package version")
173
+ .argument("[version]", "Python package version")
146
174
  .requiredOption("--index-url <url>", "devpi Simple API URL")
147
175
  .requiredOption("--publish-url <url>", "devpi writable index URL")
148
176
  .option("--root <path>", "Python workspace package root", "packages/py")
177
+ .option("--plan <path>", "affected release plan")
149
178
  .option("--dry-run", "build and inspect distributions without uploading")
150
179
  .action(
151
180
  (
152
- version: string,
153
- options: { dryRun?: boolean; indexUrl: string; publishUrl: string; root: string },
154
- ) => publishPythonProjects({ ...options, version }),
181
+ version: string | undefined,
182
+ options: {
183
+ dryRun?: boolean;
184
+ indexUrl: string;
185
+ publishUrl: string;
186
+ root: string;
187
+ plan?: string;
188
+ },
189
+ ) => {
190
+ const plan = options.plan
191
+ ? (JSON.parse(readFileSync(options.plan, "utf8")) as ReleasePlan)
192
+ : undefined;
193
+ const fallbackVersion = version ?? plan?.pythonPackages[0]?.version;
194
+ if (!fallbackVersion) throw new Error("Python publication requires a version or plan");
195
+ publishPythonProjects({ ...options, version: fallbackVersion, plan });
196
+ },
155
197
  );
156
198
  await program.parseAsync();
157
199
  }