@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 +45 -61
- package/index.ts +2 -2
- package/package.json +4 -4
- package/src/project-rs.ts +171 -13
- package/src/release-catalog.ts +8 -5
- package/src/release-github.ts +52 -3
- package/src/release.ts +6 -7
- package/src/workspace-version.ts +40 -9
- package/tasks/release-pr.ts +254 -60
- package/tasks/release-summary.ts +48 -10
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
|
441
|
-
|
|
|
442
|
-
| `bun run build`
|
|
443
|
-
| `bun run compile`
|
|
444
|
-
| `bun run test`
|
|
445
|
-
| `bun run sync`
|
|
446
|
-
| `bun run barrels`
|
|
447
|
-
| `bun run
|
|
448
|
-
| `bun run
|
|
449
|
-
| `bun run
|
|
450
|
-
| `bun run release`
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
`
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
no
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
`
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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.
|
|
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.
|
|
30
|
-
"@dbx-tools/path": "^0.
|
|
31
|
-
"@dbx-tools/shared-core": "^0.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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(
|
|
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,
|
package/src/release-catalog.ts
CHANGED
|
@@ -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
|
|
package/src/release-github.ts
CHANGED
|
@@ -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
|
-
/**
|
|
23
|
-
export
|
|
24
|
-
|
|
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 --
|
|
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:
|
|
986
|
+
description:
|
|
987
|
+
"Prepare, validate, locally publish, and automatically merge a release PR",
|
|
989
988
|
},
|
|
990
989
|
}
|
|
991
990
|
: {}),
|
package/src/workspace-version.ts
CHANGED
|
@@ -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
|
|
142
|
-
* remote is unreachable or no matching tag exists.
|
|
143
|
-
* effort) so a release made elsewhere is respected; a
|
|
144
|
-
* the local tag list is used, and callers fall back to
|
|
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
|
-
{
|
|
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
|
|
155
|
-
|
|
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];
|
package/tasks/release-pr.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
|
124
|
-
.spawnSync("gh",
|
|
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:
|
|
246
|
+
check: false,
|
|
130
247
|
})
|
|
131
248
|
.stdout?.trim();
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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", "
|
|
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
|
-
|
|
281
|
-
|
|
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 (
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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}${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
package/tasks/release-summary.ts
CHANGED
|
@@ -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
|
-
|
|
191
|
+
logEvent({ type: "text", text });
|
|
148
192
|
}
|
|
149
193
|
return;
|
|
150
194
|
}
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
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) => {
|