@dbx-tools/projen 0.6.228 → 0.6.230

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";
@@ -74,6 +75,13 @@ export function tryReleaseWorkflow(project: DBXToolsJavaScriptProject): GithubWo
74
75
  return releaseWorkflows.get(project);
75
76
  }
76
77
 
78
+ /** Register one publication job for the independent release completion barrier. */
79
+ export function registerIndependentPublicationJob(workflow: GithubWorkflow, jobId: string): void {
80
+ const jobs = independentPublicationJobs.get(workflow) ?? new Set<string>();
81
+ jobs.add(jobId);
82
+ independentPublicationJobs.set(workflow, jobs);
83
+ }
84
+
77
85
  /** Whether the unified workflow publishes the normal npm workspace. */
78
86
  export function hasNodeRelease(project: DBXToolsJavaScriptProject): boolean {
79
87
  return nodeReleaseProjects.has(project);
@@ -336,10 +344,387 @@ function addDocsJobs(
336
344
  });
337
345
  }
338
346
 
347
+ function independentReleasePleaseJob(project: DBXToolsJavaScriptProject, branch: string): Job {
348
+ return {
349
+ if: "${{ github.event_name == 'push' }}",
350
+ runsOn: ["ubuntu-latest"],
351
+ permissions: {
352
+ contents: JobPermission.WRITE,
353
+ pullRequests: JobPermission.WRITE,
354
+ },
355
+ timeoutMinutes: 30,
356
+ env: { BUN_VERSION },
357
+ outputs: {
358
+ releases_created: { stepId: "release", outputName: "releases_created" },
359
+ prs_created: { stepId: "release", outputName: "prs_created" },
360
+ releases: { stepId: "release", outputName: "releases" },
361
+ prs: { stepId: "release", outputName: "prs" },
362
+ docs_changed: { stepId: "changes", outputName: "docs_changed" },
363
+ recovery_requested: { stepId: "recovery", outputName: "requested" },
364
+ recovery_component: { stepId: "recovery", outputName: "component" },
365
+ recovery_version: { stepId: "recovery", outputName: "version" },
366
+ },
367
+ steps: [
368
+ {
369
+ name: "Checkout source",
370
+ uses: "actions/checkout@v6",
371
+ with: { "fetch-depth": 2 },
372
+ },
373
+ ...bunCacheRestoreSteps(project, {
374
+ ignorePaths: project.workflowCacheIgnorePaths,
375
+ }),
376
+ { name: "Install dependencies", run: "bun install" },
377
+ {
378
+ name: "Refresh Release Please state",
379
+ id: "release",
380
+ env: { GITHUB_TOKEN: "${{ github.token }}" },
381
+ run: [
382
+ 'OWNER="${GITHUB_REPOSITORY%%/*}"',
383
+ 'REPO="${GITHUB_REPOSITORY#*/}"',
384
+ `bun node_modules/@dbx-tools/projen/tasks/release-please.ts --owner "$OWNER" --repo "$REPO" --target-branch ${JSON.stringify(branch)}`,
385
+ ].join("\n"),
386
+ },
387
+ {
388
+ name: "Detect documentation changes",
389
+ id: "changes",
390
+ shell: "bash",
391
+ run: [
392
+ "if git diff --quiet HEAD^ HEAD -- README.md AGENTS.md ':(glob)**/README.md' docs; then",
393
+ ' echo "docs_changed=false" >> "$GITHUB_OUTPUT"',
394
+ "else",
395
+ ' echo "docs_changed=true" >> "$GITHUB_OUTPUT"',
396
+ "fi",
397
+ ].join("\n"),
398
+ },
399
+ {
400
+ name: "Detect committed recovery request",
401
+ id: "recovery",
402
+ shell: "bash",
403
+ run: [
404
+ "if [ -f .release-recovery.json ]; then",
405
+ ' echo "requested=true" >> "$GITHUB_OUTPUT"',
406
+ ' echo "component=$(jq -r .component .release-recovery.json)" >> "$GITHUB_OUTPUT"',
407
+ ' echo "version=$(jq -r .version .release-recovery.json)" >> "$GITHUB_OUTPUT"',
408
+ "else",
409
+ ' echo "requested=false" >> "$GITHUB_OUTPUT"',
410
+ "fi",
411
+ ].join("\n"),
412
+ },
413
+ {
414
+ name: "Reconcile generated release PR files",
415
+ if: "${{ steps.release.outputs.prs_created == 'true' }}",
416
+ env: { RELEASE_PRS: "${{ steps.release.outputs.prs }}" },
417
+ shell: "bash",
418
+ run: [
419
+ 'BRANCH="$(jq -r \'.[0].headBranchName\' <<<"$RELEASE_PRS")"',
420
+ 'test -n "$BRANCH" && test "$BRANCH" != "null"',
421
+ 'git fetch origin "$BRANCH"',
422
+ 'git switch --force-create "$BRANCH" "origin/$BRANCH"',
423
+ "bun install",
424
+ "bunx projen",
425
+ "bun node_modules/@dbx-tools/projen/tasks/version-check.ts",
426
+ `bun node_modules/@dbx-tools/projen/tasks/release-plan.ts --base-ref origin/${branch}`,
427
+ `bun node_modules/@dbx-tools/projen/tasks/release-summary-units.ts --from-ref origin/${branch}`,
428
+ 'if ! git diff --quiet || test -n "$(git ls-files --others --exclude-standard)"; then',
429
+ ' git config user.name "github-actions[bot]"',
430
+ ' git config user.email "41898282+github-actions[bot]@users.noreply.github.com"',
431
+ " git add -A",
432
+ ' git commit -m "chore: reconcile release metadata"',
433
+ ' git push origin "HEAD:$BRANCH"',
434
+ "fi",
435
+ ].join("\n"),
436
+ },
437
+ ],
438
+ };
439
+ }
440
+
441
+ function independentReleasePlanJob(project: DBXToolsJavaScriptProject): Job {
442
+ return {
443
+ 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') }}",
444
+ needs: ["release-please"],
445
+ runsOn: ["ubuntu-latest"],
446
+ permissions: { contents: JobPermission.READ },
447
+ timeoutMinutes: 15,
448
+ env: { BUN_VERSION },
449
+ outputs: {
450
+ release_sha: { stepId: "plan", outputName: "release_sha" },
451
+ rust: { stepId: "plan", outputName: "rust" },
452
+ python: { stepId: "plan", outputName: "python" },
453
+ node: { stepId: "plan", outputName: "node" },
454
+ github: { stepId: "plan", outputName: "github" },
455
+ docs: { stepId: "plan", outputName: "docs" },
456
+ units: { stepId: "plan", outputName: "units" },
457
+ python_packages: { stepId: "plan", outputName: "python_packages" },
458
+ rust_packages: { stepId: "plan", outputName: "rust_packages" },
459
+ artifacts: { stepId: "plan", outputName: "artifacts" },
460
+ rust_targets: { stepId: "plan", outputName: "rust_targets" },
461
+ },
462
+ steps: [
463
+ {
464
+ name: "Checkout release source",
465
+ uses: "actions/checkout@v6",
466
+ with: { "fetch-depth": 2 },
467
+ },
468
+ ...bunCacheRestoreSteps(project, {
469
+ ignorePaths: project.workflowCacheIgnorePaths,
470
+ }),
471
+ { name: "Install dependencies", run: "bun install" },
472
+ {
473
+ name: "Build affected release plan",
474
+ id: "plan",
475
+ shell: "bash",
476
+ env: {
477
+ COMPONENT:
478
+ "${{ inputs.component || needs.release-please.outputs.recovery_component || '' }}",
479
+ VERSION: "${{ inputs.version || needs.release-please.outputs.recovery_version || '' }}",
480
+ DOCS_CHANGED: "${{ needs.release-please.outputs.docs_changed || 'false' }}",
481
+ },
482
+ run: [
483
+ "ARGS=()",
484
+ 'if [ -n "$COMPONENT" ]; then ARGS+=(--component "$COMPONENT" --version "$VERSION"); fi',
485
+ 'if [ "$DOCS_CHANGED" = "true" ]; then ARGS+=(--docs); fi',
486
+ 'bun node_modules/@dbx-tools/projen/tasks/release-plan.ts "${ARGS[@]}"',
487
+ ].join("\n"),
488
+ },
489
+ {
490
+ name: "Upload release plan",
491
+ uses: "actions/upload-artifact@v7",
492
+ with: {
493
+ name: "release-plan",
494
+ path: "dist/release-plan.json",
495
+ "if-no-files-found": "error",
496
+ },
497
+ },
498
+ ],
499
+ };
500
+ }
501
+
502
+ /** Checkout and install the immutable source selected by an independent release plan. */
503
+ export function independentReleaseSetupSteps(
504
+ project: DBXToolsJavaScriptProject,
505
+ ): readonly JobStep[] {
506
+ return [
507
+ {
508
+ name: "Checkout release source",
509
+ uses: "actions/checkout@v6",
510
+ with: { ref: "${{ needs.release-plan.outputs.release_sha }}", "fetch-depth": 1 },
511
+ },
512
+ ...bunCacheRestoreSteps(project, {
513
+ ignorePaths: project.workflowCacheIgnorePaths,
514
+ }),
515
+ { name: "Install dependencies", run: "bun install" },
516
+ {
517
+ name: "Download release plan",
518
+ uses: "actions/download-artifact@v8",
519
+ with: { name: "release-plan", path: "dist" },
520
+ },
521
+ ];
522
+ }
523
+
524
+ function independentNodePublishJob(project: DBXToolsJavaScriptProject): Job {
525
+ return {
526
+ if: "${{ needs.release-plan.outputs.node == 'true' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'node') }}",
527
+ needs: ["release-plan"],
528
+ runsOn: ["ubuntu-latest"],
529
+ permissions: { contents: JobPermission.READ, idToken: JobPermission.WRITE },
530
+ timeoutMinutes: 60,
531
+ env: { BUN_VERSION, CI: "true" },
532
+ steps: [
533
+ ...independentReleaseSetupSteps(project),
534
+ {
535
+ name: "Publish affected npm packages",
536
+ env: npmPublishEnvironment(),
537
+ run: "bun node_modules/@dbx-tools/projen/tasks/publish.ts --plan dist/release-plan.json",
538
+ },
539
+ ],
540
+ };
541
+ }
542
+
543
+ function independentReleaseNotesJob(project: DBXToolsJavaScriptProject): Job {
544
+ return {
545
+ if: "${{ needs.release-plan.outputs.units != '[]' }}",
546
+ needs: ["release-plan"],
547
+ runsOn: ["ubuntu-latest"],
548
+ permissions: { contents: JobPermission.WRITE },
549
+ env: { BUN_VERSION },
550
+ steps: [
551
+ ...independentReleaseSetupSteps(project),
552
+ {
553
+ name: "Publish component release notes",
554
+ env: { GH_TOKEN: "${{ github.token }}" },
555
+ shell: "bash",
556
+ run: [
557
+ "jq -c '.units[]' dist/release-plan.json | while read -r UNIT; do",
558
+ ' COMPONENT="$(jq -r .component <<<"$UNIT")"',
559
+ ' VERSION="$(jq -r .newVersion <<<"$UNIT")"',
560
+ ' gh release edit "$COMPONENT-v$VERSION" --notes-file "docs/releases/$COMPONENT-v$VERSION.md"',
561
+ "done",
562
+ ].join("\n"),
563
+ },
564
+ ],
565
+ };
566
+ }
567
+
568
+ function addIndependentDocsJobs(
569
+ workflow: GithubWorkflow,
570
+ project: DBXToolsJavaScriptProject,
571
+ options: ReleaseDocsOptions,
572
+ ): void {
573
+ workflow.addJob("build-docs", {
574
+ if: "${{ needs.release-plan.outputs.docs == 'true' && needs.publication-complete.result == 'success' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'docs') }}",
575
+ needs: ["release-plan", "publication-complete"],
576
+ runsOn: ["ubuntu-latest"],
577
+ permissions: {
578
+ contents: JobPermission.READ,
579
+ pages: JobPermission.WRITE,
580
+ idToken: JobPermission.WRITE,
581
+ },
582
+ timeoutMinutes: 30,
583
+ env: {
584
+ BUN_VERSION,
585
+ GITHUB_PAGES: "true",
586
+ SITE_URL: options.siteUrl,
587
+ BASE_PATH: options.base ?? "${{ github.event.repository.name }}",
588
+ },
589
+ steps: [
590
+ ...independentReleaseSetupSteps(project),
591
+ ...options.prepareSteps,
592
+ bunCacheSaveStep(),
593
+ ...options.buildSteps,
594
+ {
595
+ name: "Upload Pages artifact",
596
+ uses: "actions/upload-pages-artifact@v4",
597
+ with: { path: options.artifactPath },
598
+ },
599
+ ],
600
+ });
601
+ workflow.addJob("deploy-docs", {
602
+ needs: ["build-docs"],
603
+ environment: {
604
+ name: "github-pages",
605
+ url: "${{ steps.deployment.outputs.page_url }}",
606
+ },
607
+ runsOn: ["ubuntu-latest"],
608
+ permissions: { pages: JobPermission.WRITE, idToken: JobPermission.WRITE },
609
+ timeoutMinutes: 15,
610
+ steps: [
611
+ {
612
+ name: "Deploy to GitHub Pages",
613
+ id: "deployment",
614
+ uses: "actions/deploy-pages@v4",
615
+ },
616
+ ],
617
+ });
618
+ }
619
+
620
+ class IndependentReleaseFinalizer extends Component {
621
+ private finalized = false;
622
+
623
+ constructor(
624
+ project: DBXToolsJavaScriptProject,
625
+ private readonly workflow: GithubWorkflow,
626
+ private readonly docs: ReleaseDocsOptions | undefined,
627
+ ) {
628
+ super(project);
629
+ }
630
+
631
+ public override preSynthesize(): void {
632
+ if (this.finalized) return;
633
+ this.finalized = true;
634
+ const jobs = [...(independentPublicationJobs.get(this.workflow) ?? [])].sort();
635
+ const successful = jobs
636
+ .map((job) => `needs.${job}.result != 'failure' && needs.${job}.result != 'cancelled'`)
637
+ .join(" && ");
638
+ this.workflow.addJob("publication-complete", {
639
+ if: `\${{ always()${successful ? ` && ${successful}` : ""} }}`,
640
+ needs: jobs,
641
+ runsOn: ["ubuntu-latest"],
642
+ permissions: { contents: JobPermission.READ },
643
+ steps: [{ name: "Confirm publication stages", run: "true" }],
644
+ });
645
+ if (this.docs) {
646
+ addIndependentDocsJobs(this.workflow, this.project as DBXToolsJavaScriptProject, this.docs);
647
+ }
648
+ }
649
+ }
650
+
651
+ function configureIndependentRelease(
652
+ project: DBXToolsJavaScriptProject,
653
+ options: DBXToolsReleaseOptions,
654
+ ): void {
655
+ const branch = projectReleaseBranch(project);
656
+ if (options.nodeRelease !== false) nodeReleaseProjects.add(project);
657
+ applyTasks(project, {
658
+ "version:check": {
659
+ exec: taskScript(project, "version-check.ts"),
660
+ description: "Verify every package against its release unit",
661
+ },
662
+ "release:plan": {
663
+ exec: taskScript(project, "release-plan.ts"),
664
+ receiveArgs: true,
665
+ description: "Build the affected release plan",
666
+ },
667
+ release: {
668
+ exec: taskScript(project, "release-please.ts", `--target-branch ${JSON.stringify(branch)}`),
669
+ receiveArgs: true,
670
+ description: "Refresh independent release PRs, tags, and GitHub Releases",
671
+ },
672
+ });
673
+ if (!project.github) return;
674
+ const workflow = new GithubWorkflow(project.github, "release", {
675
+ fileName: "release.yml",
676
+ limitConcurrency: true,
677
+ concurrencyOptions: { group: "release", cancelInProgress: false },
678
+ });
679
+ workflow.runName =
680
+ "release units " + "${{ github.event_name == 'push' && github.sha || inputs.component }}";
681
+ workflow.on({
682
+ push: { branches: [branch] },
683
+ workflowDispatch: {
684
+ inputs: {
685
+ component: {
686
+ description: "Component to recover",
687
+ type: "string",
688
+ default: "",
689
+ required: false,
690
+ },
691
+ version: {
692
+ description: "Component version to recover",
693
+ type: "string",
694
+ default: "",
695
+ required: false,
696
+ },
697
+ stage: {
698
+ description: "Publication target to recover",
699
+ type: "choice",
700
+ options: ["all", "node", "python", "rust", "github", "docs"],
701
+ default: "all",
702
+ required: true,
703
+ },
704
+ },
705
+ },
706
+ });
707
+ workflow.file?.addOverride("permissions.contents", "read");
708
+ workflow.addJob("release-please", independentReleasePleaseJob(project, branch));
709
+ workflow.addJob("release-plan", independentReleasePlanJob(project));
710
+ workflow.addJob("publish-release-notes", independentReleaseNotesJob(project));
711
+ registerIndependentPublicationJob(workflow, "publish-release-notes");
712
+ if (options.nodeRelease !== false) {
713
+ workflow.addJob("publish-node", independentNodePublishJob(project));
714
+ registerIndependentPublicationJob(workflow, "publish-node");
715
+ }
716
+ new IndependentReleaseFinalizer(project, workflow, options.docs);
717
+ releaseWorkflows.set(project, workflow);
718
+ }
719
+
339
720
  /** Owns the single release workflow and local release preparation tasks. */
340
721
  export class DBXToolsRelease extends Component {
341
722
  constructor(project: DBXToolsJavaScriptProject, options: DBXToolsReleaseOptions = {}) {
342
723
  super(project);
724
+ if (project.releaseCatalog.mode === "independent") {
725
+ configureIndependentRelease(project, options);
726
+ return;
727
+ }
343
728
  const tagPrefix = options.tagPrefix ?? "v";
344
729
  const releaseBranch = projectReleaseBranch(project);
345
730
  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
  }