@dbx-tools/projen 0.9.37 → 0.9.40

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/README.md CHANGED
@@ -64,6 +64,8 @@ Repository policy stays in the consuming `.projenrc.ts`:
64
64
  preparation. Omit it when the workspace has no standard Python packages.
65
65
  - `releaseValidationTasks` names repository tasks that must pass in tag CI
66
66
  before release artifacts are built and published.
67
+ - `releaseSetupSteps` installs any extra validation/build prerequisites in the
68
+ shared release job before the validation tasks run.
67
69
  - `pullRequestTitlePolicy` configures semantic title types and scope policy.
68
70
  Omit it or pass `false` to disable the title job.
69
71
  - `workflowCacheIgnorePaths` excludes generated output trees that may contain
@@ -380,17 +382,45 @@ list, so a new package is covered without a re-synth. Work from the root:
380
382
  | `bun run version:check` | verify every generated version against `VERSION` |
381
383
  | `bun run release` | run the configured release transaction |
382
384
 
383
- Run exactly `bun run release` from any branch. Pending changes are committed and
385
+ Run `bun run release` from any branch. Pending changes are committed and
384
386
  the current branch is pushed first. A non-`main` branch then fast-forwards
385
387
  `main`; the command fails without creating a merge commit if either branch has
386
388
  diverged. From `main`, it calls the existing `bump` task, commits the generated
387
389
  version changes, pushes `main`, and pushes the matching annotated `vX.Y.Z` tag.
388
390
  Pass `--no-bump` only to release an existing synchronized local bump. The tag
389
- workflow verifies that the tag points at the exact `origin/main` commit,
390
- publishes npm and PyPI directly from that checkout, then builds and deploys
391
- documentation. Local npm and Python registry publication remains available
391
+ workflow verifies that the tag points at the exact `origin/main` commit. One
392
+ `build-release` job restores the Bun cache and installs workspace dependencies,
393
+ runs validation, packs the npm archives, builds all Python distributions, and
394
+ builds the documentation site. Install validation prerequisites through
395
+ `releaseSetupSteps`; `releaseDocs.prepareSteps` runs after validation.
396
+
397
+ Publication jobs download the resulting artifacts rather than rebuilding the
398
+ workspace. Each Python package retains its own publishing environment and waits
399
+ for its package dependencies to publish. The npm job uses the bundled archive
400
+ publisher without a checkout or workspace install. Pages deploys the already
401
+ built site after registry publication succeeds. Local npm and Python registry publication remains available
392
402
  through the direct local publication tooling.
393
403
 
404
+ The default release still publishes npm, Python, and docs, then publishes to
405
+ configured local registries automatically. Select a scope or disable individual
406
+ steps for one run without changing the Projen definition:
407
+
408
+ ```sh
409
+ bun run release --publish pypi --no-docs
410
+ bun run release --no-npm --no-local-publish
411
+ bun run release --publish local --install never
412
+ bun run release --local-registry false --local-pypi auto
413
+ ```
414
+
415
+ `--install auto` keeps Projen's normal local dependency-install behavior;
416
+ `always` performs a frozen install first, and `never` uses dependencies already
417
+ installed while still regenerating versioned sources. `--no-validation` skips
418
+ optional task checks locally and in CI, not version or immutable-source checks.
419
+ `--no-docs` skips building and deploying the site, not package README validation.
420
+ Scopes select artifacts, not the normal commit and tag-push transaction.
421
+ The annotated tag records CI step selections, so publishing a selected scope does
422
+ not change the next release's defaults. Local registry URLs stay local.
423
+
394
424
  Members intentionally keep only the tasks that something OTHER than a human
395
425
  invokes, so there is no second place to run the same thing:
396
426
 
@@ -402,7 +432,7 @@ invokes, so there is no second place to run the same thing:
402
432
  edit/compile loop to one package.
403
433
  - `build` / `package` - a complete compile/test/pack lifecycle when invoked in
404
434
  one package. Release preparation validates through the root's filtered tasks,
405
- and publication concurrently uploads archives that were each packed and
435
+ and publication uploads archives that were each packed and
406
436
  validated once with lifecycle scripts disabled. The package phase also packs
407
437
  with `--ignore-scripts` because its build already compiled; `prepack` remains
408
438
  available for a standalone publish that did not run `build` first.
@@ -421,3 +451,40 @@ from the root instead.
421
451
  Projen-owned and reproduce it exactly across JavaScript and Python packages.
422
452
  Release jobs publish the generated workspace inventory in dependency-safe
423
453
  workflow order without introducing another version owner.
454
+
455
+ <!-- cli-reference:start -->
456
+
457
+ ## Command Reference
458
+
459
+ ### `release`
460
+
461
+ ```text
462
+ Usage: release [options]
463
+
464
+ Prepare an annotated release and select its build and publication steps
465
+
466
+ Options:
467
+ --root <path> repository root
468
+ --branch <name> release branch (default: "main")
469
+ --prefix <prefix> release tag prefix (default: "v")
470
+ --remote <name> git remote (default: "origin")
471
+ --python-root <path> Python package root for local publish (default: "packages/py")
472
+ --validate <task> task to run before pushing (default: [])
473
+ --no-bump use an existing synchronized local version bump
474
+ --publish <target> publication scope (choices: "auto", "npm", "pypi", "local",
475
+ "none", default: "auto")
476
+ --install <mode> local workspace dependency installation (choices: "auto",
477
+ "always", "never", default: "auto")
478
+ --no-npm skip npm build and publication, including local npm publication
479
+ --no-pypi skip Python build and publication, including local Python
480
+ publication
481
+ --docs build and deploy docs for a selected scope
482
+ --no-docs skip documentation build and deployment
483
+ --no-validation skip optional release validation tasks; version/source checks
484
+ remain mandatory
485
+ --no-local-publish skip publishing to configured local registries
486
+ --local-registry <auto|false|url> local npm registry selection (default: "auto")
487
+ --local-pypi <auto|false|url> local devpi registry selection (default: "auto")
488
+ ```
489
+
490
+ <!-- cli-reference:end -->
package/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // Hand edits are overwritten on the next watch; this file is read-only.
4
4
 
5
5
  export const PACKAGE_IDENTIFIER = "@dbx-tools/projen";
6
- export const PACKAGE_VERSION = "0.9.37";
6
+ export const PACKAGE_VERSION = "0.9.40";
7
7
  export * as barrels from "./src/barrels.ts";
8
8
  export * as bunApp from "./src/bun-app.ts";
9
9
  export * as bunWorkflow from "./src/bun-workflow.ts";
@@ -27,6 +27,7 @@ export * as pythonNodeBindings from "./src/python-node-bindings.ts";
27
27
  export * as pythonNodeBundle from "./src/python-node-bundle.ts";
28
28
  export * as release from "./src/release.ts";
29
29
  export * as releaseContext from "./src/release-context.ts";
30
+ export * as releaseOptions from "./src/release-options.ts";
30
31
  export * as scaffold from "./src/scaffold.ts";
31
32
  export * as tags from "./src/tags.ts";
32
33
  export * as tsconfig from "./src/tsconfig.ts";
@@ -60,7 +61,9 @@ export { PythonNodeBundle } from "./src/python-node-bundle.ts";
60
61
  export type { PythonNodeFunctionOverride, PythonNodeBindingsOptions, PythonNodeBundleOptions } from "./src/python-node-bundle.ts";
61
62
  export { DBXToolsRelease } from "./src/release.ts";
62
63
  export type { ReleaseDocsOptions, DBXToolsReleaseOptions } from "./src/release.ts";
63
- export { RELEASE_TAG, RELEASE_SHA, RELEASE_VERSION } from "./src/release-context.ts";
64
+ export { RELEASE_VERSION } from "./src/release-context.ts";
65
+ export { RELEASE_PUBLISH_TARGETS, RELEASE_INSTALL_MODES } from "./src/release-options.ts";
66
+ export type { ReleasePublishTarget, ReleaseInstallMode, ReleaseStepSelection, ReleaseSelectionOptions } from "./src/release-options.ts";
64
67
  export { AGNOSTIC_COMPILER_OPTIONS, PACKAGE_TAG_MIXINS } from "./src/tags.ts";
65
68
  export type { PackageTag } from "./src/tags.ts";
66
69
  export { DBXToolsRootTsconfig } from "./src/tsconfig.ts";
package/package.json CHANGED
@@ -32,9 +32,9 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@clack/prompts": "^1.7.0",
35
- "@dbx-tools/core": "^0.9.37",
36
- "@dbx-tools/path": "^0.9.37",
37
- "@dbx-tools/shared-core": "^0.9.37",
35
+ "@dbx-tools/core": "^0.9.40",
36
+ "@dbx-tools/path": "^0.9.40",
37
+ "@dbx-tools/shared-core": "^0.9.40",
38
38
  "commander": "^15.0.0",
39
39
  "concurrently": "^10.0.3",
40
40
  "constructs": "^10.6.0",
@@ -56,7 +56,7 @@
56
56
  },
57
57
  "main": "index.ts",
58
58
  "license": "Apache-2.0",
59
- "version": "0.9.37",
59
+ "version": "0.9.40",
60
60
  "types": "index.ts",
61
61
  "type": "module",
62
62
  "exports": {
package/src/project-js.ts CHANGED
@@ -573,6 +573,8 @@ export type DBXToolsJavaScriptProjectOptions = CommonProjectOptions &
573
573
  readonly releasePythonRoot?: string;
574
574
  /** Repository task names run during local release preparation before publication. */
575
575
  readonly releaseValidationTasks?: readonly string[];
576
+ /** Release build prerequisites installed before repository validation tasks. */
577
+ readonly releaseSetupSteps?: readonly JobStep[];
576
578
  /** Set to `false` to omit normal npm workspace publication. */
577
579
  readonly nodeRelease?: boolean;
578
580
  /** Unified dbx-tools release workflow, or no release surface. Defaults to `dbx-tools`. */
@@ -1365,6 +1367,7 @@ function initProject(
1365
1367
  docs: options.releaseDocs,
1366
1368
  pythonRoot: options.releasePythonRoot,
1367
1369
  validationTasks: options.releaseValidationTasks,
1370
+ setupSteps: options.releaseSetupSteps,
1368
1371
  });
1369
1372
  }
1370
1373
  }
package/src/project-py.ts CHANGED
@@ -2,16 +2,15 @@
2
2
  import * as projectUtils from "@dbx-tools/core/project-utils";
3
3
  import { stringUtils, type OneOrMany } from "@dbx-tools/shared-core";
4
4
  import { Component, License, TextFile, type Project, javascript, python } from "projen";
5
- import { JobPermission } from "projen/lib/github/workflows-model";
6
- import { BUN_VERSION } from "./bun-workflow.ts";
5
+ import { JobPermission, type Job, type JobStep } from "projen/lib/github/workflows-model";
7
6
  import { DBX_TOOLS_LICENSE, projectRepositoryUrl } from "./project-js.ts";
8
7
  import { isDBXToolsJavaScriptProject } from "./project-predicate.ts";
9
8
  import type { DBXToolsProject, DBXToolsProjectOptions } from "./project.ts";
10
9
  import { PythonNodeBundle, type PythonNodeBindingsOptions } from "./python-node-bundle.ts";
11
- import { RELEASE_VERSION, releaseSourceSteps } from "./release-context.ts";
12
10
  import {
13
11
  refreshReleaseDocsDependencies,
14
12
  releaseCondition,
13
+ releaseBuildSteps,
15
14
  releaseTagPattern,
16
15
  tryReleaseWorkflow,
17
16
  uvSetupStep,
@@ -401,16 +400,45 @@ export class DBXToolsPythonWorkspace extends Component {
401
400
  if (!workflow) {
402
401
  throw new Error("Python release requires the root dbx-tools release mode");
403
402
  }
403
+ const build = workflow.getJob("build-release") as Job | undefined;
404
+ if (!build) throw new Error("Python release requires the shared release build job");
405
+ const pythonSteps: JobStep[] = [
406
+ uvSetupStep(),
407
+ {
408
+ name: "Build Python distributions",
409
+ env: { RELEASE_VERSION: "${{ steps.release.outputs.release_version }}" },
410
+ run: [
411
+ 'bun node_modules/@dbx-tools/projen/tasks/publish-python.ts "$RELEASE_VERSION"',
412
+ `--root ${JSON.stringify(this.repository.root)}`,
413
+ `--package ${allPublications.map((publication) => JSON.stringify(publication.directory)).join(" ")}`,
414
+ "--output .release/python --package-directories",
415
+ ].join(" \\\n "),
416
+ },
417
+ ...allPublications.map((publication) => ({
418
+ name: `Upload ${publication.distribution} distributions`,
419
+ uses: "actions/upload-artifact@v4",
420
+ with: {
421
+ name: `release-python-${publication.directory}`,
422
+ path: `.release/python/${publication.directory}`,
423
+ "if-no-files-found": "error",
424
+ },
425
+ })),
426
+ ];
427
+ workflow.updateJob("build-release", {
428
+ ...build,
429
+ steps: [...build.steps, ...releaseBuildSteps("pypi", pythonSteps)],
430
+ });
404
431
  for (const publication of allPublications) {
405
432
  const dependencyJobs = (publication.dependencies ?? []).map(
406
433
  (dependency) => `publish-pypi-${dependency}`,
407
434
  );
408
435
  workflow.addJob(`publish-pypi-${publication.directory}`, {
409
436
  if: releaseCondition([
410
- "needs.verify-context.result == 'success'",
437
+ "needs.build-release.result == 'success'",
438
+ "needs.build-release.outputs.pypi == 'true'",
411
439
  ...dependencyJobs.map((job) => `needs['${job}'].result == 'success'`),
412
440
  ]),
413
- needs: ["verify-context", ...dependencyJobs],
441
+ needs: ["build-release", ...dependencyJobs],
414
442
  environment: {
415
443
  name: publication.environment,
416
444
  url:
@@ -420,20 +448,14 @@ export class DBXToolsPythonWorkspace extends Component {
420
448
  runsOn: ["ubuntu-latest"],
421
449
  permissions: { idToken: JobPermission.WRITE },
422
450
  timeoutMinutes: 10,
423
- env: { BUN_VERSION },
424
451
  steps: [
425
- ...releaseSourceSteps(),
426
- ...project.renderWorkflowSetup({ mutable: false }),
427
- uvSetupStep(),
428
452
  {
429
- name: `Build ${publication.distribution} distributions`,
430
- env: { RELEASE_VERSION },
431
- run: [
432
- 'bun node_modules/@dbx-tools/projen/tasks/publish-python.ts "$RELEASE_VERSION"',
433
- `--root ${JSON.stringify(this.repository.root)}`,
434
- `--package ${JSON.stringify(publication.directory)}`,
435
- `--output dist/${publication.directory}`,
436
- ].join(" \\\n "),
453
+ name: `Download ${publication.distribution} distributions`,
454
+ uses: "actions/download-artifact@v4",
455
+ with: {
456
+ name: `release-python-${publication.directory}`,
457
+ path: `dist/${publication.directory}`,
458
+ },
437
459
  },
438
460
  {
439
461
  name: `Publish ${publication.distribution} to PyPI`,
@@ -1,34 +1,3 @@
1
1
  /** Verified release context shared by every job in the unified workflow. */
2
- import { github } from "projen";
3
- import type { JobStep } from "projen/lib/github/workflows-model";
4
-
5
- export const RELEASE_TAG = "${{ needs.verify-context.outputs.release_tag }}";
6
- export const RELEASE_SHA = "${{ needs.verify-context.outputs.expected_sha }}";
7
- export const RELEASE_VERSION = "${{ needs.verify-context.outputs.release_version }}";
8
-
9
- /** Check out and verify the immutable commit selected by the release tag. */
10
- export function releaseSourceSteps(): readonly JobStep[] {
11
- return [
12
- github.WorkflowSteps.checkout({
13
- name: "Checkout release commit",
14
- with: {
15
- ref: RELEASE_SHA,
16
- fetchDepth: 1,
17
- },
18
- }),
19
- {
20
- name: "Verify release source",
21
- shell: "bash",
22
- env: {
23
- RELEASE_TAG,
24
- EXPECTED_SHA: RELEASE_SHA,
25
- },
26
- run: [
27
- 'git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"',
28
- 'test "$(git cat-file -t "$RELEASE_TAG")" = "tag"',
29
- 'test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$EXPECTED_SHA"',
30
- 'test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"',
31
- ].join("\n"),
32
- },
33
- ];
34
- }
2
+ /** Version verified by the shared build that produced every publication artifact. */
3
+ export const RELEASE_VERSION = "${{ needs.build-release.outputs.release_version }}";
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Per-run release policy shared by the Projen task and tag-triggered workflow.
3
+ *
4
+ * This module owns defaults and the annotated-tag contract. Reuse these values
5
+ * when adding release steps so local flags and CI cannot drift. Registry URLs
6
+ * and local installation choices never belong in the public tag annotation.
7
+ *
8
+ * @module
9
+ */
10
+ import { json } from "@dbx-tools/shared-core";
11
+
12
+ /** Publication scopes accepted by the release task; auto preserves normal publication. */
13
+ export const RELEASE_PUBLISH_TARGETS = ["auto", "npm", "pypi", "local", "none"] as const;
14
+
15
+ /** One publication scope accepted by the release task. */
16
+ export type ReleasePublishTarget = (typeof RELEASE_PUBLISH_TARGETS)[number];
17
+
18
+ /** Native Projen dependency-install modes available during local release preparation. */
19
+ export const RELEASE_INSTALL_MODES = ["auto", "always", "never"] as const;
20
+
21
+ /** Local dependency-install behavior; auto retains Projen's normal trigger policy. */
22
+ export type ReleaseInstallMode = (typeof RELEASE_INSTALL_MODES)[number];
23
+
24
+ /** CI steps selected by an immutable release annotation. */
25
+ export interface ReleaseStepSelection {
26
+ readonly npm: boolean;
27
+ readonly pypi: boolean;
28
+ readonly docs: boolean;
29
+ readonly validation: boolean;
30
+ }
31
+
32
+ /** Flags consumed by the shared release-selection policy. */
33
+ export interface ReleaseSelectionOptions {
34
+ readonly publish?: ReleasePublishTarget;
35
+ readonly npm?: boolean;
36
+ readonly pypi?: boolean;
37
+ readonly docs?: boolean;
38
+ readonly validation?: boolean;
39
+ }
40
+
41
+ const ANNOTATION_MARKER = "dbx-tools-release:";
42
+
43
+ /** Derive public build/publication steps without mutating repository configuration. */
44
+ export function releaseStepSelection(options: ReleaseSelectionOptions = {}): ReleaseStepSelection {
45
+ const target = options.publish ?? "auto";
46
+ return {
47
+ npm: (target === "auto" || target === "npm") && options.npm !== false,
48
+ pypi: (target === "auto" || target === "pypi") && options.pypi !== false,
49
+ docs: options.docs ?? (target !== "local" && target !== "none"),
50
+ validation: options.validation ?? true,
51
+ };
52
+ }
53
+
54
+ /** Record non-default CI selections alongside the existing annotated release tag. */
55
+ export function releaseTagAnnotation(tag: string, selection: ReleaseStepSelection): string {
56
+ if (selection.npm && selection.pypi && selection.docs && selection.validation) return tag;
57
+ return `${tag}\n\n${ANNOTATION_MARKER} ${JSON.stringify(selection)}`;
58
+ }
59
+
60
+ /** Read the release selection, rejecting malformed or ambiguous annotated policy. */
61
+ export function parseReleaseTagAnnotation(annotation: string): ReleaseStepSelection {
62
+ const selections = annotation.split("\n").filter((line) => line.startsWith(ANNOTATION_MARKER));
63
+ if (selections.length === 0) return releaseStepSelection();
64
+ if (selections.length !== 1) throw new Error("Release tag contains multiple step selections");
65
+ const value = json.parseRecord(selections[0]!.slice(ANNOTATION_MARKER.length));
66
+ if (
67
+ !value ||
68
+ Object.keys(value).length !== 4 ||
69
+ typeof value.npm !== "boolean" ||
70
+ typeof value.pypi !== "boolean" ||
71
+ typeof value.docs !== "boolean" ||
72
+ typeof value.validation !== "boolean"
73
+ ) {
74
+ throw new Error("Invalid release tag step selection");
75
+ }
76
+ return { npm: value.npm, pypi: value.pypi, docs: value.docs, validation: value.validation };
77
+ }
78
+
79
+ /** Whether the selected release should inspect and publish configured local registries. */
80
+ export function releasePublishesLocally(target: ReleasePublishTarget = "auto"): boolean {
81
+ return target === "auto" || target === "local";
82
+ }
package/src/release.ts CHANGED
@@ -5,7 +5,8 @@ import { GithubWorkflow } from "projen/lib/github";
5
5
  import { JobPermission, type Job, type JobStep } from "projen/lib/github/workflows-model";
6
6
  import { BUN_VERSION } from "./bun-workflow.ts";
7
7
  import { projectReleaseBranch, taskCommand, type DBXToolsJavaScriptProject } from "./project-js.ts";
8
- import { RELEASE_VERSION, releaseSourceSteps } from "./release-context.ts";
8
+ import { RELEASE_VERSION } from "./release-context.ts";
9
+ import type { ReleaseStepSelection } from "./release-options.ts";
9
10
 
10
11
  const NODE_VERSION = "24";
11
12
  const NPM_VERSION = "11.4.2";
@@ -17,7 +18,7 @@ const releaseWorkflows = new WeakMap<DBXToolsJavaScriptProject, GithubWorkflow>(
17
18
  export interface ReleaseDocsOptions {
18
19
  readonly siteUrl: string;
19
20
  readonly base?: string;
20
- /** Repository-defined setup and generation steps run before saving the Bun cache. */
21
+ /** Documentation generation steps run in the shared release build after validation. */
21
22
  readonly prepareSteps: readonly JobStep[];
22
23
  /** Repository-defined documentation build and validation steps. */
23
24
  readonly buildSteps: readonly JobStep[];
@@ -37,6 +38,8 @@ export interface DBXToolsReleaseOptions {
37
38
  readonly pythonRoot?: string;
38
39
  /** Repository task names run after VERSION verification and before publication. */
39
40
  readonly validationTasks?: readonly string[];
41
+ /** Repository prerequisites installed before release validation and artifact builds. */
42
+ readonly setupSteps?: readonly JobStep[];
40
43
  }
41
44
 
42
45
  /** Locate the unified workflow when release generation is enabled. */
@@ -44,7 +47,7 @@ export function tryReleaseWorkflow(project: DBXToolsJavaScriptProject): GithubWo
44
47
  return releaseWorkflows.get(project);
45
48
  }
46
49
 
47
- /** Tag pattern created by release jobs and accepted by manual recovery. */
50
+ /** Tag pattern accepted by the unified release workflow. */
48
51
  export function releaseTagPattern(project: DBXToolsJavaScriptProject): string {
49
52
  const prefix = releaseTagPrefixes.get(project);
50
53
  if (!prefix) throw new Error("Release workflow is not configured");
@@ -57,11 +60,25 @@ export function releaseCondition(prerequisites: readonly string[] = []): string
57
60
  return `\${{ always() && ${prerequisites.map((condition) => `(${condition})`).join(" && ")} }}`;
58
61
  }
59
62
 
60
- /** Native project setup plus npm publication authentication. */
61
- export function nodeReleaseSetupSteps(project: DBXToolsJavaScriptProject): readonly JobStep[] {
63
+ /** Gate shared-build steps using the same per-run flag, preserving their existing conditions. */
64
+ export function releaseBuildSteps(
65
+ flag: keyof ReleaseStepSelection,
66
+ steps: readonly JobStep[],
67
+ ): JobStep[] {
68
+ return steps.map((step) => ({
69
+ ...step,
70
+ if: `\${{ success() && steps.release.outputs.${flag} == 'true'${step.if ? ` && (${step.if.replace(/^\s*\$\{\{\s*|\s*\}\}\s*$/g, "")})` : ""} }}`,
71
+ }));
72
+ }
73
+
74
+ /** Artifact-only publication runtime and npm authentication; no workspace installation. */
75
+ export function nodeReleaseSetupSteps(): readonly JobStep[] {
62
76
  return [
63
- ...releaseSourceSteps(),
64
- ...project.renderWorkflowSetup({ mutable: false }),
77
+ {
78
+ name: "Setup Bun",
79
+ uses: "oven-sh/setup-bun@v2",
80
+ with: { "bun-version": BUN_VERSION },
81
+ },
65
82
  {
66
83
  name: "Setup Node.js",
67
84
  uses: "actions/setup-node@v6",
@@ -88,13 +105,23 @@ export function uvSetupStep(): JobStep {
88
105
  }
89
106
 
90
107
  function refreshDocsRegistryDependencies(workflow: GithubWorkflow): void {
91
- const docs = workflow.getJob("build-docs") as Job | undefined;
108
+ const docs = workflow.getJob("deploy-docs") as Job | undefined;
92
109
  if (!docs) return;
93
110
  const registryJobs = Object.keys(workflow.jobs).filter(
94
111
  (name) => name === "publish-node" || name.startsWith("publish-pypi-"),
95
112
  );
96
113
  if (registryJobs.length > 0) {
97
- workflow.updateJob("build-docs", { ...docs, needs: ["verify-context", ...registryJobs] });
114
+ workflow.updateJob("deploy-docs", {
115
+ ...docs,
116
+ needs: ["build-release", ...registryJobs],
117
+ if: releaseCondition([
118
+ "needs['build-release'].result == 'success'",
119
+ "needs['build-release'].outputs.docs == 'true'",
120
+ ...registryJobs.map(
121
+ (name) => `needs['${name}'].result == 'success' || needs['${name}'].result == 'skipped'`,
122
+ ),
123
+ ]),
124
+ });
98
125
  }
99
126
  }
100
127
 
@@ -106,7 +133,7 @@ export function refreshReleaseDocsDependencies(project: DBXToolsJavaScriptProjec
106
133
  }
107
134
  }
108
135
 
109
- function verifyContextJob(
136
+ function releaseBuildJob(
110
137
  project: DBXToolsJavaScriptProject,
111
138
  tagPrefix: string,
112
139
  releaseBranch: string,
@@ -115,12 +142,21 @@ function verifyContextJob(
115
142
  return {
116
143
  runsOn: ["ubuntu-latest"],
117
144
  permissions: { contents: JobPermission.READ },
118
- timeoutMinutes: 30,
119
- env: { BUN_VERSION, CI: "true" },
145
+ timeoutMinutes: 60,
146
+ env: {
147
+ BUN_VERSION,
148
+ CI: "true",
149
+ ...(options.docs
150
+ ? { DOCS_SITE_URL: options.docs.siteUrl, DOCS_BASE: options.docs.base ?? "/" }
151
+ : {}),
152
+ },
120
153
  outputs: {
121
154
  release_tag: { stepId: "release", outputName: "release_tag" },
122
155
  expected_sha: { stepId: "release", outputName: "expected_sha" },
123
156
  release_version: { stepId: "release", outputName: "release_version" },
157
+ npm: { stepId: "release", outputName: "npm" },
158
+ pypi: { stepId: "release", outputName: "pypi" },
159
+ docs: { stepId: "release", outputName: "docs" },
124
160
  },
125
161
  steps: [
126
162
  github.WorkflowSteps.checkout({
@@ -155,28 +191,70 @@ function verifyContextJob(
155
191
  echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
156
192
  echo "expected_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT"
157
193
  echo "release_version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT"
194
+ bun node_modules/@dbx-tools/projen/tasks/release-options.ts --tag "$RELEASE_TAG" --output "$GITHUB_OUTPUT"
158
195
  `
159
196
  // ============================================================================
160
197
  ),
161
198
  },
162
- ...(options.validationTasks ?? []).map((task) => ({
163
- name: `Validate ${task}`,
164
- run: `bun run ${task}`,
165
- })),
199
+ ...(options.setupSteps ?? []),
200
+ ...releaseBuildSteps(
201
+ "validation",
202
+ (options.validationTasks ?? []).map((task) => ({
203
+ name: `Validate ${task}`,
204
+ run: `bun run ${task}`,
205
+ })),
206
+ ),
207
+ ...(options.nodeRelease === false
208
+ ? []
209
+ : releaseBuildSteps("npm", [
210
+ {
211
+ name: "Build npm archives",
212
+ env: { RELEASE_VERSION: "${{ steps.release.outputs.release_version }}" },
213
+ run: [
214
+ "bun run compile",
215
+ 'bun node_modules/@dbx-tools/projen/tasks/publish.ts "$RELEASE_VERSION" --skip-compile --output .release/npm',
216
+ "bun build node_modules/@dbx-tools/projen/tasks/publish-npm.ts --target=bun --outfile=.release/npm/publish-npm.mjs",
217
+ ].join("\n"),
218
+ },
219
+ {
220
+ name: "Upload npm archives",
221
+ uses: "actions/upload-artifact@v4",
222
+ with: { name: "release-npm", path: ".release/npm", "if-no-files-found": "error" },
223
+ },
224
+ ])),
225
+ ...(options.docs
226
+ ? releaseBuildSteps("docs", [
227
+ ...options.docs.prepareSteps,
228
+ ...options.docs.buildSteps,
229
+ {
230
+ name: "Upload Pages artifact",
231
+ uses: "actions/upload-pages-artifact@v4",
232
+ with: { path: options.docs.artifactPath },
233
+ },
234
+ ])
235
+ : []),
166
236
  ],
167
237
  };
168
238
  }
169
239
 
170
- function nodePublishJob(project: DBXToolsJavaScriptProject): Job {
240
+ function nodePublishJob(): Job {
171
241
  return {
172
- if: releaseCondition(),
173
- needs: ["verify-context"],
242
+ if: releaseCondition([
243
+ "needs['build-release'].result == 'success'",
244
+ "needs['build-release'].outputs.npm == 'true'",
245
+ ]),
246
+ needs: ["build-release"],
174
247
  runsOn: ["ubuntu-latest"],
175
248
  permissions: { contents: JobPermission.READ, idToken: JobPermission.WRITE },
176
249
  timeoutMinutes: 30,
177
250
  env: { BUN_VERSION, CI: "true" },
178
251
  steps: [
179
- ...nodeReleaseSetupSteps(project),
252
+ {
253
+ name: "Download npm archives",
254
+ uses: "actions/download-artifact@v4",
255
+ with: { name: "release-npm", path: ".release/npm" },
256
+ },
257
+ ...nodeReleaseSetupSteps(),
180
258
  {
181
259
  name: "Publish npm workspace",
182
260
  env: {
@@ -184,47 +262,19 @@ function nodePublishJob(project: DBXToolsJavaScriptProject): Job {
184
262
  NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}",
185
263
  NPM_CONFIG_PROVENANCE: "true",
186
264
  },
187
- run: 'bun node_modules/@dbx-tools/projen/tasks/publish.ts "$RELEASE_VERSION" $DRY_RUN',
265
+ run: 'bun .release/npm/publish-npm.mjs --directory .release/npm --version "$RELEASE_VERSION"',
188
266
  },
189
267
  ],
190
268
  };
191
269
  }
192
270
 
193
- function addDocsJobs(
194
- workflow: GithubWorkflow,
195
- project: DBXToolsJavaScriptProject,
196
- options: ReleaseDocsOptions,
197
- ): void {
198
- workflow.addJob("build-docs", {
199
- if: releaseCondition(),
200
- needs: ["verify-context"],
201
- runsOn: ["ubuntu-latest"],
202
- permissions: {
203
- contents: JobPermission.READ,
204
- pages: JobPermission.WRITE,
205
- idToken: JobPermission.WRITE,
206
- },
207
- timeoutMinutes: 30,
208
- env: {
209
- BUN_VERSION,
210
- DOCS_SITE_URL: options.siteUrl,
211
- DOCS_BASE: options.base ?? "/",
212
- },
213
- steps: [
214
- ...releaseSourceSteps(),
215
- ...project.renderWorkflowSetup({ mutable: false }),
216
- ...options.prepareSteps,
217
- ...options.buildSteps,
218
- {
219
- name: "Upload Pages artifact",
220
- uses: "actions/upload-pages-artifact@v4",
221
- with: { path: options.artifactPath },
222
- },
223
- ],
224
- });
271
+ function addDocsJobs(workflow: GithubWorkflow): void {
225
272
  workflow.addJob("deploy-docs", {
226
- if: releaseCondition(),
227
- needs: ["build-docs"],
273
+ if: releaseCondition([
274
+ "needs['build-release'].result == 'success'",
275
+ "needs['build-release'].outputs.docs == 'true'",
276
+ ]),
277
+ needs: ["build-release"],
228
278
  environment: {
229
279
  name: "github-pages",
230
280
  url: "${{ steps.deployment.outputs.page_url }}",
@@ -247,6 +297,11 @@ function addDocsJobs(
247
297
  export class DBXToolsRelease extends Component {
248
298
  constructor(project: DBXToolsJavaScriptProject, options: DBXToolsReleaseOptions = {}) {
249
299
  super(project);
300
+ project.addGitIgnore(".release/");
301
+ const installCondition =
302
+ 'node -e "process.exit(process.env.DBX_TOOLS_RELEASE_INSTALL === \'never\' ? 1 : 0)"';
303
+ project.package.installTask.addCondition(installCondition);
304
+ project.package.installCiTask.addCondition(installCondition);
250
305
  const tagPrefix = options.tagPrefix ?? "v";
251
306
  const releaseBranch = projectReleaseBranch(project);
252
307
  releaseTagPrefixes.set(project, tagPrefix);
@@ -290,12 +345,12 @@ export class DBXToolsRelease extends Component {
290
345
  push: { tags: [`${tagPrefix}*`] },
291
346
  });
292
347
  workflow.file?.addOverride("permissions.contents", "read");
293
- workflow.addJob("verify-context", verifyContextJob(project, tagPrefix, releaseBranch, options));
348
+ workflow.addJob("build-release", releaseBuildJob(project, tagPrefix, releaseBranch, options));
294
349
  if (options.nodeRelease !== false) {
295
- workflow.addJob("publish-node", nodePublishJob(project));
350
+ workflow.addJob("publish-node", nodePublishJob());
296
351
  }
297
352
  if (options.docs) {
298
- addDocsJobs(workflow, project, options.docs);
353
+ addDocsJobs(workflow);
299
354
  }
300
355
  refreshReleaseDocsDependencies(project);
301
356
  }
@@ -94,6 +94,8 @@ export function buildPythonProjects(options: {
94
94
  readonly allowEmpty?: boolean;
95
95
  readonly output: string;
96
96
  readonly packages?: readonly string[];
97
+ /** Place each package's artifacts in its directory beneath output. */
98
+ readonly packageDirectories?: boolean;
97
99
  readonly root: string;
98
100
  readonly version: string;
99
101
  }): void {
@@ -136,9 +138,15 @@ export function buildPythonProjects(options: {
136
138
  version: options.version,
137
139
  }),
138
140
  );
139
- runTaskCommand(root, "uv", ["build", "--out-dir", output, packageRoot]);
141
+ const packageOutput = options.packageDirectories ? join(output, project.directory) : output;
142
+ runTaskCommand(root, "uv", ["build", "--out-dir", packageOutput, packageRoot]);
143
+ if (pythonDistributionPaths(packageOutput).length === 0) {
144
+ throw new Error(`No Python distributions found for ${project.name}`);
145
+ }
140
146
  }
141
- const distributions = pythonDistributionPaths(output);
147
+ const distributions = options.packageDirectories
148
+ ? projects.flatMap((project) => pythonDistributionPaths(join(output, project.directory)))
149
+ : pythonDistributionPaths(output);
142
150
  if (distributions.length === 0) {
143
151
  throw new Error(`No Python distributions found in ${output}`);
144
152
  }
@@ -157,6 +165,10 @@ export async function main(): Promise<void> {
157
165
  .option("--root <path>", "Python workspace package root", "packages/py")
158
166
  .option("--package <directory...>", "Build only selected package directories")
159
167
  .option("--output <path>", "Build distributions into a directory without publishing")
168
+ .option(
169
+ "--package-directories",
170
+ "Keep each package's distributions in its own output directory",
171
+ )
160
172
  .option("--dry-run", "build and inspect distributions without uploading")
161
173
  .action(
162
174
  (
@@ -165,6 +177,7 @@ export async function main(): Promise<void> {
165
177
  dryRun?: boolean;
166
178
  indexUrl?: string;
167
179
  package?: string[];
180
+ packageDirectories?: boolean;
168
181
  publishUrl?: string;
169
182
  root: string;
170
183
  output?: string;
@@ -174,6 +187,7 @@ export async function main(): Promise<void> {
174
187
  buildPythonProjects({
175
188
  output: options.output,
176
189
  packages: options.package,
190
+ packageDirectories: options.packageDirectories,
177
191
  root: options.root,
178
192
  version,
179
193
  });
@@ -2,7 +2,7 @@
2
2
  /** Run the configured release transaction. */
3
3
  import * as projectUtils from "@dbx-tools/core/project-utils";
4
4
  import { log } from "@dbx-tools/shared-core";
5
- import { Command } from "commander";
5
+ import { Command, Option } from "commander";
6
6
  import { publishLocalRelease } from "./local-publish.ts";
7
7
  import { assertReleaseVersion } from "./release-version.ts";
8
8
  import {
@@ -11,6 +11,15 @@ import {
11
11
  taskCommandSucceeds,
12
12
  } from "../../src/_task-command.ts";
13
13
  import { readWorkspaceVersion } from "../../src/workspace-version.ts";
14
+ import {
15
+ RELEASE_INSTALL_MODES,
16
+ RELEASE_PUBLISH_TARGETS,
17
+ releasePublishesLocally,
18
+ releaseStepSelection,
19
+ releaseTagAnnotation,
20
+ type ReleaseInstallMode,
21
+ type ReleaseSelectionOptions,
22
+ } from "../../src/release-options.ts";
14
23
 
15
24
  const logger = log.logger("projen:release");
16
25
 
@@ -87,23 +96,34 @@ function prepareReleaseBranch(options: {
87
96
  runTaskCommand(root, "git", ["push", remote, `HEAD:${branch}`]);
88
97
  }
89
98
 
90
- export async function runRelease(options: {
91
- readonly root: string;
92
- readonly branch: string;
93
- readonly bump?: boolean;
94
- readonly prefix: string;
95
- readonly remote: string;
96
- readonly localPublish?: boolean;
97
- readonly pythonRoot?: string;
98
- readonly validationTasks?: readonly string[];
99
- }): Promise<string> {
99
+ export async function runRelease(
100
+ options: ReleaseSelectionOptions & {
101
+ readonly root: string;
102
+ readonly branch: string;
103
+ readonly bump?: boolean;
104
+ readonly prefix: string;
105
+ readonly remote: string;
106
+ readonly localPublish?: boolean;
107
+ readonly localRegistry?: string;
108
+ readonly localPypi?: string;
109
+ readonly install?: ReleaseInstallMode;
110
+ readonly pythonRoot?: string;
111
+ readonly validationTasks?: readonly string[];
112
+ },
113
+ ): Promise<string> {
100
114
  const { branch, prefix, remote, root } = options;
115
+ const selection = releaseStepSelection(options);
116
+ const install = options.install ?? "auto";
117
+
118
+ if (install === "always") runTaskCommand(root, "bun", ["install", "--frozen-lockfile"]);
101
119
 
102
120
  prepareReleaseBranch({ branch, remote, root });
103
121
 
104
122
  runTaskCommand(root, "git", ["fetch", remote, branch, "--tags"]);
105
123
  if (options.bump ?? true) {
106
- runTaskCommand(root, "bun", ["run", "bump"]);
124
+ runTaskCommand(root, "bun", ["run", "bump"], {
125
+ env: { ...process.env, DBX_TOOLS_RELEASE_INSTALL: install === "auto" ? "auto" : "never" },
126
+ });
107
127
  }
108
128
 
109
129
  const version = readWorkspaceVersion(root);
@@ -115,7 +135,7 @@ export async function runRelease(options: {
115
135
  }
116
136
 
117
137
  runTaskCommand(root, "bun", ["run", "version:check"]);
118
- for (const task of options.validationTasks ?? []) {
138
+ for (const task of selection.validation ? (options.validationTasks ?? []) : []) {
119
139
  runTaskCommand(root, "bun", ["run", task]);
120
140
  }
121
141
  const status = captureTaskCommand(
@@ -145,7 +165,13 @@ export async function runRelease(options: {
145
165
  );
146
166
  }
147
167
 
148
- runTaskCommand(root, "git", ["tag", "--annotate", tag, "--message", tag]);
168
+ runTaskCommand(root, "git", [
169
+ "tag",
170
+ "--annotate",
171
+ tag,
172
+ "--message",
173
+ releaseTagAnnotation(tag, selection),
174
+ ]);
149
175
  try {
150
176
  runTaskCommand(root, "git", ["push", "--atomic", remote, `HEAD:${branch}`, `refs/tags/${tag}`]);
151
177
  } catch (error) {
@@ -157,10 +183,10 @@ export async function runRelease(options: {
157
183
  // Restore local deploys: when a non-standard (loopback) npm or uv registry is
158
184
  // configured, publish the freshly tagged version to it directly. Detection and
159
185
  // publishing are owned by local-publish.ts; this no-ops on standard registries.
160
- if (options.localPublish ?? true) {
186
+ if ((options.localPublish ?? true) && releasePublishesLocally(options.publish)) {
161
187
  await publishLocalRelease({
162
- localPypi: "auto",
163
- localRegistry: "auto",
188
+ localPypi: options.pypi === false ? "false" : (options.localPypi ?? "auto"),
189
+ localRegistry: options.npm === false ? "false" : (options.localRegistry ?? "auto"),
164
190
  pythonRoot: options.pythonRoot ?? "packages/py",
165
191
  root,
166
192
  version,
@@ -170,8 +196,11 @@ export async function runRelease(options: {
170
196
  return tag;
171
197
  }
172
198
 
173
- export async function main(): Promise<void> {
174
- await new Command()
199
+ /** Build the release task's native parser without executing git or registry operations. */
200
+ export function createReleaseCommand(): Command {
201
+ return new Command()
202
+ .name("release")
203
+ .description("Prepare an annotated release and select its build and publication steps")
175
204
  .option("--root <path>", "repository root")
176
205
  .option("--branch <name>", "release branch", "main")
177
206
  .option("--prefix <prefix>", "release tag prefix", "v")
@@ -184,18 +213,43 @@ export async function main(): Promise<void> {
184
213
  [],
185
214
  )
186
215
  .option("--no-bump", "use an existing synchronized local version bump")
216
+ .addOption(
217
+ new Option("--publish <target>", "publication scope")
218
+ .choices([...RELEASE_PUBLISH_TARGETS])
219
+ .default("auto"),
220
+ )
221
+ .addOption(
222
+ new Option("--install <mode>", "local workspace dependency installation")
223
+ .choices([...RELEASE_INSTALL_MODES])
224
+ .default("auto"),
225
+ )
226
+ .option("--no-npm", "skip npm build and publication, including local npm publication")
227
+ .option("--no-pypi", "skip Python build and publication, including local Python publication")
228
+ .option("--docs", "build and deploy docs for a selected scope")
229
+ .option("--no-docs", "skip documentation build and deployment")
230
+ .option(
231
+ "--no-validation",
232
+ "skip optional release validation tasks; version/source checks remain mandatory",
233
+ )
187
234
  .option("--no-local-publish", "skip publishing to configured local registries")
235
+ .option("--local-registry <auto|false|url>", "local npm registry selection", "auto")
236
+ .option("--local-pypi <auto|false|url>", "local devpi registry selection", "auto")
188
237
  .action(
189
- async (options: {
190
- root?: string;
191
- branch: string;
192
- bump: boolean;
193
- prefix: string;
194
- remote: string;
195
- pythonRoot: string;
196
- localPublish: boolean;
197
- validate: string[];
198
- }) => {
238
+ async (
239
+ options: ReleaseSelectionOptions & {
240
+ root?: string;
241
+ branch: string;
242
+ bump: boolean;
243
+ prefix: string;
244
+ remote: string;
245
+ pythonRoot: string;
246
+ localPublish: boolean;
247
+ localRegistry: string;
248
+ localPypi: string;
249
+ install: ReleaseInstallMode;
250
+ validate: string[];
251
+ },
252
+ ) => {
199
253
  await runRelease({
200
254
  root: options.root ?? projectUtils.root() ?? process.cwd(),
201
255
  branch: options.branch,
@@ -204,9 +258,21 @@ export async function main(): Promise<void> {
204
258
  remote: options.remote,
205
259
  pythonRoot: options.pythonRoot,
206
260
  localPublish: options.localPublish,
261
+ localRegistry: options.localRegistry,
262
+ localPypi: options.localPypi,
263
+ install: options.install,
264
+ publish: options.publish,
265
+ npm: options.npm,
266
+ pypi: options.pypi,
267
+ docs: options.docs,
268
+ validation: options.validation,
207
269
  validationTasks: options.validate,
208
270
  });
209
271
  },
210
- )
211
- .parseAsync();
272
+ );
273
+ }
274
+
275
+ /** Execute the native release task parser. */
276
+ export async function main(): Promise<void> {
277
+ await createReleaseCommand().parseAsync();
212
278
  }
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env -S bun
2
+ /** Export the current annotated release tag's step selection to GitHub Actions. */
3
+ import { appendFileSync } from "node:fs";
4
+ import { parseArgs } from "node:util";
5
+ import { captureTaskCommand } from "../src/_task-command.ts";
6
+ import { parseReleaseTagAnnotation } from "../src/release-options.ts";
7
+
8
+ /** Verify and export a tag policy using the shared annotated-tag contract. */
9
+ export function main(args: string[] = process.argv.slice(2)): void {
10
+ const { values } = parseArgs({
11
+ args,
12
+ options: { tag: { type: "string" }, output: { type: "string" } },
13
+ strict: true,
14
+ });
15
+ if (!values.tag || !values.output) throw new Error("--tag and --output are required");
16
+ const annotation = captureTaskCommand(
17
+ process.cwd(),
18
+ "git",
19
+ ["for-each-ref", "--format=%(contents)", `refs/tags/${values.tag}`],
20
+ { check: true },
21
+ );
22
+ if (!annotation) throw new Error(`Release tag ${values.tag} has no annotation`);
23
+ const selection = parseReleaseTagAnnotation(annotation);
24
+ appendFileSync(
25
+ values.output,
26
+ Object.entries(selection)
27
+ .map(([name, enabled]) => `${name}=${enabled}\n`)
28
+ .join(""),
29
+ );
30
+ }
31
+
32
+ if (import.meta.main) main();
package/tasks/release.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env -S bun
2
2
  import { main } from "./lib/release.ts";
3
3
 
4
- export { main, runRelease } from "./lib/release.ts";
4
+ export { createReleaseCommand, main, runRelease } from "./lib/release.ts";
5
5
 
6
6
  if (import.meta.main) await main();