@dbx-tools/projen 0.6.194 → 0.6.195

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
@@ -129,13 +129,14 @@ removing a crate or `setup_scaffolding!()` marker triggers a full synth. Repos
129
129
  without Rust crates start no Rust watcher. When Rust projects are detected,
130
130
  Cargo is required and the focused task fails immediately if it is unavailable.
131
131
 
132
- The workspace generates one `release.yml` workflow for every ecosystem. An
133
- annotated `v*` tag starts it directly. The context job resolves the tag to a
134
- commit, requires the tag object to be annotated, and exposes the verified tag,
135
- commit, and version to every source job. A manual run accepts that same tag and
132
+ The workspace generates one `release.yml` workflow for every ecosystem. A push
133
+ to the configured release branch starts it. The context job reads `VERSION`,
134
+ creates the corresponding annotated `v*` tag, and exposes the verified tag,
135
+ commit, and version to every source job. If the tag already exists, it must be
136
+ annotated and reference the same commit. A manual run accepts that same tag and
136
137
  commit but requires dry-run mode, so it builds and validates without publishing
137
- packages or deploying documentation. One `release` concurrency group cancels a
138
- superseded run.
138
+ packages or deploying documentation. One non-cancelling `release` concurrency
139
+ group serializes publication.
139
140
 
140
141
  Release generation writes `release.yml` without scanning for or removing other
141
142
  workflow files. Consumers explicitly delete exact workflow files they no longer
@@ -146,15 +147,15 @@ For a separate recovery run, choose `node`, `python`, or `docs` instead of
146
147
  `all`. Node and Python recovery can supply the prior `release.yml` run ID; the
147
148
  workflow verifies that run used the commit selected by the annotated release
148
149
  tag before downloading its artifacts. Dispatch the workflow with the release
149
- tag as its Git ref so environment tag policies still apply. Manual recovery
150
+ tag as its Git ref so recovery checks out the immutable release boundary. Manual recovery
150
151
  defaults to dry-run and publishes only when `dry_run` is cleared. npm retries skip an exact version only
151
152
  after the local archive integrity and repository identity match registry
152
153
  metadata. PyPI retries enable Twine's hash-aware existing-file behavior, which
153
154
  skips matching files and fails when an existing filename has different content.
154
155
 
155
156
  The Rust matrix has one row per target. Each row installs native dependencies,
156
- restores Cargo and sccache once, builds the Cargo workspace once, then packages
157
- every discovered output from that shared build. Set a source-only crate's or
157
+ builds the Cargo workspace once, then packages every discovered output from
158
+ that shared build. Set a source-only crate's or
158
159
  release-enabled binary's `releaseExcludeOs` package option to omit it from
159
160
  incompatible rows through Cargo `--exclude`. Release binary packaging and
160
161
  artifact upload are skipped in those rows. UniFFI crates cannot use this option
@@ -171,10 +172,11 @@ Node facades. A binary-only row therefore installs no language package tool.
171
172
  `rustVersion` remains the MSRV recorded in package manifests, while
172
173
  `releaseRustVersion` independently defaults release compilation to `stable`.
173
174
  Stable Windows rows verify and use the hosted runner's installed Rust toolchain
174
- and select `rust-lld` for the workspace build. Cargo registry caches
175
- and the `SCCACHE_GHA_VERSION` namespace stay stable per target/toolchain across
176
- version tags. Cache keys, restore results, sccache statistics, and phase timings
177
- are written to each build log. Python generation executes the already-built
175
+ and select `rust-lld` for the workspace build. Rust release rows restore and
176
+ update dependency-only Cargo caches in the release branch's cache scope. The
177
+ cache key excludes workspace version changes, and manual tag recovery restores
178
+ without saving a tag-scoped copy. There is no separate cache workflow or
179
+ sccache layer. Phase timings are written to each build log. Python generation executes the already-built
178
180
  `target/<triple>/release/<crate>-uniffi-bindgen` directly. Artifact packaging therefore
179
181
  does no Rust compilation after the main workspace build.
180
182
 
@@ -204,7 +206,7 @@ to run the optional nonblocking registry install and import check after facade
204
206
  publication. Python combines same-run platform wheels with standard wheel and
205
207
  source builds, then publishes each distribution through its own PyPI
206
208
  trusted-publisher environment. Binding publishers wait for their dependencies.
207
- Trusted-publisher instructions name `release.yml` and the release tag policy.
209
+ Trusted-publisher instructions name `release.yml` and the release branch policy.
208
210
  The docs jobs generate README and TypeScript API content and deploy GitHub Pages
209
211
  from the same workflow. When a conventional Node binding path already belongs
210
212
  to a root subproject, Rust mapping reuses that project and adds binding
@@ -388,7 +390,7 @@ a new package is covered without a re-synth. Work from the root:
388
390
  | `bun run test` | `eslint` once, then each member's tests |
389
391
  | `bun run sync` | re-synth (`--watch` to keep synthing) |
390
392
  | `bun run barrels` | regenerate the read-only `index.ts` barrels |
391
- | `bun run bump` | version, tag, and publish |
393
+ | `bun run bump` | version, commit, and push the current branch |
392
394
 
393
395
  `bump` also mirrors a release into local registries when the active clients are
394
396
  pointed at loopback services. npm uses `npm config get registry` and publishes
@@ -408,12 +410,14 @@ Use `--local-registry false` or `--local-pypi false` to disable either local
408
410
  publish. An explicit `--local-pypi http://localhost:3141/user/index/` overrides
409
411
  auto-detection; `--python-root` defaults to `packages/py`.
410
412
 
411
- The pushed `v*` tag is also the public release boundary. Available workflow
413
+ The configured release branch is the publication trigger. Its workflow creates
414
+ the annotated `v*` public release boundary from `VERSION`. Available workflow
412
415
  stages form the generated chain Rust -> Python -> Node -> docs: Rust builds and
413
416
  publishes native artifacts and Cargo crates, Python publishes standard
414
417
  distributions, Node publishes standard workspace packages, and docs deploys
415
- after publication. A stage with no corresponding outputs is omitted. Ordinary
416
- pushes to `main` publish none of those surfaces.
418
+ after publication. A stage with no corresponding outputs is omitted. Keep
419
+ ongoing development on another branch when every release-branch update should
420
+ publish.
417
421
 
418
422
  Members intentionally keep only the tasks that something OTHER than a human
419
423
  invokes, so there is no second place to run the same thing:
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.6.193";
6
+ export const PACKAGE_VERSION = "0.6.194";
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";
package/package.json CHANGED
@@ -25,9 +25,9 @@
25
25
  },
26
26
  "dependencies": {
27
27
  "@clack/prompts": "^1.7.0",
28
- "@dbx-tools/core": "0.6.194",
29
- "@dbx-tools/path": "0.6.194",
30
- "@dbx-tools/shared-core": "0.6.194",
28
+ "@dbx-tools/core": "0.6.195",
29
+ "@dbx-tools/path": "0.6.195",
30
+ "@dbx-tools/shared-core": "0.6.195",
31
31
  "commander": "^15.0.0",
32
32
  "concurrently": "^10.0.3",
33
33
  "constructs": "^10.6.0",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "main": "index.ts",
49
49
  "license": "Apache-2.0",
50
- "version": "0.6.194",
50
+ "version": "0.6.195",
51
51
  "types": "index.ts",
52
52
  "type": "module",
53
53
  "exports": {
package/src/project-js.ts CHANGED
@@ -416,7 +416,7 @@ function defaultProjectOptions(
416
416
  // the block to render, giving the root a `publishConfig` it does not have
417
417
  // today. Provenance is never written to a manifest here (projen only reads it
418
418
  // in its own `Publisher`, and `release: false` means none exists); the
419
- // tag-driven `release` workflow opts in per-run via `npm_config_provenance`
419
+ // The release workflow opts in per run through `npm_config_provenance`.
420
420
  // instead, so LOCAL publishes to a verdaccio still work with no CI OIDC
421
421
  // provider. See {@link DBXToolsRelease}.
422
422
  ...(isRoot ? {} : { npmAccess: javascript.NpmAccess.PUBLIC }),
package/src/project-py.ts CHANGED
@@ -6,7 +6,7 @@ import type { IResolver } from "projen/lib/file";
6
6
  import { JobPermission } from "projen/lib/github/workflows-model";
7
7
  import { parse, stringify } from "smol-toml";
8
8
  import { BUN_VERSION, bunCacheRestoreSteps, bunCacheSaveStep } from "./bun-workflow.ts";
9
- import { projectRepositoryUrl } from "./project-js.ts";
9
+ import { projectReleaseBranch, projectRepositoryUrl } from "./project-js.ts";
10
10
  import { isDBXToolsJavaScriptProject } from "./project-predicate.ts";
11
11
  import type { DBXToolsProject, DBXToolsProjectOptions } from "./project.ts";
12
12
  import { RELEASE_VERSION, releaseSourceSteps } from "./release-dispatch.ts";
@@ -14,7 +14,6 @@ import {
14
14
  releaseArtifactSteps,
15
15
  releasePublishCondition,
16
16
  releaseStageCondition,
17
- releaseTagPattern,
18
17
  releaseWorkflow,
19
18
  } from "./release.ts";
20
19
  import { readWorkspaceVersion } from "./workspace-version.ts";
@@ -577,13 +576,15 @@ export class DBXToolsPythonWorkspace extends Component {
577
576
  ): void {
578
577
  const repository = this.githubRepository();
579
578
  const publications = this.trustedPublisherPublications(options);
580
- const releaseTags = isDBXToolsJavaScriptProject()(project) ? releaseTagPattern(project) : "v*";
579
+ const releaseBranch = isDBXToolsJavaScriptProject()(project)
580
+ ? projectReleaseBranch(project)
581
+ : "main";
581
582
  const linesBeforeAuthentication = [
582
583
  "# PyPI Trusted Publisher Setup Instructions",
583
584
  "",
584
585
  "Use the system browser to audit and configure the PyPI trusted publishers below.",
585
586
  "Do not use an in-app browser or embedded webview.",
586
- "Do not visit GitHub or use the GitHub API or CLI. Every required GitHub owner, repository, workflow, environment, and tag value is provided below and is authoritative.",
587
+ "Do not visit GitHub or use the GitHub API or CLI. Every required GitHub owner, repository, workflow, environment, and branch value is provided below and is authoritative.",
587
588
  "Use only PyPI pages for this task.",
588
589
  "",
589
590
  "## Audit and confirmation",
@@ -601,7 +602,7 @@ export class DBXToolsPythonWorkspace extends Component {
601
602
  "",
602
603
  "## GitHub environment policy",
603
604
  "",
604
- `- The supplied tag policy value is ${releaseTags}.`,
605
+ `- The supplied branch policy value is ${releaseBranch}.`,
605
606
  "- Do not inspect or configure GitHub environments during this task.",
606
607
  "- GitHub environment administration is a separate task; use the supplied values only when comparing PyPI publisher entries.",
607
608
  "",
@@ -642,7 +643,7 @@ export class DBXToolsPythonWorkspace extends Component {
642
643
  `- Repository name: ${repository.name}`,
643
644
  "- Workflow name: release.yml",
644
645
  `- Environment name: ${publication.environment}`,
645
- `- GitHub environment tag: ${releaseTags}`,
646
+ `- GitHub environment branch: ${releaseBranch}`,
646
647
  ...(publication.artifacts ? [`- Artifacts: ${publication.artifacts}`] : []),
647
648
  "",
648
649
  ];
package/src/project-rs.ts CHANGED
@@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url";
5
5
  import { project as coreProject } from "@dbx-tools/core";
6
6
  import { string } from "@dbx-tools/shared-core";
7
7
  import { Project, TextFile, javascript } from "projen";
8
- import { GithubWorkflow } from "projen/lib/github";
9
8
  import { JobPermission, type JobStep } from "projen/lib/github/workflows-model";
10
9
  import { BUN_VERSION } from "./bun-workflow.ts";
11
10
  import { DBXToolsTypeScriptProject, projectRepositoryUrl } from "./project-js.ts";
@@ -84,7 +83,7 @@ export interface DBXToolsRustWorkspaceOptions {
84
83
  readonly pythonRoot?: string;
85
84
  readonly pythonModulePrefix?: string;
86
85
  readonly private?: boolean;
87
- /** Generate tag-driven cross-platform UniFFI package releases. */
86
+ /** Generate release-branch-driven cross-platform UniFFI package releases. */
88
87
  readonly release?: boolean;
89
88
  /** Native release targets; defaults to the maintained GitHub-hosted matrix. */
90
89
  readonly releaseTargets?: readonly UniFFIReleaseTarget[];
@@ -174,30 +173,35 @@ export const UNIFFI_RELEASE_TARGETS: readonly UniFFIReleaseTarget[] = [
174
173
  },
175
174
  ] as const;
176
175
 
176
+ const UBRN_VERSION = "0.31.0-5";
177
+ const RELEASE_PLATFORMS_ENV = "DBX_TOOLS_RELEASE_PLATFORMS";
177
178
  const RUST_CACHE_ENV = {
178
179
  CARGO_INCREMENTAL: "0",
179
180
  CARGO_TERM_COLOR: "always",
180
181
  } as const;
181
- const UBRN_VERSION = "0.31.0-5";
182
- const RELEASE_PLATFORMS_ENV = "DBX_TOOLS_RELEASE_PLATFORMS";
183
182
 
184
- function rustCacheSteps(
185
- sharedKey: string,
186
- idPrefix = "",
187
- saveIf = true,
188
- ): readonly Record<string, unknown>[] {
183
+ function cargoCacheKeyStep(): Record<string, unknown> {
184
+ return {
185
+ name: "Resolve Cargo dependency cache key",
186
+ id: "cargo_cache_key",
187
+ run: "node .projen/cargo-cache-key.mjs",
188
+ };
189
+ }
190
+
191
+ function rustCacheSteps(sharedKey: string): readonly Record<string, unknown>[] {
189
192
  return [
190
193
  {
191
- name: "Cache Cargo registry and targets",
192
- id: `${idPrefix}cargo_cache`,
194
+ name: "Cache Cargo registry and dependencies",
195
+ id: "cargo_cache",
193
196
  uses: "Swatinem/rust-cache@v2.9.2",
194
197
  with: {
195
198
  "cache-targets": true,
196
199
  "cache-workspace-crates": false,
197
200
  "add-job-id-key": false,
198
- "add-rust-environment-hash-key": true,
201
+ "add-rust-environment-hash-key": false,
202
+ key: "${{ steps.cargo_cache_key.outputs.key }}",
199
203
  "shared-key": sharedKey,
200
- "save-if": saveIf,
204
+ "save-if": "${{ github.event_name == 'push' }}",
201
205
  },
202
206
  },
203
207
  ];
@@ -273,6 +277,66 @@ function uniffiReleaseTaskSource(): string {
273
277
  return readFileSync(source, "utf8");
274
278
  }
275
279
 
280
+ function cargoCacheKeySource(): string {
281
+ return `#!/usr/bin/env node
282
+ import { createHash } from "node:crypto";
283
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
284
+ import { spawnSync } from "node:child_process";
285
+
286
+ function command(command, args) {
287
+ const result = spawnSync(command, args, { encoding: "utf8" });
288
+ if (result.error) throw result.error;
289
+ if (result.status !== 0) throw new Error(\`\${command} exited with \${result.status}\`);
290
+ return result.stdout;
291
+ }
292
+
293
+ const hasLock = existsSync("Cargo.lock");
294
+ const metadata = JSON.parse(
295
+ command("cargo", [
296
+ "metadata",
297
+ ...(hasLock ? ["--locked"] : []),
298
+ "--format-version",
299
+ "1",
300
+ "--no-deps",
301
+ ]),
302
+ );
303
+ const workspaceNames = new Set(metadata.packages.map((pkg) => pkg.name));
304
+ const dependencyLock = hasLock
305
+ ? readFileSync("Cargo.lock", "utf8")
306
+ .split("[[package]]")
307
+ .slice(1)
308
+ .filter((block) => {
309
+ const name = /^\\s*name = "([^"]+)"/m.exec(block)?.[1];
310
+ return name && !workspaceNames.has(name);
311
+ })
312
+ .map((block) => \`[[package]]\${block}\`)
313
+ .join("")
314
+ : "";
315
+ const manifests = [
316
+ metadata.workspace_root + "/Cargo.toml",
317
+ ...metadata.packages.map((pkg) => pkg.manifest_path),
318
+ ]
319
+ .map((path) =>
320
+ readFileSync(path, "utf8").replace(/^version = "[0-9]+\\.[0-9]+\\.[0-9]+"\\s*$/gm, ""),
321
+ )
322
+ .join("\\n");
323
+ const config = existsSync(".cargo/config.toml")
324
+ ? readFileSync(".cargo/config.toml", "utf8")
325
+ : "";
326
+ const key = createHash("sha256")
327
+ .update(dependencyLock)
328
+ .update(manifests)
329
+ .update(config)
330
+ .update(command("rustc", ["-vV"]))
331
+ .digest("hex");
332
+ if (process.env.GITHUB_OUTPUT) {
333
+ appendFileSync(process.env.GITHUB_OUTPUT, \`key=\${key}\\n\`);
334
+ } else {
335
+ process.stdout.write(\`\${key}\\n\`);
336
+ }
337
+ `;
338
+ }
339
+
276
340
  /** Persisted mapping consumed by the focused Rust source watcher. */
277
341
  export interface RustBindingMapping {
278
342
  readonly crate: string;
@@ -842,112 +906,13 @@ export class DBXToolsRustWorkspace {
842
906
  }
843
907
  project.removeTask("rs:bindings:demo");
844
908
  if (releaseEnabled) {
845
- this.addCacheWorkflow(project, options, nativeTargets);
909
+ new TextFile(project, ".projen/cargo-cache-key.mjs", {
910
+ lines: cargoCacheKeySource().trimEnd().split("\n"),
911
+ });
846
912
  this.addReleaseWorkflow(project, options, nativeTargets);
847
913
  }
848
914
  }
849
915
 
850
- private addCacheWorkflow(
851
- project: javascript.NodeProject,
852
- options: DBXToolsRustWorkspaceOptions,
853
- targets: readonly UniFFIReleaseTarget[],
854
- ): void {
855
- if (!project.github || !isDBXToolsJavaScriptProject()(project) || targets.length === 0) return;
856
- const releaseRustVersion = options.releaseRustVersion ?? "stable";
857
- const usePreinstalledWindowsRust = releaseRustVersion === "stable";
858
- const usesCargoLock = existsSync(join(project.outdir, "Cargo.lock"));
859
- const hasReleaseExclusions = this.packages.some(
860
- (pkg) => pkg.packageOptions.releaseExcludeOs?.length,
861
- );
862
- const matrix = targets.map((target) => ({
863
- ...target,
864
- ...(hasReleaseExclusions
865
- ? {
866
- cargoExcludes: this.packages
867
- .filter((pkg) => pkg.packageOptions.releaseExcludeOs?.includes(target.os))
868
- .map((pkg) => `--exclude ${pkg.crateName}`)
869
- .join(" "),
870
- }
871
- : {}),
872
- }));
873
- const workflow = new GithubWorkflow(project.github, "rust-cache", {
874
- fileName: "rust-cache.yml",
875
- limitConcurrency: true,
876
- concurrencyOptions: { group: "rust-cache", cancelInProgress: true },
877
- });
878
- workflow.on({
879
- push: {
880
- branches: ["main"],
881
- paths: [
882
- ".cargo/**",
883
- ".github/workflows/rust-cache.yml",
884
- "Cargo.lock",
885
- "Cargo.toml",
886
- "packages/rs/**",
887
- ],
888
- },
889
- workflowDispatch: {},
890
- });
891
- workflow.addJob("prime", {
892
- name: "${{ matrix.node }}",
893
- runsOn: ["${{ matrix.runner }}"],
894
- permissions: { contents: JobPermission.READ },
895
- env: RUST_CACHE_ENV,
896
- strategy: {
897
- failFast: false,
898
- matrix: { include: matrix },
899
- },
900
- steps: [
901
- { name: "Checkout", uses: "actions/checkout@v6" },
902
- {
903
- name: "Setup Rust",
904
- ...(usePreinstalledWindowsRust ? { if: "${{ matrix.os != 'win32' }}" } : {}),
905
- uses: `dtolnay/rust-toolchain@${releaseRustVersion}`,
906
- with: { targets: "${{ matrix.cargo }}" },
907
- },
908
- ...(usePreinstalledWindowsRust
909
- ? [
910
- {
911
- name: "Verify preinstalled Windows Rust",
912
- if: "${{ matrix.os == 'win32' }}",
913
- shell: "bash",
914
- run: [
915
- "rustc --version --verbose",
916
- "cargo --version",
917
- 'rustup target list --installed | grep -Fx "${{ matrix.cargo }}"',
918
- 'test -f "$(rustc --print sysroot)/lib/rustlib/${{ matrix.cargo }}/bin/rust-lld.exe"',
919
- ].join("\n"),
920
- },
921
- ]
922
- : []),
923
- ...rustCacheSteps(`release-\${{ matrix.cargo }}-rust-${releaseRustVersion}`),
924
- {
925
- name: "Install Linux native dependencies",
926
- if: "${{ matrix.os == 'linux' }}",
927
- run: [
928
- "sudo rm -f /etc/apt/sources.list.d/google-chrome.list",
929
- "sudo apt-get update",
930
- "sudo apt-get install --yes libdbus-1-dev pkg-config",
931
- ].join("\n"),
932
- },
933
- {
934
- name: "Prime Cargo target cache",
935
- shell: "bash",
936
- env: {
937
- CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER:
938
- "${{ matrix.os == 'win32' && 'rust-lld' || '' }}",
939
- },
940
- run: timedBash(
941
- "rust_cache",
942
- `cargo build --release --workspace${usesCargoLock ? " --locked" : ""} --target "\${{ matrix.cargo }}"${
943
- hasReleaseExclusions ? " ${{ matrix.cargoExcludes }}" : ""
944
- }`,
945
- ),
946
- },
947
- ],
948
- });
949
- }
950
-
951
916
  private addReleaseWorkflow(
952
917
  project: javascript.NodeProject,
953
918
  options: DBXToolsRustWorkspaceOptions,
@@ -1142,15 +1107,8 @@ export class DBXToolsRustWorkspace {
1142
1107
  },
1143
1108
  ]
1144
1109
  : []),
1145
- ...rustCacheSteps(`release-\${{ matrix.cargo }}-rust-${releaseRustVersion}`, "", false),
1146
- {
1147
- name: "Log cache configuration",
1148
- shell: "bash",
1149
- run: [
1150
- `echo "cargo_cache_namespace=release-\${{ matrix.cargo }}-rust-${releaseRustVersion}"`,
1151
- 'echo "cargo_cache_hit=${{ steps.cargo_cache.outputs.cache-hit }}"',
1152
- ].join("\n"),
1153
- },
1110
+ cargoCacheKeyStep(),
1111
+ ...rustCacheSteps(`release-\${{ matrix.cargo }}-rust-${releaseRustVersion}`),
1154
1112
  {
1155
1113
  name: "Install Linux native dependencies",
1156
1114
  if: "${{ matrix.os == 'linux' }}",
package/src/release.ts CHANGED
@@ -1,9 +1,9 @@
1
- /** Unified tag-driven release workflow generation. */
1
+ /** Unified default-branch release workflow generation. */
2
2
  import { Component } from "projen";
3
3
  import { GithubWorkflow } from "projen/lib/github";
4
4
  import { JobPermission, type Job, type JobStep } from "projen/lib/github/workflows-model";
5
5
  import { BUN_VERSION, bunCacheRestoreSteps, bunCacheSaveStep } from "./bun-workflow.ts";
6
- import type { DBXToolsJavaScriptProject } from "./project-js.ts";
6
+ import { projectReleaseBranch, type DBXToolsJavaScriptProject } from "./project-js.ts";
7
7
  import { applyTasks, taskScript } from "./project.ts";
8
8
  import { RELEASE_VERSION, releaseSourceSteps } from "./release-dispatch.ts";
9
9
 
@@ -44,14 +44,14 @@ export function hasNodeRelease(project: DBXToolsJavaScriptProject): boolean {
44
44
  return nodeReleaseProjects.has(project);
45
45
  }
46
46
 
47
- /** Tag pattern accepted by release jobs and GitHub environments. */
47
+ /** Tag pattern created by release jobs and accepted by manual recovery. */
48
48
  export function releaseTagPattern(project: DBXToolsJavaScriptProject): string {
49
49
  const prefix = releaseTagPrefixes.get(project);
50
50
  if (!prefix) throw new Error("Release workflow is not configured");
51
51
  return `${prefix}*`;
52
52
  }
53
53
 
54
- /** Run a release stage on tag pushes or when selected for manual recovery. */
54
+ /** Run a release stage on default-branch pushes or when selected for manual recovery. */
55
55
  export function releaseStageCondition(stage: Exclude<ReleaseStage, "all">): string {
56
56
  return `\${{ github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == '${stage}' }}`;
57
57
  }
@@ -121,10 +121,10 @@ export function npmPublishEnvironment(): Record<string, string> {
121
121
  };
122
122
  }
123
123
 
124
- function verifyContextJob(tagPrefix: string): Job {
124
+ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
125
125
  return {
126
126
  runsOn: ["ubuntu-latest"],
127
- permissions: { actions: JobPermission.READ, contents: JobPermission.READ },
127
+ permissions: { actions: JobPermission.READ, contents: JobPermission.WRITE },
128
128
  outputs: {
129
129
  release_tag: { stepId: "release", outputName: "release_tag" },
130
130
  expected_sha: { stepId: "release", outputName: "expected_sha" },
@@ -132,10 +132,10 @@ function verifyContextJob(tagPrefix: string): Job {
132
132
  },
133
133
  steps: [
134
134
  {
135
- name: "Checkout release tag",
135
+ name: "Checkout release source",
136
136
  uses: "actions/checkout@v6",
137
137
  with: {
138
- ref: "${{ github.event_name == 'push' && github.ref || inputs.expected_sha }}",
138
+ ref: "${{ github.event_name == 'push' && github.sha || inputs.expected_sha }}",
139
139
  "fetch-depth": 1,
140
140
  },
141
141
  },
@@ -145,18 +145,36 @@ function verifyContextJob(tagPrefix: string): Job {
145
145
  shell: "bash",
146
146
  env: {
147
147
  RELEASE_TAG:
148
- "${{ github.event_name == 'push' && github.ref_name || inputs.release_tag }}",
148
+ "${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || '' }}",
149
149
  EXPECTED_SHA:
150
150
  "${{ github.event_name == 'workflow_dispatch' && inputs.expected_sha || '' }}",
151
151
  DRY_RUN: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }}",
152
152
  },
153
153
  run: [
154
- `case "$RELEASE_TAG" in ${tagPrefix}*) ;; *) exit 1 ;; esac`,
155
- 'git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"',
156
- 'test "$(git cat-file -t "$RELEASE_TAG")" = "tag"',
157
- 'RELEASE_SHA="$(git rev-parse "$RELEASE_TAG^{commit}")"',
158
- 'test "$(git rev-parse HEAD)" = "$RELEASE_SHA"',
159
- 'if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then',
154
+ 'if [ "$GITHUB_EVENT_NAME" = "push" ]; then',
155
+ ' test "$GITHUB_REF_TYPE" = "branch"',
156
+ ` test "$GITHUB_REF_NAME" = "${releaseBranch}"`,
157
+ ' RELEASE_SHA="$GITHUB_SHA"',
158
+ ' test "$(git rev-parse HEAD)" = "$RELEASE_SHA"',
159
+ " RELEASE_VERSION=\"$(tr -d '\\r\\n' < VERSION)\"",
160
+ ' [[ "$RELEASE_VERSION" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+$ ]]',
161
+ ` RELEASE_TAG="${tagPrefix}$RELEASE_VERSION"`,
162
+ ' if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG" >/dev/null 2>&1; then',
163
+ ' git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"',
164
+ ' test "$(git cat-file -t "$RELEASE_TAG")" = "tag"',
165
+ ' test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$RELEASE_SHA"',
166
+ " else",
167
+ ' git config user.name "github-actions[bot]"',
168
+ ' git config user.email "41898282+github-actions[bot]@users.noreply.github.com"',
169
+ ' git tag -a "$RELEASE_TAG" "$RELEASE_SHA" -m "$RELEASE_TAG"',
170
+ ' git push origin "refs/tags/$RELEASE_TAG"',
171
+ " fi",
172
+ "else",
173
+ ` case "$RELEASE_TAG" in ${tagPrefix}*) ;; *) exit 1 ;; esac`,
174
+ ' git fetch --force origin "+refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"',
175
+ ' test "$(git cat-file -t "$RELEASE_TAG")" = "tag"',
176
+ ' RELEASE_SHA="$(git rev-parse "$RELEASE_TAG^{commit}")"',
177
+ ' test "$(git rev-parse HEAD)" = "$RELEASE_SHA"',
160
178
  ' test "$GITHUB_REF_TYPE" = "tag"',
161
179
  ' test "$GITHUB_REF_NAME" = "$RELEASE_TAG"',
162
180
  ' test "$RELEASE_SHA" = "$EXPECTED_SHA"',
@@ -294,13 +312,14 @@ export class DBXToolsRelease extends Component {
294
312
  constructor(project: DBXToolsJavaScriptProject, options: DBXToolsReleaseOptions = {}) {
295
313
  super(project);
296
314
  const tagPrefix = options.tagPrefix ?? "v";
315
+ const releaseBranch = projectReleaseBranch(project);
297
316
  releaseTagPrefixes.set(project, tagPrefix);
298
317
  if (options.nodeRelease !== false) nodeReleaseProjects.add(project);
299
318
  applyTasks(project, {
300
319
  bump: {
301
320
  exec: taskScript(project, "bump.ts", `--prefix ${tagPrefix}`),
302
321
  receiveArgs: true,
303
- description: "Bump the release version (default patch), then commit, tag, and push it",
322
+ description: "Bump the release version (default patch), then commit and push it",
304
323
  },
305
324
  });
306
325
  if (!project.github) return;
@@ -308,12 +327,12 @@ export class DBXToolsRelease extends Component {
308
327
  const workflow = new GithubWorkflow(project.github, "release", {
309
328
  fileName: "release.yml",
310
329
  limitConcurrency: true,
311
- concurrencyOptions: { group: "release", cancelInProgress: true },
330
+ concurrencyOptions: { group: "release", cancelInProgress: false },
312
331
  });
313
332
  workflow.runName =
314
- "release ${{ github.event_name == 'push' && github.ref_name || inputs.release_tag }}";
333
+ "release ${{ github.event_name == 'push' && github.sha || inputs.release_tag }}";
315
334
  workflow.on({
316
- push: { tags: [`${tagPrefix}*`] },
335
+ push: { branches: [releaseBranch] },
317
336
  workflowDispatch: {
318
337
  inputs: {
319
338
  release_tag: {
@@ -350,7 +369,7 @@ export class DBXToolsRelease extends Component {
350
369
  });
351
370
  workflow.file?.addOverride("permissions.contents", "read");
352
371
  workflow.file?.addOverride("on.workflow_dispatch.inputs.dry_run.default", true);
353
- workflow.addJob("verify-context", verifyContextJob(tagPrefix));
372
+ workflow.addJob("verify-context", verifyContextJob(tagPrefix, releaseBranch));
354
373
  if (options.nodeRelease !== false) {
355
374
  workflow.addJob("publish-node", nodePublishJob(project));
356
375
  }
package/tasks/bump.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  /**
3
3
  * `projen bump` - compute the next release version, write it to the workspace
4
4
  * `VERSION` file, synth so every manifest copies it, then (by default) commit,
5
- * tag, and push. Pushing the tag is what triggers the release workflow.
5
+ * and push. Pushing the release branch triggers the workflow, which creates the
6
+ * annotated version tag before publishing.
6
7
  *
7
8
  * The base version is the HIGHEST published git tag across `<prefix>` and every
8
9
  * `--sibling` prefix (fetched from the remote so a release cut elsewhere wins),
@@ -22,27 +23,28 @@
22
23
  * tags. Both draw the fallback from the one root `VERSION` file, so the engine
23
24
  * and the packages share a single source of truth.
24
25
  *
25
- * Flags (all default ON; negate with the `--no-` form, per commander):
26
+ * Flags default ON unless noted; negate with the `--no-` form, per commander:
26
27
  * --synth / --no-synth synth after writing VERSION so manifests copy it
27
28
  * --version / --no-version write the bumped version into `VERSION`
28
29
  * --commit / --no-commit commit the release (staged with `git add -A`)
29
- * --tag / --no-tag create the `<prefix><version>` git tag
30
- * --push / --no-push push the CURRENT branch + tag to origin
30
+ * --tag create the `<prefix><version>` git tag locally (default OFF)
31
+ * --push / --no-push push the CURRENT branch to origin
31
32
  *
32
- * `--publish` / `--no-publish` is an alias for `--push` (pushing the tag is
33
- * what publishes). The tag prefix comes from `--prefix` (default `v`).
33
+ * `--publish` / `--no-publish` is an alias for `--push`. A push publishes only
34
+ * when the current branch is the configured release branch. The tag prefix
35
+ * comes from `--prefix` (default `v`).
34
36
  *
35
37
  * `--local-registry <value>` publishes npm packages to a LOCAL registry (e.g.
36
- * verdaccio) right after the tag push. `--local-pypi <value>` does the same for
38
+ * verdaccio) right after the branch push. `--local-pypi <value>` does the same for
37
39
  * Python packages through a writable devpi index. Values for both:
38
40
  * - `auto` (default): publish only when `npm config get registry` is a
39
- * loopback host, or when ANY active Python index — the primary
40
- * `index-url` OR any `extra-index-url`, across uv and pip — is a loopback
41
+ * loopback host, or when ANY active Python index - the primary
42
+ * `index-url` OR any `extra-index-url`, across uv and pip - is a loopback
41
43
  * devpi URL. Scanning the extras is what lets the corp proxy stay the
42
44
  * primary index while a local devpi added as an extra is the detected
43
45
  * publish target. The deploy endpoint for that index is taken from the
44
- * GLOBAL uv config — an explicit `publish-url` on the matching `[[index]]`
45
- * (or `UV_PUBLISH_URL`) — falling back to deriving it from the `+simple`
46
+ * GLOBAL uv config - an explicit `publish-url` on the matching `[[index]]`
47
+ * (or `UV_PUBLISH_URL`) - falling back to deriving it from the `+simple`
46
48
  * URL shape when no such setting exists.
47
49
  * - `false`: never publish locally.
48
50
  * - a URL: always publish to that registry.
@@ -182,7 +184,7 @@ function localCargoRegistry(): string | undefined {
182
184
 
183
185
  const program = new Command();
184
186
  program
185
- .description("Bump the release version, then commit, tag, and push it")
187
+ .description("Bump the release version, then commit and push it")
186
188
  .addOption(
187
189
  new Option("-l, --level <level>", "semver increment").choices([...LEVELS]).default("patch"),
188
190
  )
@@ -211,13 +213,13 @@ program
211
213
  .option("--no-synth", "do not run `projen` (synth) before bumping")
212
214
  .option("--no-version", "do not write the bumped version into package.json")
213
215
  .option("--no-commit", "do not commit the version change")
214
- .option("--no-tag", "do not create the git tag")
215
- .option("--no-push", "do not push the branch and tag to origin")
216
- // `--publish` is a friendlier alias for `--push` (pushing the tag publishes).
216
+ .option("--tag", "create the release tag locally instead of leaving it to the workflow")
217
+ .option("--no-push", "do not push the current branch to origin")
218
+ // `--publish` is a friendlier alias for `--push`.
217
219
  .option("--no-publish", "alias for --no-push")
218
220
  .option(
219
221
  "--local-registry <value>",
220
- "publish locally after the tag push: 'auto' (only a loopback npm registry), 'false', or a registry URL",
222
+ "publish locally after the branch push: 'auto' (only a loopback npm registry), 'false', or a registry URL",
221
223
  "auto",
222
224
  )
223
225
  .option(
@@ -280,6 +282,7 @@ program
280
282
  }
281
283
 
282
284
  const push = opts.push && opts.publish;
285
+ const createTags = opts.tag || siblings.length > 0;
283
286
 
284
287
  // Write the source of truth first, then synth so every generated manifest
285
288
  // COPIES it - synth never invents, resets, or drifts a version on its own.
@@ -338,7 +341,7 @@ program
338
341
  "-m",
339
342
  `chore(release): ${version}`,
340
343
  "-m",
341
- "🌸 Shipped with Kanna — https://kanna.sh",
344
+ "Shipped with Kanna - https://kanna.sh",
342
345
  "-m",
343
346
  "Co-Authored-By: Kanna <noreply@kanna.sh>\nKanna-Agent: codex/databricks-gpt-5-6-sol",
344
347
  ]);
@@ -347,32 +350,24 @@ program
347
350
  }
348
351
  }
349
352
 
350
- if (opts.tag) {
353
+ if (createTags) {
351
354
  for (const t of tags) git(["tag", "-a", t, "-m", t]);
352
355
  logger.info(`tagged ${tags.join(", ")}`);
353
356
  }
354
357
 
355
358
  if (push) {
356
359
  git(["push", "origin", "HEAD"]);
357
- // The managed Databricks pre-push hook treats a newly created tag's
358
- // all-zero remote SHA as a branch with no upstream and scans hundreds of
359
- // historical commits. The release commit was already scanned by the
360
- // branch push immediately above, and each annotated tag points to that
361
- // exact commit, so bypass the hook only for the tag-ref transport.
362
- // Push every tag in one invocation so a partial push cannot release
363
- // only some namespaces at this version.
364
- if (opts.tag) {
360
+ if (createTags) {
365
361
  assertReleaseTagsPointToHead(tags);
366
362
  git(["push", "--no-verify", "origin", ...tags]);
367
363
  }
368
- logger.success(`pushed ${opts.tag ? tags.join(", ") : "HEAD"} to origin`);
364
+ logger.success(`pushed ${createTags ? tags.join(", ") : "HEAD"} to origin`);
369
365
  } else {
370
366
  logger.info("skipped push (--no-push / --no-publish)");
371
367
  }
372
368
 
373
- // Local registry (e.g. verdaccio): publish AFTER the tag push so the
374
- // GitHub release still owns the public registry. Skipped under
375
- // `--no-version` (nothing bumped to publish).
369
+ // Local registry publication starts after the branch push so the GitHub
370
+ // release workflow can own public publication independently.
376
371
  const localRegistry = resolveLocalRegistry(opts.localRegistry);
377
372
  const publishToLocalRegistry = opts.version && localRegistry;
378
373
  if (opts.version === false && localRegistry) {