@dbx-tools/projen 0.9.1 → 0.9.3

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
@@ -173,26 +173,20 @@ a host Rust build. Regenerate bindings through the focused watcher or explicit
173
173
  task while changing a UniFFI API. Release preparation runs Cargo workspace tests
174
174
  without invoking UBRN.
175
175
 
176
- The workspace generates one `release.yml` workflow for every ecosystem.
177
- Independent mode runs Release Please on default-branch pushes. It owns the
178
- combined release PR, per-component semantic increments, component-qualified
179
- tags, and GitHub Releases. A Projen reconciliation pass reads the reviewed
180
- manifest, regenerates every owned version surface, validates a clean second
181
- synthesis, and commits component-qualified release notes to the release PR.
182
- One non-cancelling `release` concurrency group serializes publication.
176
+ The workspace generates one `release.yml` workflow for every ecosystem. One
177
+ root `VERSION` drives every Node, Python, Cargo, native, and GitHub artifact.
178
+ `bun run release` prepares one reviewed PR into `main`; it enables automatic
179
+ merge by default and accepts `--no-approve` for a human-controlled merge. One
180
+ non-cancelling `release` concurrency group serializes publication.
183
181
 
184
182
  Release generation writes `release.yml` without scanning for or removing other
185
183
  workflow files. Consumers explicitly delete exact workflow files they no longer
186
184
  want.
187
185
 
188
- The release-plan job compares the previous and reviewed release-unit graphs and
189
- selects only changed units, required dependents, packages, native targets,
190
- assets, and docs. A Node-only release allocates no Rust runner. Manual recovery
191
- names one component, reviewed version, and publication stage; it reconstructs
192
- that component's plan without changing any other version. npm retries compare
193
- archive content and repository identity, PyPI uses hash-aware existing-file
194
- behavior, Cargo checks existing versions, and GitHub assets upload to the
195
- component release.
186
+ The release workflow accepts stage-specific manual recovery only for the exact
187
+ annotated tag and expected SHA. npm retries compare archive content and
188
+ repository identity, PyPI uses hash-aware existing-file behavior, Cargo checks
189
+ existing versions, and GitHub assets upload to the single repository release.
196
190
 
197
191
  Set `releaseSyncBranch` when a repository retains a long-lived source branch.
198
192
  After publication the workflow fast-forwards a branch that is behind, skips a
@@ -437,37 +431,34 @@ Every repo-wide task lives on the root, and the root's `compile` / `test`
437
431
  delegate with `bun run --filter '*'` rather than emitting a step per member - so
438
432
  a new package is covered without a re-synth. Work from the root:
439
433
 
440
- | Task | What it does |
441
- | --------------------------- | ----------------------------------------------------- |
442
- | `bun run build` | synth + workspace compile and tests |
443
- | `bun run compile` | `tsc --build` in each member, in parallel |
444
- | `bun run test` | `eslint` once, then each member's tests |
445
- | `bun run sync` | re-synth (`--watch` to keep synthing) |
446
- | `bun run barrels` | regenerate the read-only `index.ts` barrels |
447
- | `bun run release:bootstrap` | initialize writable Release Please state once |
448
- | `bun run release:plan` | emit the affected component and publication plan |
449
- | `bun run version:check` | verify every package against its owning release unit |
450
- | `bun run release` | commit, annotate, and push one source release request |
451
- | `bun run release:refresh` | refresh Release Please PRs, tags, and releases |
452
-
453
- `release` first proves the branch differs from `main`; an unchanged branch exits
454
- before notes, AI, commits, pushes, or PR creation. It commits dirty source,
455
- writes custom `--notes` / `--notes-file` content or a bounded Cursor, Codex,
456
- Claude, then Git fallback to `.release-notes/requests/<branch>.md`, adds one
457
- request commit, and pushes. The generated workflow creates or updates the source
458
- PR, merges it directly with `GITHUB_TOKEN`, and dispatches release planning, so
459
- no UI approval or merge is required. GitHub may briefly record
460
- `action_required` for the bot-authored PR validation; it is not a required gate.
461
-
462
- After the source PR merges, Release Please retains direct conventional semantic
463
- increments and applies required dependent patches. Reconciliation copies the
464
- readable request Markdown into one temporary
465
- `.release-notes/final/<component>-v<version>.md` per affected component and
466
- deletes the consumed request file in the release PR, then merges that generated
467
- PR and explicitly dispatches publication. A source merge with no affected
468
- release units creates no release PR or publication work. Publication applies
469
- each file to its matching GitHub Release, then removes the temporary notes tree
470
- in a cleanup commit. Request and final notes never count as source changes.
434
+ | Task | What it does |
435
+ | ------------------------------ | ---------------------------------------------------- |
436
+ | `bun run build` | synth + workspace compile and tests |
437
+ | `bun run compile` | `tsc --build` in each member, in parallel |
438
+ | `bun run test` | `eslint` once, then each member's tests |
439
+ | `bun run sync` | re-synth (`--watch` to keep synthing) |
440
+ | `bun run barrels` | regenerate the read-only `index.ts` barrels |
441
+ | `bun run bump` | increment `VERSION` and regenerate version surfaces |
442
+ | `bun run version:check` | verify every generated version against `VERSION` |
443
+ | `bun run release` | validate, open, and automatically merge a release PR |
444
+ | `bun run release --no-approve` | open the release PR without automatic merge |
445
+
446
+ `release` commits and pushes pending source work, creates a release worktree,
447
+ increments `VERSION`, synthesizes manifests and registries, runs validation and
448
+ local publication, writes the release summary, and opens one PR. It enables
449
+ automatic merge unless `--no-approve` is supplied. The merged `VERSION` change
450
+ starts publication from the exact `main` SHA and creates one `v<version>` tag;
451
+ the command waits for that workflow by default. Use `--no-wait` to return after
452
+ requesting auto-merge, `--no-validate` to skip repository tests/compile, or
453
+ `--no-local-publish` to skip local registry preflight.
454
+ Version resolution considers both repository tags and historical
455
+ `<component>-v<version>` tags, ensuring a singular release starts above every
456
+ component version left by an independent-version migration.
457
+ The repository host and owner are derived from the Git remote. If the GitHub CLI
458
+ has multiple accounts for that host, release preparation selects the first
459
+ authenticated account with write access to the detected repository. Classic
460
+ tokens must include the `workflow` scope so the release PR can update the
461
+ generated workflow.
471
462
 
472
463
  The GitHub PR workflow runs the explicit `pr:validate` task through Projen's
473
464
  public `BuildWorkflow` `buildTask` option. That task runs synth plus the
@@ -476,10 +467,9 @@ preparation has already run Rust tests, workspace type-checking, and local
476
467
  package preflight before opening the PR. JavaScript behavior tests remain an
477
468
  explicit developer task.
478
469
 
479
- The generated workflow runs Release Please on the configured branch. A merged
480
- release PR produces an affected plan, then selected Rust, Python, Node, GitHub
481
- asset, and docs jobs run behind a publication barrier. A stage with no selected
482
- outputs is skipped before runner allocation.
470
+ The generated workflow publishes the complete public workspace at the singular
471
+ version. Rust, Python, Node, GitHub asset, and docs stages share the verified tag
472
+ and SHA. Documentation generation runs only on this version-triggered workflow.
483
473
 
484
474
  Members intentionally keep only the tasks that something OTHER than a human
485
475
  invokes, so there is no second place to run the same thing:
@@ -506,14 +496,8 @@ from the root instead.
506
496
 
507
497
  ## Versioning
508
498
 
509
- `DBXToolsReleaseCatalog` is the language-neutral version seam. It generates
510
- `.projen/release-units.json` from attached projects, explicit external members,
511
- UniFFI families, and binary assets. Independent mode reads component versions
512
- from `.release-please-manifest.json`; fixed mode reads `VERSION`.
513
-
514
- Every unit has `.release-units/<component>/source.json`, generated from
515
- release-affecting inputs. Release Please owns the adjacent `version.txt` and
516
- `CHANGELOG.md`. Real package manifests remain Projen-owned and reproduce the
517
- reviewed manifest exactly. `versioningMode: "fixed"` remains the default for
518
- consumers during migration; `versioningMode: "independent"` enables the
519
- Release Please and affected-workflow surface.
499
+ `VERSION` is the language-neutral version seam. Real package manifests remain
500
+ Projen-owned and reproduce it exactly across JavaScript, Python, Rust, UniFFI
501
+ bindings, native package metadata, and runtime binary registries. Package and
502
+ artifact discovery still supplies publication order and target inventory; it
503
+ does not create independent version ownership.
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.1";
6
+ export const PACKAGE_VERSION = "0.9.3";
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";
@@ -63,7 +63,7 @@ export { DBXToolsReleaseCatalog } from "./src/release-catalog.ts";
63
63
  export type { DBXToolsVersioningMode, ReleaseProjectLanguage, ReleaseArtifactKind, ReleaseEdgeKind, ReleasePropagation, ReleaseUnitRule, DBXToolsReleaseCatalogOptions, ReleaseDependencyInput, ReleaseProjectRegistration, ExternalReleaseProjectRegistration, ReleaseArtifactRegistration, ReleaseProjectNode, ReleaseArtifact, ReleaseDependencyEdge, ReleaseUnit, ReleaseUnitGraph } from "./src/release-catalog.ts";
64
64
  export { RELEASE_TAG, RELEASE_SHA, RELEASE_VERSION, RELEASE_SUMMARY_DIRECTORY, RELEASE_SUMMARY_PROVIDER_NAMES, RELEASE_SUMMARY_FILE } from "./src/release-dispatch.ts";
65
65
  export type { ReleaseSummaryProviderName } from "./src/release-dispatch.ts";
66
- export type { GithubRepositoryIdentity } from "./src/release-github.ts";
66
+ export type { GithubRepositoryIdentity, GithubAuthenticatedAccount } from "./src/release-github.ts";
67
67
  export type { ReleaseReason, PlannedReleaseUnit, PlannedPackage, PlannedArtifact, PlannedRustTarget, ReleasePlan } from "./src/release-plan.ts";
68
68
  export { ReleaseUnitWorkspacePlugin } from "./src/release-please.ts";
69
69
  export type { ReleaseUnitWorkspacePluginOptions } from "./src/release-please.ts";
package/package.json CHANGED
@@ -26,9 +26,9 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@clack/prompts": "^1.7.0",
29
- "@dbx-tools/core": "^0.6.229",
30
- "@dbx-tools/path": "^0.6.229",
31
- "@dbx-tools/shared-core": "^0.6.229",
29
+ "@dbx-tools/core": "^0.9.3",
30
+ "@dbx-tools/path": "^0.9.3",
31
+ "@dbx-tools/shared-core": "^0.9.3",
32
32
  "commander": "^15.0.0",
33
33
  "concurrently": "^10.0.3",
34
34
  "constructs": "^10.6.0",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "main": "index.ts",
52
52
  "license": "Apache-2.0",
53
- "version": "0.9.1",
53
+ "version": "0.9.3",
54
54
  "types": "index.ts",
55
55
  "type": "module",
56
56
  "exports": {
package/src/project-rs.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /** Filesystem-discovered Rust workspaces and UniFFI binding package wiring. */
2
- import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import { dirname, join, relative, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { exec, project as coreProject } from "@dbx-tools/core";
6
+ import { project as coreProject } from "@dbx-tools/core";
7
7
  import { string } from "@dbx-tools/shared-core";
8
8
  import { Component, Project, TextFile, TomlFile, javascript } from "projen";
9
9
  import { JobPermission, type Job, type JobStep } from "projen/lib/github/workflows-model";
@@ -287,16 +287,39 @@ function smolTomlSource(name: "dist/index.cjs" | "LICENSE"): string {
287
287
  );
288
288
  }
289
289
 
290
+ /** Update only source-less workspace package entries in Cargo.lock. */
291
+ export function synchronizeCargoLockVersions(
292
+ content: string,
293
+ packageNames: ReadonlySet<string>,
294
+ version: string,
295
+ ): string {
296
+ return content
297
+ .split("[[package]]")
298
+ .map((block, index) => {
299
+ if (index === 0 || /^source = /m.test(block)) return block;
300
+ const name = /^name = "([^"]+)"$/m.exec(block)?.[1];
301
+ if (!name || !packageNames.has(name)) return block;
302
+ return block.replace(/^version = "[^"]+"$/m, `version = "${version}"`);
303
+ })
304
+ .join("[[package]]");
305
+ }
306
+
290
307
  /** Keep tracked workspace package versions in Cargo.lock aligned with VERSION. */
291
308
  class RustWorkspaceVersionLock extends Component {
309
+ constructor(
310
+ project: Project,
311
+ private readonly packageNames: ReadonlySet<string>,
312
+ private readonly version: string,
313
+ ) {
314
+ super(project);
315
+ }
316
+
292
317
  public override postSynthesize(): void {
293
- exec.spawnSync("cargo", ["metadata", "--format-version", "1"], {
294
- cwd: this.project.outdir,
295
- stdout: "ignore",
296
- stderr: "inherit",
297
- stdin: "ignore",
298
- check: true,
299
- });
318
+ const path = join(this.project.outdir, "Cargo.lock");
319
+ if (!existsSync(path)) return;
320
+ const content = readFileSync(path, "utf8");
321
+ const next = synchronizeCargoLockVersions(content, this.packageNames, this.version);
322
+ if (next !== content) writeFileSync(path, next);
300
323
  }
301
324
  }
302
325
 
@@ -1011,7 +1034,11 @@ function configureRustWorkspaceFiles(
1011
1034
  },
1012
1035
  },
1013
1036
  });
1014
- new RustWorkspaceVersionLock(project);
1037
+ new RustWorkspaceVersionLock(
1038
+ project,
1039
+ new Set(packages.map((pkg) => pkg.crateName)),
1040
+ readWorkspaceVersion(project.outdir),
1041
+ );
1015
1042
  }
1016
1043
 
1017
1044
  function configureRustWorkspaceTasks(project: javascript.NodeProject): void {
@@ -1149,6 +1176,18 @@ function configureRustReleaseTask(project: javascript.NodeProject, plan: RustRel
1149
1176
  } else {
1150
1177
  for (const path of supportFiles) project.tryRemoveFile(path);
1151
1178
  }
1179
+ project.addTask("rs:release-fingerprint", {
1180
+ description: "Write the version-independent Rust release build manifest",
1181
+ exec: [
1182
+ "cargo run --quiet --package dbx-tools-release-tools -- fingerprint",
1183
+ ...plan.targets.map(
1184
+ (target) =>
1185
+ `--target ${JSON.stringify(`${String(target.cargo)}|${String(target.cargoExcludes ?? "")}`)}`,
1186
+ ),
1187
+ `--toolchain ${JSON.stringify(plan.releaseRustVersion)}`,
1188
+ "--portable",
1189
+ ].join(" "),
1190
+ });
1152
1191
  }
1153
1192
 
1154
1193
  function rustBindingCommands(plan: RustReleasePlan, independent: boolean): string[] {
@@ -1329,8 +1368,71 @@ function rustBuildJob(plan: RustReleasePlan, independentSetup?: readonly JobStep
1329
1368
  "sudo apt-get install --yes libdbus-1-dev pkg-config",
1330
1369
  ].join("\n"),
1331
1370
  },
1371
+ {
1372
+ name: "Verify Rust build fingerprint",
1373
+ if: independentSetup
1374
+ ? undefined
1375
+ : "${{ github.event_name == 'push' || inputs.stage == 'all' }}",
1376
+ ...(!independentSetup ? { id: "rust-fingerprint" } : {}),
1377
+ shell: "bash",
1378
+ run: independentSetup
1379
+ ? [
1380
+ "cargo run --quiet --package dbx-tools-release-tools -- fingerprint --check",
1381
+ '--target "${{ matrix.cargo }}|${{ matrix.cargoExcludes }}"',
1382
+ `--toolchain ${JSON.stringify(plan.releaseRustVersion)}`,
1383
+ "--portable",
1384
+ ].join(" ")
1385
+ : [
1386
+ 'RUNTIME_MANIFEST="dist/rust-raw/rust-build-${{ matrix.node }}.json"',
1387
+ 'mkdir -p "$(dirname "$RUNTIME_MANIFEST")"',
1388
+ [
1389
+ "cargo run --quiet --package dbx-tools-release-tools -- fingerprint",
1390
+ '--output "$RUNTIME_MANIFEST"',
1391
+ '--target "${{ matrix.cargo }}|${{ matrix.cargoExcludes }}"',
1392
+ `--toolchain ${JSON.stringify(plan.releaseRustVersion)}`,
1393
+ ].join(" "),
1394
+ 'test "$(jq -r .rustSourceHash "$RUNTIME_MANIFEST")" = "$(jq -r .rustSourceHash .release/rust-build.json)"',
1395
+ 'KEY="$(jq -r --arg target "${{ matrix.cargo }}" \'.targets[$target] // empty\' "$RUNTIME_MANIFEST")"',
1396
+ 'test -n "$KEY"',
1397
+ 'echo "key=$KEY" >> "$GITHUB_OUTPUT"',
1398
+ ].join("\n"),
1399
+ },
1400
+ ...(!independentSetup
1401
+ ? [
1402
+ {
1403
+ name: "Reuse matching raw Rust outputs",
1404
+ id: "raw-native",
1405
+ shell: "bash",
1406
+ env: { GH_TOKEN: "${{ github.token }}" },
1407
+ run: [
1408
+ 'KEY="${{ steps.rust-fingerprint.outputs.key }}"',
1409
+ 'test -n "$KEY"',
1410
+ 'ASSET="rust-raw-${{ matrix.node }}-$KEY.tar.gz"',
1411
+ "mkdir -p dist/rust-raw",
1412
+ 'MATCH="$(gh api --paginate "repos/${{ github.repository }}/releases?per_page=100" --jq \'.[] | select(.draft == false) | . as $release | .assets[] | select(.name == "\'"$ASSET"\'") | [$release.tag_name, .url] | @tsv\' | head -n 1)"',
1413
+ 'if [ -n "$MATCH" ]; then',
1414
+ " TAG=\"${MATCH%%$'\\t'*}\"",
1415
+ " URL=\"${MATCH#*$'\\t'}\"",
1416
+ ' CHECKSUM_URL="$(gh api "repos/${{ github.repository }}/releases/tags/$TAG" --jq \'.assets[] | select(.name == "\'"$ASSET.sha256"\'") | .url\')"',
1417
+ ' rm -rf "target/${{ matrix.cargo }}/release"',
1418
+ ' if test -n "$CHECKSUM_URL" && gh api "$URL" -H "Accept: application/octet-stream" > "dist/rust-raw/$ASSET" && gh api "$CHECKSUM_URL" -H "Accept: application/octet-stream" > "dist/rust-raw/$ASSET.sha256" && (cd dist/rust-raw && if command -v sha256sum >/dev/null 2>&1; then sha256sum --check "$ASSET.sha256"; else shasum -a 256 --check "$ASSET.sha256"; fi) && tar -xzf "dist/rust-raw/$ASSET"; then',
1419
+ ' echo "hit=true" >> "$GITHUB_OUTPUT"',
1420
+ " else",
1421
+ ' rm -rf "target/${{ matrix.cargo }}/release" "dist/rust-raw/$ASSET" "dist/rust-raw/$ASSET.sha256"',
1422
+ ' echo "::warning::matching raw Rust bundle failed validation; rebuilding"',
1423
+ ' echo "hit=false" >> "$GITHUB_OUTPUT"',
1424
+ " fi",
1425
+ "else",
1426
+ ' echo "hit=false" >> "$GITHUB_OUTPUT"',
1427
+ "fi",
1428
+ 'echo "asset=$ASSET" >> "$GITHUB_OUTPUT"',
1429
+ ].join("\n"),
1430
+ },
1431
+ ]
1432
+ : []),
1332
1433
  {
1333
1434
  name: "Build Rust outputs",
1435
+ ...(independentSetup ? {} : { if: "${{ steps.raw-native.outputs.hit != 'true' }}" }),
1334
1436
  shell: "bash",
1335
1437
  env: {
1336
1438
  CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER:
@@ -1343,13 +1445,47 @@ function rustBuildJob(plan: RustReleasePlan, independentSetup?: readonly JobStep
1343
1445
  "mapfile -t PACKAGES < <(jq -r '.[]' <<<'${{ toJSON(matrix.packages) }}')",
1344
1446
  "PACKAGE_ARGS=()",
1345
1447
  'for PACKAGE in "${PACKAGES[@]}"; do PACKAGE_ARGS+=(--package "$PACKAGE"); done',
1346
- `cargo build --release "\${PACKAGE_ARGS[@]}"${plan.usesCargoLock ? " --locked" : ""} --target "\${{ matrix.cargo }}"`,
1448
+ `cargo build --release --timings "\${PACKAGE_ARGS[@]}"${plan.usesCargoLock ? " --locked" : ""} --target "\${{ matrix.cargo }}"`,
1347
1449
  ].join("\n")
1348
- : `cargo build --release --workspace${plan.usesCargoLock ? " --locked" : ""} --target "\${{ matrix.cargo }}"${
1450
+ : `cargo build --release --timings --workspace${plan.usesCargoLock ? " --locked" : ""} --target "\${{ matrix.cargo }}"${
1349
1451
  plan.hasReleaseExclusions ? " ${{ matrix.cargoExcludes }}" : ""
1350
1452
  }`,
1351
1453
  ),
1352
1454
  },
1455
+ ...(!independentSetup
1456
+ ? [
1457
+ {
1458
+ name: "Upload Cargo timings",
1459
+ if: "${{ steps.raw-native.outputs.hit != 'true' }}",
1460
+ uses: "actions/upload-artifact@v7",
1461
+ with: {
1462
+ name: "rust-${{ matrix.node }}-cargo-timings",
1463
+ path: "target/cargo-timings/*",
1464
+ "retention-days": 14,
1465
+ },
1466
+ },
1467
+ ]
1468
+ : []),
1469
+ ...(!independentSetup
1470
+ ? [
1471
+ {
1472
+ name: "Archive raw Rust outputs",
1473
+ if: "${{ steps.raw-native.outputs.hit != 'true' }}",
1474
+ shell: "bash",
1475
+ run: [
1476
+ "mkdir -p dist/rust-raw",
1477
+ 'tar -czf "dist/rust-raw/${{ steps.raw-native.outputs.asset }}" "target/${{ matrix.cargo }}/release"',
1478
+ '(cd dist/rust-raw && if command -v sha256sum >/dev/null 2>&1; then sha256sum "${{ steps.raw-native.outputs.asset }}"; else shasum -a 256 "${{ steps.raw-native.outputs.asset }}"; fi > "${{ steps.raw-native.outputs.asset }}.sha256")',
1479
+ ].join("\n"),
1480
+ },
1481
+ {
1482
+ name: "Stamp native release version",
1483
+ shell: "bash",
1484
+ env: { VERSION: RELEASE_VERSION },
1485
+ run: 'cargo run --quiet --package dbx-tools-release-tools -- stamp-tree --root "target/${{ matrix.cargo }}/release" --version "$VERSION"',
1486
+ },
1487
+ ]
1488
+ : []),
1353
1489
  ...(bindingCommands.length
1354
1490
  ? [
1355
1491
  {
@@ -1369,6 +1505,19 @@ function rustBuildJob(plan: RustReleasePlan, independentSetup?: readonly JobStep
1369
1505
  },
1370
1506
  ]
1371
1507
  : []),
1508
+ ...(!independentSetup
1509
+ ? [
1510
+ {
1511
+ name: "Upload reusable raw Rust outputs",
1512
+ uses: "actions/upload-artifact@v7",
1513
+ with: {
1514
+ name: "rust-${{ matrix.node }}-raw",
1515
+ path: "dist/rust-raw/*",
1516
+ "retention-days": 7,
1517
+ },
1518
+ },
1519
+ ]
1520
+ : []),
1372
1521
  ...rustArtifactSteps(plan),
1373
1522
  ],
1374
1523
  };
@@ -1464,6 +1613,15 @@ function independentRustGitHubReleaseJob(
1464
1613
  "merge-multiple": true,
1465
1614
  },
1466
1615
  },
1616
+ {
1617
+ name: "Download reusable raw Rust outputs",
1618
+ uses: "actions/download-artifact@v8",
1619
+ with: {
1620
+ pattern: "*-raw",
1621
+ path: "dist/rust-raw",
1622
+ "merge-multiple": true,
1623
+ },
1624
+ },
1467
1625
  {
1468
1626
  name: "Upload affected GitHub release assets",
1469
1627
  env: { GH_TOKEN: "${{ github.token }}" },
@@ -1505,7 +1663,7 @@ function rustGitHubReleaseJob(): Job {
1505
1663
  name: "Publish GitHub release assets",
1506
1664
  uses: "softprops/action-gh-release@v2",
1507
1665
  with: {
1508
- files: "dist/rust-release/*",
1666
+ files: ["dist/rust-release/*", "dist/rust-raw/*"].join("\n"),
1509
1667
  body_path: RELEASE_SUMMARY_FILE,
1510
1668
  generate_release_notes: true,
1511
1669
  tag_name: RELEASE_TAG,
@@ -268,12 +268,12 @@ export class DBXToolsReleaseCatalog extends Component {
268
268
  this.bootstrapSha = options.bootstrapSha;
269
269
  this.rules = [...(options.units ?? [])];
270
270
  for (const external of options.externalProjects ?? []) this.registerExternalProject(external);
271
- new JsonFile(project, this.graphFile, {
272
- marker: false,
273
- readonly: true,
274
- obj: () => this.graph(),
275
- });
276
271
  if (this.mode === "independent") {
272
+ new JsonFile(project, this.graphFile, {
273
+ marker: false,
274
+ readonly: true,
275
+ obj: () => this.graph(),
276
+ });
277
277
  new JsonFile(project, this.releasePleaseConfigFile, {
278
278
  marker: false,
279
279
  readonly: true,
@@ -284,6 +284,9 @@ export class DBXToolsReleaseCatalog extends Component {
284
284
  description: "Bootstrap Release Please state from release units",
285
285
  })
286
286
  .exec("bun node_modules/@dbx-tools/projen/tasks/release-bootstrap.ts");
287
+ } else {
288
+ project.tryRemoveFile(this.graphFile);
289
+ project.tryRemoveFile(this.releasePleaseConfigFile);
287
290
  }
288
291
  }
289
292
 
@@ -1,3 +1,5 @@
1
+ import { json, object } from "@dbx-tools/shared-core";
2
+
1
3
  /** GitHub repository identity used by release preparation. */
2
4
  export interface GithubRepositoryIdentity {
3
5
  hostname: string;
@@ -19,9 +21,51 @@ export function githubRepositoryIdentity(repositoryUrl: string): GithubRepositor
19
21
  };
20
22
  }
21
23
 
22
- /** Ask GitHub CLI for the current actor's token on the repository host. */
23
- export function githubTokenArguments(hostname: string): string[] {
24
- return ["auth", "token", "--hostname", hostname];
24
+ /** One authenticated GitHub CLI account available on a repository host. */
25
+ export interface GithubAuthenticatedAccount {
26
+ login: string;
27
+ active: boolean;
28
+ scopes: string[];
29
+ }
30
+
31
+ /** Parse successful GitHub CLI accounts with the active account first. */
32
+ export function githubAuthenticatedAccounts(
33
+ statusOutput: string,
34
+ hostname: string,
35
+ ): GithubAuthenticatedAccount[] {
36
+ const status = json.parseRecord(statusOutput);
37
+ const hosts = object.isRecord(status?.hosts) ? status.hosts : undefined;
38
+ const entries = hosts?.[hostname];
39
+ if (!Array.isArray(entries)) return [];
40
+ return entries
41
+ .flatMap((entry) => {
42
+ if (!object.isRecord(entry) || entry.state !== "success" || typeof entry.login !== "string") {
43
+ return [];
44
+ }
45
+ const scopes =
46
+ typeof entry.scopes === "string"
47
+ ? entry.scopes
48
+ .split(",")
49
+ .map((scope) => scope.trim())
50
+ .filter(Boolean)
51
+ : [];
52
+ return [{ login: entry.login, active: entry.active === true, scopes }];
53
+ })
54
+ .filter(
55
+ (account, index, accounts) =>
56
+ accounts.findIndex((candidate) => candidate.login === account.login) === index,
57
+ )
58
+ .sort((left, right) => Number(right.active) - Number(left.active));
59
+ }
60
+
61
+ /** Whether an account can update workflow files when using a classic OAuth token. */
62
+ export function githubAccountSupportsWorkflowChanges(account: GithubAuthenticatedAccount): boolean {
63
+ return !account.scopes.includes("repo") || account.scopes.includes("workflow");
64
+ }
65
+
66
+ /** Ask GitHub CLI for one account token on the repository host. */
67
+ export function githubTokenArguments(hostname: string, user?: string): string[] {
68
+ return ["auth", "token", "--hostname", hostname, ...(user ? ["--user", user] : [])];
25
69
  }
26
70
 
27
71
  /** GitHub CLI token variable for a public, data-residency, or enterprise host. */
@@ -35,3 +79,8 @@ export function githubTokenEnvironmentName(hostname: string): "GH_TOKEN" | "GH_E
35
79
  export function githubRepositorySpecifier(identity: GithubRepositoryIdentity): string {
36
80
  return `${identity.hostname}/${identity.owner}/${identity.repository}`;
37
81
  }
82
+
83
+ /** GitHub REST path for the detected repository. */
84
+ export function githubRepositoryApiPath(identity: GithubRepositoryIdentity): string {
85
+ return `repos/${identity.owner}/${identity.repository}`;
86
+ }
package/src/release.ts CHANGED
@@ -217,11 +217,10 @@ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
217
217
  " else",
218
218
  " git fetch --force --tags origin",
219
219
  ' LATEST_VERSION=""',
220
- ` for TAG in $(git tag --sort=-version:refname --list '${tagPrefix}*'); do`,
221
- ` CANDIDATE="\${TAG#${tagPrefix}}"`,
220
+ ` for TAG in $(git tag --list '${tagPrefix}*' '*-${tagPrefix}*'); do`,
221
+ ` if [[ "$TAG" == ${tagPrefix}* ]]; then CANDIDATE="\${TAG#${tagPrefix}}"; else CANDIDATE="\${TAG##*-${tagPrefix}}"; fi`,
222
222
  ' if [[ "$CANDIDATE" =~ ^[0-9]+\\.[0-9]+\\.[0-9]+$ ]]; then',
223
- ' LATEST_VERSION="$CANDIDATE"',
224
- " break",
223
+ ' if [ -z "$LATEST_VERSION" ] || node -e \'const a=process.argv[1].split(".").map(Number); const b=process.argv[2].split(".").map(Number); process.exit(b[0]>a[0] || b[0]===a[0] && (b[1]>a[1] || b[1]===a[1] && b[2]>a[2]) ? 0 : 1)\' "$LATEST_VERSION" "$CANDIDATE"; then LATEST_VERSION="$CANDIDATE"; fi',
225
224
  " fi",
226
225
  " done",
227
226
  ' if [ -n "$LATEST_VERSION" ]; then',
@@ -238,8 +237,6 @@ function verifyContextJob(tagPrefix: string, releaseBranch: string): Job {
238
237
  ' test "$(git cat-file -t "$RELEASE_TAG")" = "tag"',
239
238
  ' RELEASE_SHA="$(git rev-parse "$RELEASE_TAG^{commit}")"',
240
239
  ' test "$(git rev-parse HEAD)" = "$RELEASE_SHA"',
241
- ' test "$GITHUB_REF_TYPE" = "tag"',
242
- ' test "$GITHUB_REF_NAME" = "$RELEASE_TAG"',
243
240
  ' test "$RELEASE_SHA" = "$EXPECTED_SHA"',
244
241
  ' if [ -n "${{ inputs.source_run_id }}" ]; then',
245
242
  ' case "${{ inputs.stage }}" in node|python) ;; *) exit 1 ;; esac',
@@ -728,6 +725,7 @@ function addIndependentDocsJobs(
728
725
  ],
729
726
  });
730
727
  workflow.addJob("deploy-docs", {
728
+ if: "${{ always() && needs['build-docs'].result == 'success' }}",
731
729
  needs: ["build-docs"],
732
730
  environment: {
733
731
  name: "github-pages",
@@ -985,7 +983,8 @@ export class DBXToolsRelease extends Component {
985
983
  ].join(" "),
986
984
  ),
987
985
  receiveArgs: true,
988
- description: "Prepare, validate, locally publish, and open a reviewed release PR",
986
+ description:
987
+ "Prepare, validate, locally publish, and automatically merge a release PR",
989
988
  },
990
989
  }
991
990
  : {}),
@@ -137,22 +137,53 @@ export function latestTagVersion(cwd: string, prefix: string): Semver | undefine
137
137
  return undefined;
138
138
  }
139
139
 
140
+ /** Highest historical `<component>-<prefix><semver>` tag, or `undefined`. */
141
+ export function latestComponentTagVersion(cwd: string, prefix: string): Semver | undefined {
142
+ const marker = `-${prefix}`;
143
+ const out = gitCapture(cwd, [
144
+ "-c",
145
+ "versionsort.suffix=-",
146
+ "tag",
147
+ "--sort=-version:refname",
148
+ "--list",
149
+ `*${marker}*`,
150
+ ]);
151
+ let best: Semver | undefined;
152
+ for (const tag of out.split("\n")) {
153
+ const index = tag.lastIndexOf(marker);
154
+ const raw = index >= 0 ? tag.slice(index + marker.length) : "";
155
+ if (!/^\d+\.\d+\.\d+$/.test(raw)) continue;
156
+ const version = parseSemver(raw);
157
+ if (version && (!best || compareSemver(version, best) > 0)) best = version;
158
+ }
159
+ return best;
160
+ }
161
+
140
162
  /**
141
- * Highest published version across every tag prefix, or `undefined` when the
142
- * remote is unreachable or no matching tag exists. Fetches tags first (best
143
- * effort) so a release made elsewhere is respected; a fetch failure just means
144
- * the local tag list is used, and callers fall back to the `VERSION` file.
163
+ * Highest published version across repository and historical component tags,
164
+ * or `undefined` when the remote is unreachable or no matching tag exists.
165
+ * Fetches tags first (best effort) so a release made elsewhere is respected; a
166
+ * fetch failure just means the local tag list is used, and callers fall back to
167
+ * the `VERSION` file.
145
168
  */
146
169
  export function resolveRemoteVersion(
147
170
  cwd: string,
148
171
  prefixes: readonly string[],
149
- { fetch = true }: { fetch?: boolean } = {},
172
+ {
173
+ fetch = true,
174
+ includeComponentTags = true,
175
+ }: { fetch?: boolean; includeComponentTags?: boolean } = {},
150
176
  ): string | undefined {
151
177
  if (fetch) gitCapture(cwd, ["fetch", "--tags", "--quiet"]);
152
178
  let best: Semver | undefined;
153
179
  for (const prefix of prefixes) {
154
- const v = latestTagVersion(cwd, prefix);
155
- if (v && (!best || compareSemver(v, best) > 0)) best = v;
180
+ const versions = [
181
+ latestTagVersion(cwd, prefix),
182
+ ...(includeComponentTags ? [latestComponentTagVersion(cwd, prefix)] : []),
183
+ ];
184
+ for (const version of versions) {
185
+ if (version && (!best || compareSemver(version, best) > 0)) best = version;
186
+ }
156
187
  }
157
188
  return best ? best.join(".") : undefined;
158
189
  }
@@ -165,7 +196,7 @@ export function resolveRemoteVersion(
165
196
  export function resolveBaseVersion(
166
197
  root: string,
167
198
  prefixes: readonly string[],
168
- options: { fetch?: boolean } = {},
199
+ options: { fetch?: boolean; includeComponentTags?: boolean } = {},
169
200
  ): { version: string; source: "remote" | "local" } {
170
201
  const remote = resolveRemoteVersion(root, prefixes, options);
171
202
  if (remote) return { version: remote, source: "remote" };
@@ -177,7 +208,7 @@ export function resolveNextVersion(
177
208
  root: string,
178
209
  prefixes: readonly string[],
179
210
  level: VersionLevel,
180
- options: { fetch?: boolean } = {},
211
+ options: { fetch?: boolean; includeComponentTags?: boolean } = {},
181
212
  ): { base: string; version: string; source: "remote" | "local" } {
182
213
  const base = resolveBaseVersion(root, prefixes, options);
183
214
  const parsed = parseSemver(base.version) ?? [0, 0, 1];
@@ -11,7 +11,7 @@ import { existsSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { exec, project } from "@dbx-tools/core";
14
- import { log } from "@dbx-tools/shared-core";
14
+ import { async as asyncModule, json, log, object } from "@dbx-tools/shared-core";
15
15
  import { Command } from "commander";
16
16
  import { publishLocalRelease } from "./local-publish.ts";
17
17
  import { generateReleaseSummary } from "./release-summary.ts";
@@ -24,6 +24,9 @@ import {
24
24
  type VersionLevel,
25
25
  } from "../src/_release-platform.ts";
26
26
  import {
27
+ githubAccountSupportsWorkflowChanges,
28
+ githubAuthenticatedAccounts,
29
+ githubRepositoryApiPath,
27
30
  githubRepositoryIdentity,
28
31
  githubRepositorySpecifier,
29
32
  githubTokenArguments,
@@ -33,7 +36,11 @@ import {
33
36
  RELEASE_SUMMARY_PROVIDER_NAMES,
34
37
  type ReleaseSummaryProviderName,
35
38
  } from "../src/release-dispatch.ts";
36
- import { readWorkspaceVersion, resolveNextVersion } from "../src/workspace-version.ts";
39
+ import {
40
+ readWorkspaceVersion,
41
+ resolveBaseVersion,
42
+ resolveNextVersion,
43
+ } from "../src/workspace-version.ts";
37
44
 
38
45
  const logger = log.logger("projen:release");
39
46
 
@@ -101,38 +108,201 @@ function run(root: string, command: string, args: string[], env?: NodeJS.Process
101
108
  });
102
109
  }
103
110
 
104
- function runIgnoringStdout(root: string, command: string, args: string[]): void {
105
- exec.spawnSync(command, args, {
111
+ /** Run one release command with inherited output and a hard timeout. */
112
+ async function runWithTimeout(
113
+ root: string,
114
+ command: string,
115
+ args: string[],
116
+ env: NodeJS.ProcessEnv,
117
+ timeoutMs: number,
118
+ ): Promise<void> {
119
+ await exec.spawn(command, args, {
106
120
  cwd: root,
107
- stdout: "ignore",
121
+ env,
122
+ stdout: "inherit",
108
123
  stderr: "inherit",
109
124
  stdin: "ignore",
125
+ signal: AbortSignal.timeout(timeoutMs),
110
126
  check: true,
111
127
  });
112
128
  }
113
129
 
130
+ /** Capture one non-interactive command without throwing on a nonzero exit. */
131
+ function captureCommand(
132
+ root: string,
133
+ command: string,
134
+ args: string[],
135
+ env: NodeJS.ProcessEnv,
136
+ ): string {
137
+ return (
138
+ exec
139
+ .spawnSync(command, args, {
140
+ cwd: root,
141
+ env,
142
+ stdout: "capture",
143
+ stderr: "ignore",
144
+ stdin: "ignore",
145
+ check: false,
146
+ })
147
+ .stdout?.trim() ?? ""
148
+ );
149
+ }
150
+
151
+ /** Wait for required checks and return the merged pull request commit. */
152
+ async function waitForPullRequestMerge(
153
+ root: string,
154
+ releaseBranch: string,
155
+ env: NodeJS.ProcessEnv,
156
+ timeoutMs: number,
157
+ ): Promise<string> {
158
+ await runWithTimeout(
159
+ root,
160
+ "gh",
161
+ ["pr", "checks", releaseBranch, "--watch", "--fail-fast", "--required", "--interval", "10"],
162
+ env,
163
+ timeoutMs,
164
+ );
165
+ const deadline = Date.now() + timeoutMs;
166
+ while (Date.now() < deadline) {
167
+ const state = captureCommand(
168
+ root,
169
+ "gh",
170
+ [
171
+ "pr",
172
+ "view",
173
+ releaseBranch,
174
+ "--json",
175
+ "state,mergeCommit",
176
+ "--jq",
177
+ '[.state, (.mergeCommit.oid // "")] | @tsv',
178
+ ],
179
+ env,
180
+ );
181
+ const [status, mergeSha] = state.split("\t");
182
+ if (status === "MERGED" && mergeSha) return mergeSha;
183
+ if (status === "CLOSED") throw new Error(`Release pull request closed without merging`);
184
+ await asyncModule.sleep(2_000);
185
+ }
186
+ throw new Error(`Release pull request did not merge within ${timeoutMs}ms`);
187
+ }
188
+
189
+ /** Find and watch the release workflow for an exact merged commit. */
190
+ async function waitForReleaseWorkflow(
191
+ root: string,
192
+ baseBranch: string,
193
+ mergeSha: string,
194
+ env: NodeJS.ProcessEnv,
195
+ timeoutMs: number,
196
+ ): Promise<void> {
197
+ const deadline = Date.now() + timeoutMs;
198
+ let runId = "";
199
+ while (Date.now() < deadline) {
200
+ runId = captureCommand(
201
+ root,
202
+ "gh",
203
+ [
204
+ "run",
205
+ "list",
206
+ "--workflow",
207
+ "release.yml",
208
+ "--branch",
209
+ baseBranch,
210
+ "--event",
211
+ "push",
212
+ "--commit",
213
+ mergeSha,
214
+ "--limit",
215
+ "1",
216
+ "--json",
217
+ "databaseId",
218
+ "--jq",
219
+ ".[0].databaseId // empty",
220
+ ],
221
+ env,
222
+ );
223
+ if (runId) break;
224
+ await asyncModule.sleep(5_000);
225
+ }
226
+ if (!runId) throw new Error(`Release workflow did not start within ${timeoutMs}ms`);
227
+ await runWithTimeout(root, "gh", ["run", "watch", runId, "--exit-status"], env, timeoutMs);
228
+ }
229
+
114
230
  function githubAccount(root: string): {
115
231
  hostname: string;
116
232
  owner: string;
117
233
  repository: string;
234
+ login: string;
118
235
  token: string;
119
236
  } {
120
237
  const repository = project.repositoryUrl(root);
121
238
  if (!repository) throw new Error("Release preparation requires a GitHub repository");
122
239
  const identity = githubRepositoryIdentity(repository);
123
- const token = exec
124
- .spawnSync("gh", githubTokenArguments(identity.hostname), {
240
+ const status = exec
241
+ .spawnSync("gh", ["auth", "status", "--hostname", identity.hostname, "--json", "hosts"], {
125
242
  cwd: root,
126
243
  stdout: "capture",
127
244
  stderr: "ignore",
128
245
  stdin: "ignore",
129
- check: true,
246
+ check: false,
130
247
  })
131
248
  .stdout?.trim();
132
- if (!token) {
133
- throw new Error(`No GitHub CLI authentication found for ${identity.hostname}`);
249
+ const accounts = githubAuthenticatedAccounts(status ?? "", identity.hostname);
250
+ for (const account of accounts) {
251
+ if (!githubAccountSupportsWorkflowChanges(account)) continue;
252
+ const token = exec
253
+ .spawnSync("gh", githubTokenArguments(identity.hostname, account.login), {
254
+ cwd: root,
255
+ stdout: "capture",
256
+ stderr: "ignore",
257
+ stdin: "ignore",
258
+ check: false,
259
+ })
260
+ .stdout?.trim();
261
+ if (!token) continue;
262
+ const tokenEnvironment = {
263
+ ...process.env,
264
+ GH_HOST: identity.hostname,
265
+ GH_REPO: githubRepositorySpecifier(identity),
266
+ [githubTokenEnvironmentName(identity.hostname)]: token,
267
+ };
268
+ const repositoryResult = exec.spawnSync("gh", ["api", githubRepositoryApiPath(identity)], {
269
+ cwd: root,
270
+ env: tokenEnvironment,
271
+ stdout: "capture",
272
+ stderr: "ignore",
273
+ stdin: "ignore",
274
+ check: false,
275
+ });
276
+ const repositoryData =
277
+ repositoryResult.exitCode === 0 ? json.parseRecord(repositoryResult.stdout ?? "") : undefined;
278
+ const permissions = object.isRecord(repositoryData?.permissions)
279
+ ? repositoryData.permissions
280
+ : undefined;
281
+ if (
282
+ permissions?.push !== true &&
283
+ permissions?.maintain !== true &&
284
+ permissions?.admin !== true
285
+ ) {
286
+ continue;
287
+ }
288
+ logger.info("selected GitHub CLI account", {
289
+ login: account.login,
290
+ repository: `${identity.owner}/${identity.repository}`,
291
+ });
292
+ return { ...identity, login: account.login, token };
134
293
  }
135
- return { ...identity, token };
294
+ const detected =
295
+ accounts
296
+ .map((account) => {
297
+ const workflow = githubAccountSupportsWorkflowChanges(account)
298
+ ? "workflow-capable"
299
+ : "missing workflow scope";
300
+ return `${account.login} (${workflow})`;
301
+ })
302
+ .join(", ") || "none";
303
+ throw new Error(
304
+ `No authenticated GitHub CLI account can write release workflow changes to ${identity.owner}/${identity.repository} on ${identity.hostname}; detected accounts: ${detected}`,
305
+ );
136
306
  }
137
307
 
138
308
  function releaseSummaryProviders(
@@ -174,8 +344,16 @@ program
174
344
  "--release-summary-providers <providers>",
175
345
  "comma-separated provider order: cursor,codex,claude",
176
346
  )
347
+ .option("--no-validate", "skip repository validation tasks, Rust tests, and TypeScript compile")
348
+ .option("--no-local-publish", "skip local npm, PyPI, and Cargo publication")
177
349
  .option("--no-local-cargo", "skip local Cargo publication")
178
- .option("--approve", "merge the release branch directly into the release base")
350
+ .option("--no-approve", "open the release pull request without enabling automatic merge")
351
+ .option("--no-wait", "return after enabling automatic merge without watching publication")
352
+ .option(
353
+ "--wait-timeout-minutes <minutes>",
354
+ "maximum time for pull request checks, merge, and publication",
355
+ "120",
356
+ )
179
357
  .action(
180
358
  async (opts: {
181
359
  level: VersionLevel;
@@ -190,14 +368,34 @@ program
190
368
  validateTask: string[];
191
369
  releaseSummary: boolean;
192
370
  releaseSummaryProviders?: string;
371
+ validate: boolean;
372
+ localPublish: boolean;
193
373
  localCargo: boolean;
194
374
  approve: boolean;
375
+ wait: boolean;
376
+ waitTimeoutMinutes: string;
195
377
  }) => {
196
378
  const root = project.root() ?? process.cwd();
379
+ const waitTimeoutMinutes = object.toNumber(opts.waitTimeoutMinutes, {
380
+ separators: false,
381
+ percent: false,
382
+ });
383
+ if (
384
+ waitTimeoutMinutes === undefined ||
385
+ !Number.isInteger(waitTimeoutMinutes) ||
386
+ waitTimeoutMinutes <= 0
387
+ ) {
388
+ throw new Error("--wait-timeout-minutes must be a positive integer");
389
+ }
390
+ const waitTimeoutMs = waitTimeoutMinutes * 60_000;
197
391
  const currentBranch = git(root, ["branch", "--show-current"], { capture: true });
198
392
  if (!currentBranch) throw new Error("Release preparation requires a local branch");
199
393
  const account = githubAccount(root);
200
394
  git(root, ["fetch", "--tags", "origin", opts.base]);
395
+ const comparisonBase = resolveBaseVersion(root, [opts.prefix], {
396
+ fetch: false,
397
+ includeComponentTags: false,
398
+ }).version;
201
399
  const next = resolveNextVersion(root, [opts.prefix], opts.level, { fetch: false });
202
400
  const releaseTag = `${opts.prefix}${next.version}`;
203
401
  const releaseBranch = `release/${releaseTag}`;
@@ -273,35 +471,40 @@ program
273
471
  throw new Error(`Release preparation did not produce ${next.version}`);
274
472
  }
275
473
 
276
- if (existsSync(join(releaseRoot, "Cargo.toml"))) {
277
- runIgnoringStdout(releaseRoot, "cargo", ["metadata", "--format-version", "1"]);
278
- }
279
474
  run(releaseRoot, process.execPath, [versionCheckScript]);
280
- for (const task of opts.validateTask) {
281
- run(releaseRoot, process.execPath, ["run", task]);
475
+ if (opts.validate) {
476
+ for (const task of opts.validateTask) {
477
+ run(releaseRoot, process.execPath, ["run", task]);
478
+ }
479
+ if (existsSync(join(releaseRoot, "Cargo.toml"))) {
480
+ run(releaseRoot, "cargo", [
481
+ "test",
482
+ "--workspace",
483
+ ...(existsSync(join(releaseRoot, "Cargo.lock")) ? ["--locked"] : []),
484
+ ]);
485
+ }
486
+ run(releaseRoot, process.execPath, ["run", "compile"]);
487
+ } else {
488
+ logger.warn("release validation skipped by --no-validate");
282
489
  }
283
- if (existsSync(join(releaseRoot, "Cargo.toml"))) {
284
- run(releaseRoot, "cargo", [
285
- "test",
286
- "--workspace",
287
- ...(existsSync(join(releaseRoot, "Cargo.lock")) ? ["--locked"] : []),
288
- ]);
490
+ if (opts.localPublish) {
491
+ await publishLocalRelease({
492
+ root: releaseRoot,
493
+ version: next.version,
494
+ localRegistry: opts.localRegistry,
495
+ localPypi: opts.localPypi,
496
+ pythonRoot: opts.pythonRoot,
497
+ localCargo: opts.localCargo,
498
+ });
499
+ } else {
500
+ logger.info("local publication skipped by --no-local-publish");
289
501
  }
290
- run(releaseRoot, process.execPath, ["run", "compile"]);
291
- await publishLocalRelease({
292
- root: releaseRoot,
293
- version: next.version,
294
- localRegistry: opts.localRegistry,
295
- localPypi: opts.localPypi,
296
- pythonRoot: opts.pythonRoot,
297
- localCargo: opts.localCargo,
298
- });
299
502
  run(releaseRoot, process.execPath, [versionCheckScript]);
300
503
  const releaseSummary = opts.releaseSummary
301
504
  ? await generateReleaseSummary({
302
505
  root: releaseRoot,
303
506
  version: next.version,
304
- fromRef: `${opts.prefix}${next.base}`,
507
+ fromRef: `${opts.prefix}${comparisonBase}`,
305
508
  providers: releaseSummaryProviders(opts.releaseSummaryProviders),
306
509
  })
307
510
  : undefined;
@@ -353,38 +556,29 @@ program
353
556
  githubEnvironment,
354
557
  );
355
558
  };
559
+ ensurePullRequest();
356
560
  if (opts.approve) {
357
- const merged = commandSucceeds(
561
+ run(root, "gh", ["pr", "merge", releaseBranch, "--auto", "--merge"], githubEnvironment);
562
+ }
563
+ git(root, ["worktree", "remove", "--force", releaseRoot]);
564
+ git(root, ["branch", "--delete", "--force", releaseBranch]);
565
+ if (opts.approve && opts.wait) {
566
+ const mergeSha = await waitForPullRequestMerge(
358
567
  root,
359
- "gh",
360
- [
361
- "api",
362
- "--method",
363
- "POST",
364
- `repos/${account.owner}/${account.repository}/merges`,
365
- "-f",
366
- `base=${opts.base}`,
367
- "-f",
368
- `head=${releaseBranch}`,
369
- "-f",
370
- `commit_message=Merge ${releaseTag}`,
371
- ],
568
+ releaseBranch,
372
569
  githubEnvironment,
570
+ waitTimeoutMs,
373
571
  );
374
- if (!merged) {
375
- ensurePullRequest();
376
- run(root, "gh", ["pr", "merge", releaseBranch, "--admin", "--merge"], githubEnvironment);
377
- }
378
- git(releaseRoot, ["push", "--no-verify", "origin", "--delete", releaseBranch]);
379
- git(root, ["fetch", "origin", opts.base]);
380
- git(root, ["merge", "--ff-only", `origin/${opts.base}`]);
381
- git(root, ["push", "origin", currentBranch]);
382
- } else {
383
- ensurePullRequest();
572
+ logger.info("release pull request merged", { releaseBranch, mergeSha });
573
+ await waitForReleaseWorkflow(root, opts.base, mergeSha, githubEnvironment, waitTimeoutMs);
574
+ logger.success(`published ${releaseTag} from ${mergeSha}`);
575
+ return;
384
576
  }
385
- git(root, ["worktree", "remove", "--force", releaseRoot]);
386
- git(root, ["branch", "--delete", "--force", releaseBranch]);
387
- logger.success(`${opts.approve ? "merged" : "opened"} ${releaseBranch} for ${releaseTag}`);
577
+ logger.success(
578
+ `${
579
+ opts.approve ? "enabled automatic merge for" : "opened"
580
+ } ${releaseBranch} for ${releaseTag}${opts.approve && !opts.wait ? " without waiting" : ""}`,
581
+ );
388
582
  },
389
583
  );
390
584
 
@@ -126,6 +126,42 @@ function eventDetail(event: Record<string, unknown>): string | undefined {
126
126
  return undefined;
127
127
  }
128
128
 
129
+ function eventToolName(event: Record<string, unknown>): string | undefined {
130
+ const item = object.isRecord(event.item) ? event.item : undefined;
131
+ for (const value of [event.tool_name, event.toolName, event.name, item?.tool_name, item?.name]) {
132
+ if (typeof value === "string") return string.trimToNull(value) ?? undefined;
133
+ }
134
+ const toolCall = object.isRecord(event.tool_call)
135
+ ? event.tool_call
136
+ : object.isRecord(event.toolCall)
137
+ ? event.toolCall
138
+ : undefined;
139
+ const wrapper = toolCall
140
+ ? Object.keys(toolCall).find(
141
+ (key) => key.endsWith("ToolCall") && object.isRecord(toolCall[key]),
142
+ )
143
+ : undefined;
144
+ return wrapper ? wrapper.slice(0, -"ToolCall".length) : undefined;
145
+ }
146
+
147
+ /** Normalize one provider event into the fields retained in release logs. */
148
+ export function releaseSummaryProviderEvent(event: Record<string, unknown>): {
149
+ type: string;
150
+ text?: string;
151
+ detail?: string;
152
+ tool?: string;
153
+ } {
154
+ const text = eventText(event);
155
+ const detail = eventDetail(event);
156
+ const tool = eventToolName(event);
157
+ return {
158
+ type: eventType(event),
159
+ ...(text ? { text } : {}),
160
+ ...(detail ? { detail } : {}),
161
+ ...(tool ? { tool } : {}),
162
+ };
163
+ }
164
+
129
165
  async function runProvider(
130
166
  provider: ReleaseSummaryProvider,
131
167
  root: string,
@@ -133,7 +169,15 @@ async function runProvider(
133
169
  ): Promise<string | undefined> {
134
170
  if (!capture(root, provider.command, [...provider.probeArgs])) return undefined;
135
171
  let summary: string | undefined;
172
+ let previousEventKey: string | undefined;
136
173
  const textOutput: string[] = [];
174
+ const logEvent = (event: Record<string, unknown>) => {
175
+ const fields = { provider: provider.name, ...event };
176
+ const key = JSON.stringify(fields);
177
+ if (key === previousEventKey) return;
178
+ previousEventKey = key;
179
+ logger.info("provider-event", fields);
180
+ };
137
181
  const child = exec.spawn(provider.command, provider.args(root, prompt), {
138
182
  cwd: root,
139
183
  stdout: [
@@ -144,19 +188,13 @@ async function runProvider(
144
188
  const text = string.trimToNull(line);
145
189
  if (text) {
146
190
  textOutput.push(text);
147
- logger.info("provider-event", { provider: provider.name, type: "text", text });
191
+ logEvent({ type: "text", text });
148
192
  }
149
193
  return;
150
194
  }
151
- const text = eventText(event);
152
- const detail = eventDetail(event);
153
- if (text) summary = text;
154
- logger.info("provider-event", {
155
- provider: provider.name,
156
- type: eventType(event),
157
- ...(text ? { text } : {}),
158
- ...(detail ? { detail } : {}),
159
- });
195
+ const fields = releaseSummaryProviderEvent(event);
196
+ if (fields.text) summary = fields.text;
197
+ logEvent(fields);
160
198
  },
161
199
  ],
162
200
  stderr: (line) => {