@dbx-tools/projen 0.7.0 → 0.8.1
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 +21 -15
- package/index.ts +1 -1
- package/package.json +1 -1
- package/src/project-js.ts +30 -2
- package/src/release.ts +152 -9
- package/tasks/release-request.ts +187 -0
- package/tasks/release-summary-units.ts +41 -3
- package/tasks/release-summary.ts +19 -10
package/README.md
CHANGED
|
@@ -195,9 +195,10 @@ behavior, Cargo checks existing versions, and GitHub assets upload to the
|
|
|
195
195
|
component release.
|
|
196
196
|
|
|
197
197
|
Set `releaseSyncBranch` when a repository retains a long-lived source branch.
|
|
198
|
-
After publication the workflow fast-forwards
|
|
199
|
-
|
|
200
|
-
|
|
198
|
+
After publication the workflow fast-forwards a branch that is behind, skips a
|
|
199
|
+
branch that already contains released `main`, and merges `main` into a diverged
|
|
200
|
+
branch when the merge is conflict-free. Missing branches and conflicted merges
|
|
201
|
+
are left untouched.
|
|
201
202
|
|
|
202
203
|
The Rust matrix has one row per target. Each row installs native dependencies,
|
|
203
204
|
builds the Cargo workspace once, then packages every discovered output from
|
|
@@ -446,18 +447,23 @@ a new package is covered without a re-synth. Work from the root:
|
|
|
446
447
|
| `bun run release:bootstrap` | initialize writable Release Please state once |
|
|
447
448
|
| `bun run release:plan` | emit the affected component and publication plan |
|
|
448
449
|
| `bun run version:check` | verify every package against its owning release unit |
|
|
449
|
-
| `bun run release` |
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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 request workflow creates or updates
|
|
458
|
+
the source PR. `--approve` enables auto-merge after checks.
|
|
459
|
+
|
|
460
|
+
After the source PR merges, Release Please retains direct conventional semantic
|
|
461
|
+
increments and applies required dependent patches. Reconciliation copies the
|
|
462
|
+
readable request Markdown into one temporary
|
|
463
|
+
`.release-notes/final/<component>-v<version>.md` per affected component and
|
|
464
|
+
deletes the consumed request file in the release PR. Publication applies each
|
|
465
|
+
file to its matching GitHub Release, then removes the temporary notes tree in a
|
|
466
|
+
cleanup commit. Request and final notes never count as source changes.
|
|
461
467
|
|
|
462
468
|
The GitHub PR workflow runs the explicit `pr:validate` task through Projen's
|
|
463
469
|
public `BuildWorkflow` `buildTask` option. That task runs synth plus the
|
package/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Hand edits are overwritten on the next watch; this file is read-only.
|
|
4
4
|
|
|
5
5
|
export const PACKAGE_IDENTIFIER = "@dbx-tools/projen";
|
|
6
|
-
export const PACKAGE_VERSION = "0.
|
|
6
|
+
export const PACKAGE_VERSION = "0.8.1";
|
|
7
7
|
export * as barrels from "./src/barrels.ts";
|
|
8
8
|
export * as bunApp from "./src/bun-app.ts";
|
|
9
9
|
export * as bunWorkflow from "./src/bun-workflow.ts";
|
package/package.json
CHANGED
package/src/project-js.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { type IConstruct } from "constructs";
|
|
|
15
15
|
import { Component, IgnoreFile, Project, type TaskOptions, javascript, typescript } from "projen";
|
|
16
16
|
import { BuildWorkflow } from "projen/lib/build";
|
|
17
17
|
import { AutoMerge } from "projen/lib/github";
|
|
18
|
-
import { JobPermission, type JobStep } from "projen/lib/github/workflows-model";
|
|
18
|
+
import { JobPermission, type JobStep, type Triggers } from "projen/lib/github/workflows-model";
|
|
19
19
|
import type { ReleaseProjectOptions } from "projen/lib/release";
|
|
20
20
|
import { mixin } from "..";
|
|
21
21
|
import { generateBarrels } from "./barrels.ts";
|
|
@@ -943,7 +943,35 @@ function configureBuildWorkflow(
|
|
|
943
943
|
});
|
|
944
944
|
validation.exec("bunx projen default");
|
|
945
945
|
validation.exec("bun run compile");
|
|
946
|
-
const configured =
|
|
946
|
+
const configured = {
|
|
947
|
+
...options.buildWorkflowOptions,
|
|
948
|
+
...(!options.buildWorkflowOptions?.workflowTriggers &&
|
|
949
|
+
project.releaseCatalog.mode === "independent"
|
|
950
|
+
? {
|
|
951
|
+
workflowTriggers: {
|
|
952
|
+
pullRequest: {
|
|
953
|
+
types: ["opened", "synchronize", "reopened", "closed"],
|
|
954
|
+
paths: [
|
|
955
|
+
"**",
|
|
956
|
+
"!.projen/release-units.json",
|
|
957
|
+
"!.release-please-manifest.json",
|
|
958
|
+
"!.release-units/**",
|
|
959
|
+
"!.release-notes/**",
|
|
960
|
+
"!docs/releases/**",
|
|
961
|
+
"!Cargo.lock",
|
|
962
|
+
"!**/Cargo.toml",
|
|
963
|
+
"!**/package.json",
|
|
964
|
+
"!**/pyproject.toml",
|
|
965
|
+
"!**/index.ts",
|
|
966
|
+
"!packages/js/node/rust-binary/src/_rust-release-binaries.ts",
|
|
967
|
+
"!packages/js/node/appkit-graphiti/src/_python-runtime.ts",
|
|
968
|
+
],
|
|
969
|
+
},
|
|
970
|
+
workflowDispatch: {},
|
|
971
|
+
} satisfies Triggers,
|
|
972
|
+
}
|
|
973
|
+
: {}),
|
|
974
|
+
};
|
|
947
975
|
const compatibility = nodeWorkflowCompatibility(project);
|
|
948
976
|
const configuredRunner = configured.runsOn !== undefined || configured.runsOnGroup !== undefined;
|
|
949
977
|
const workflow = new BuildWorkflow(project, {
|
package/src/release.ts
CHANGED
|
@@ -395,7 +395,7 @@ function independentReleasePleaseJob(project: DBXToolsJavaScriptProject, branch:
|
|
|
395
395
|
id: "changes",
|
|
396
396
|
shell: "bash",
|
|
397
397
|
run: [
|
|
398
|
-
"if git diff --quiet HEAD^ HEAD -- README.md AGENTS.md ':(glob)**/README.md' docs; then",
|
|
398
|
+
"if git diff --quiet HEAD^ HEAD -- README.md AGENTS.md ':(glob)**/README.md' docs ':(exclude)docs/releases/**' ':(exclude).release-notes/**'; then",
|
|
399
399
|
' echo "docs_changed=false" >> "$GITHUB_OUTPUT"',
|
|
400
400
|
"else",
|
|
401
401
|
' echo "docs_changed=true" >> "$GITHUB_OUTPUT"',
|
|
@@ -444,6 +444,87 @@ function independentReleasePleaseJob(project: DBXToolsJavaScriptProject, branch:
|
|
|
444
444
|
};
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
+
function configureReleaseRequestWorkflow(
|
|
448
|
+
project: DBXToolsJavaScriptProject,
|
|
449
|
+
baseBranch: string,
|
|
450
|
+
): void {
|
|
451
|
+
if (!project.github) return;
|
|
452
|
+
const workflow = new GithubWorkflow(project.github, "release-request", {
|
|
453
|
+
fileName: "release-request.yml",
|
|
454
|
+
limitConcurrency: true,
|
|
455
|
+
concurrencyOptions: {
|
|
456
|
+
group: "release-request-${{ github.ref_name }}",
|
|
457
|
+
cancelInProgress: false,
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
workflow.runName = "release request ${{ github.ref_name }}";
|
|
461
|
+
workflow.on({ push: {} });
|
|
462
|
+
workflow.file?.addOverride("permissions.contents", "read");
|
|
463
|
+
workflow.addJob("request", {
|
|
464
|
+
if: `\${{ github.ref_name != '${baseBranch}' }}`,
|
|
465
|
+
runsOn: ["ubuntu-latest"],
|
|
466
|
+
permissions: {
|
|
467
|
+
contents: JobPermission.WRITE,
|
|
468
|
+
pullRequests: JobPermission.WRITE,
|
|
469
|
+
},
|
|
470
|
+
steps: [
|
|
471
|
+
{
|
|
472
|
+
name: "Checkout source branch",
|
|
473
|
+
uses: "actions/checkout@v6",
|
|
474
|
+
with: { "fetch-depth": 2 },
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
name: "Read release request",
|
|
478
|
+
id: "request",
|
|
479
|
+
shell: "bash",
|
|
480
|
+
run: [
|
|
481
|
+
'MESSAGE="$(git log -1 --format=%B)"',
|
|
482
|
+
'if ! grep -q "^Release-Request: true$" <<<"$MESSAGE"; then',
|
|
483
|
+
' echo "requested=false" >> "$GITHUB_OUTPUT"',
|
|
484
|
+
" exit 0",
|
|
485
|
+
"fi",
|
|
486
|
+
`git fetch origin ${JSON.stringify(baseBranch)}`,
|
|
487
|
+
`if git diff --quiet "origin/${baseBranch}...HEAD"; then`,
|
|
488
|
+
' echo "requested=false" >> "$GITHUB_OUTPUT"',
|
|
489
|
+
" exit 0",
|
|
490
|
+
"fi",
|
|
491
|
+
'echo "requested=true" >> "$GITHUB_OUTPUT"',
|
|
492
|
+
'echo "branch=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"',
|
|
493
|
+
'echo "approve=$(sed -n \'s/^Release-Approve: //p\' <<<"$MESSAGE" | tail -1)" >> "$GITHUB_OUTPUT"',
|
|
494
|
+
'echo "notes_path=$(sed -n \'s/^Release-Notes-Path: //p\' <<<"$MESSAGE" | tail -1)" >> "$GITHUB_OUTPUT"',
|
|
495
|
+
'echo "title=$(git log -1 --skip=1 --format=%s)" >> "$GITHUB_OUTPUT"',
|
|
496
|
+
].join("\n"),
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
name: "Create or update source pull request",
|
|
500
|
+
if: "${{ steps.request.outputs.requested == 'true' }}",
|
|
501
|
+
env: {
|
|
502
|
+
GH_TOKEN: "${{ github.token }}",
|
|
503
|
+
SOURCE_BRANCH: "${{ steps.request.outputs.branch }}",
|
|
504
|
+
BASE_BRANCH: baseBranch,
|
|
505
|
+
TITLE: "${{ steps.request.outputs.title }}",
|
|
506
|
+
NOTES_PATH: "${{ steps.request.outputs.notes_path }}",
|
|
507
|
+
APPROVE: "${{ steps.request.outputs.approve }}",
|
|
508
|
+
},
|
|
509
|
+
shell: "bash",
|
|
510
|
+
run: [
|
|
511
|
+
'test -f "$NOTES_PATH"',
|
|
512
|
+
'NOTES="$(cat "$NOTES_PATH")"',
|
|
513
|
+
'BODY="$(printf \'## Release notes\\n\\n%s\\n\' "$NOTES")"',
|
|
514
|
+
'PR="$(gh pr list --head "$SOURCE_BRANCH" --base "$BASE_BRANCH" --state open --json number --jq \'.[0].number // empty\')"',
|
|
515
|
+
'if [ -n "$PR" ]; then',
|
|
516
|
+
' gh pr edit "$PR" --title "$TITLE" --body "$BODY"',
|
|
517
|
+
"else",
|
|
518
|
+
' gh pr create --head "$SOURCE_BRANCH" --base "$BASE_BRANCH" --title "$TITLE" --body "$BODY"',
|
|
519
|
+
' PR="$(gh pr list --head "$SOURCE_BRANCH" --base "$BASE_BRANCH" --state open --json number --jq \'.[0].number\')"',
|
|
520
|
+
"fi",
|
|
521
|
+
'if [ "$APPROVE" = "true" ]; then gh pr merge "$PR" --auto --merge; fi',
|
|
522
|
+
].join("\n"),
|
|
523
|
+
},
|
|
524
|
+
],
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
447
528
|
function independentReleasePlanJob(project: DBXToolsJavaScriptProject): Job {
|
|
448
529
|
return {
|
|
449
530
|
if: "${{ always() && (github.event_name == 'workflow_dispatch' || needs.release-please.outputs.releases_created == 'true' || needs.release-please.outputs.docs_changed == 'true' || needs.release-please.outputs.recovery_requested == 'true') }}",
|
|
@@ -563,10 +644,28 @@ function independentReleaseNotesJob(project: DBXToolsJavaScriptProject): Job {
|
|
|
563
644
|
"jq -c '.units[]' dist/release-plan.json | while read -r UNIT; do",
|
|
564
645
|
' COMPONENT="$(jq -r .component <<<"$UNIT")"',
|
|
565
646
|
' VERSION="$(jq -r .newVersion <<<"$UNIT")"',
|
|
566
|
-
' gh release edit "$COMPONENT-v$VERSION" --notes-file "
|
|
647
|
+
' gh release edit "$COMPONENT-v$VERSION" --notes-file ".release-notes/final/$COMPONENT-v$VERSION.md"',
|
|
567
648
|
"done",
|
|
568
649
|
].join("\n"),
|
|
569
650
|
},
|
|
651
|
+
{
|
|
652
|
+
name: "Remove consumed release notes",
|
|
653
|
+
shell: "bash",
|
|
654
|
+
run: [
|
|
655
|
+
`git fetch origin ${JSON.stringify(projectReleaseBranch(project))}`,
|
|
656
|
+
`if [ "$(git rev-parse origin/${projectReleaseBranch(project)})" != "$(git rev-parse HEAD)" ]; then`,
|
|
657
|
+
' echo "::warning::main advanced before release-note cleanup; leaving temporary notes for the next release"',
|
|
658
|
+
" exit 0",
|
|
659
|
+
"fi",
|
|
660
|
+
"git rm -r --ignore-unmatch .release-notes",
|
|
661
|
+
"if ! git diff --cached --quiet; then",
|
|
662
|
+
' git config user.name "github-actions[bot]"',
|
|
663
|
+
' git config user.email "41898282+github-actions[bot]@users.noreply.github.com"',
|
|
664
|
+
' git commit -m "chore: remove consumed release notes"',
|
|
665
|
+
` git push origin "HEAD:${projectReleaseBranch(project)}"`,
|
|
666
|
+
"fi",
|
|
667
|
+
].join("\n"),
|
|
668
|
+
},
|
|
570
669
|
],
|
|
571
670
|
};
|
|
572
671
|
}
|
|
@@ -577,7 +676,7 @@ function addIndependentDocsJobs(
|
|
|
577
676
|
options: ReleaseDocsOptions,
|
|
578
677
|
): void {
|
|
579
678
|
workflow.addJob("build-docs", {
|
|
580
|
-
if: "${{ needs
|
|
679
|
+
if: "${{ always() && needs['release-plan'].outputs.docs == 'true' && needs['publication-complete'].result == 'success' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'docs') }}",
|
|
581
680
|
needs: ["release-plan", "publication-complete"],
|
|
582
681
|
runsOn: ["ubuntu-latest"],
|
|
583
682
|
permissions: {
|
|
@@ -623,20 +722,29 @@ function addIndependentDocsJobs(
|
|
|
623
722
|
});
|
|
624
723
|
}
|
|
625
724
|
|
|
626
|
-
function addIndependentBranchSyncJob(
|
|
725
|
+
function addIndependentBranchSyncJob(
|
|
726
|
+
workflow: GithubWorkflow,
|
|
727
|
+
project: DBXToolsJavaScriptProject,
|
|
728
|
+
branch: string,
|
|
729
|
+
): void {
|
|
627
730
|
workflow.addJob("sync-release-branch", {
|
|
628
|
-
if: "${{ github.event_name == 'push' && needs
|
|
731
|
+
if: "${{ always() && github.event_name == 'push' && needs['publication-complete'].result == 'success' }}",
|
|
629
732
|
needs: ["publication-complete"],
|
|
630
733
|
runsOn: ["ubuntu-latest"],
|
|
631
734
|
permissions: { contents: JobPermission.WRITE },
|
|
735
|
+
env: { BUN_VERSION },
|
|
632
736
|
steps: [
|
|
633
737
|
{
|
|
634
738
|
name: "Checkout released main",
|
|
635
739
|
uses: "actions/checkout@v6",
|
|
636
740
|
with: { "fetch-depth": 0 },
|
|
637
741
|
},
|
|
742
|
+
...bunCacheRestoreSteps(project, {
|
|
743
|
+
ignorePaths: project.workflowCacheIgnorePaths,
|
|
744
|
+
}),
|
|
745
|
+
{ name: "Install dependencies", run: "bun install" },
|
|
638
746
|
{
|
|
639
|
-
name: `Safely
|
|
747
|
+
name: `Safely sync ${branch}`,
|
|
640
748
|
shell: "bash",
|
|
641
749
|
env: { SOURCE_BRANCH: branch },
|
|
642
750
|
run: [
|
|
@@ -647,8 +755,33 @@ function addIndependentBranchSyncJob(workflow: GithubWorkflow, branch: string):
|
|
|
647
755
|
'git fetch origin "refs/heads/$SOURCE_BRANCH:refs/remotes/origin/$SOURCE_BRANCH"',
|
|
648
756
|
'if git merge-base --is-ancestor "origin/$SOURCE_BRANCH" HEAD; then',
|
|
649
757
|
' git push origin "HEAD:refs/heads/$SOURCE_BRANCH"',
|
|
758
|
+
'elif git merge-base --is-ancestor HEAD "origin/$SOURCE_BRANCH"; then',
|
|
759
|
+
' echo "source branch $SOURCE_BRANCH already contains released main"',
|
|
650
760
|
"else",
|
|
651
|
-
'
|
|
761
|
+
' git config user.name "github-actions[bot]"',
|
|
762
|
+
' git config user.email "41898282+github-actions[bot]@users.noreply.github.com"',
|
|
763
|
+
' git switch --force-create "$SOURCE_BRANCH" "origin/$SOURCE_BRANCH"',
|
|
764
|
+
' if git merge --no-ff --no-edit "$GITHUB_SHA"; then',
|
|
765
|
+
' git push origin "HEAD:refs/heads/$SOURCE_BRANCH"',
|
|
766
|
+
" else",
|
|
767
|
+
' SAFE_GENERATED="true"',
|
|
768
|
+
" while IFS= read -r FILE; do",
|
|
769
|
+
' case "$FILE" in',
|
|
770
|
+
" .projen/release-units.json|.release-please-manifest.json|.release-units/*/source.json|.release-units/*/version.txt|.release-units/*/CHANGELOG.md|Cargo.lock|*/Cargo.toml|*/package.json|*/pyproject.toml|*/index.ts) ;;",
|
|
771
|
+
' *) SAFE_GENERATED="false" ;;',
|
|
772
|
+
" esac",
|
|
773
|
+
" done < <(git diff --name-only --diff-filter=U)",
|
|
774
|
+
' if [ "$SAFE_GENERATED" = "true" ] && bunx projen; then',
|
|
775
|
+
" git add -A",
|
|
776
|
+
' if [ -z "$(git diff --name-only --diff-filter=U)" ]; then',
|
|
777
|
+
" git commit --no-edit",
|
|
778
|
+
' git push origin "HEAD:refs/heads/$SOURCE_BRANCH"',
|
|
779
|
+
" exit 0",
|
|
780
|
+
" fi",
|
|
781
|
+
" fi",
|
|
782
|
+
" git merge --abort",
|
|
783
|
+
' echo "::warning::source branch $SOURCE_BRANCH has handwritten or unresolved conflicts with released main; leaving it untouched"',
|
|
784
|
+
" fi",
|
|
652
785
|
"fi",
|
|
653
786
|
].join("\n"),
|
|
654
787
|
},
|
|
@@ -686,7 +819,11 @@ class IndependentReleaseFinalizer extends Component {
|
|
|
686
819
|
addIndependentDocsJobs(this.workflow, this.project as DBXToolsJavaScriptProject, this.docs);
|
|
687
820
|
}
|
|
688
821
|
if (this.syncBranch) {
|
|
689
|
-
addIndependentBranchSyncJob(
|
|
822
|
+
addIndependentBranchSyncJob(
|
|
823
|
+
this.workflow,
|
|
824
|
+
this.project as DBXToolsJavaScriptProject,
|
|
825
|
+
this.syncBranch,
|
|
826
|
+
);
|
|
690
827
|
}
|
|
691
828
|
}
|
|
692
829
|
}
|
|
@@ -708,9 +845,14 @@ function configureIndependentRelease(
|
|
|
708
845
|
description: "Build the affected release plan",
|
|
709
846
|
},
|
|
710
847
|
release: {
|
|
848
|
+
exec: taskScript(project, "release-request.ts", `--base ${JSON.stringify(branch)}`),
|
|
849
|
+
receiveArgs: true,
|
|
850
|
+
description: "Commit, annotate, and push a source release request",
|
|
851
|
+
},
|
|
852
|
+
"release:refresh": {
|
|
711
853
|
exec: taskScript(project, "release-please.ts", `--target-branch ${JSON.stringify(branch)}`),
|
|
712
854
|
receiveArgs: true,
|
|
713
|
-
description: "Refresh
|
|
855
|
+
description: "Refresh Release Please PRs, tags, and GitHub Releases",
|
|
714
856
|
},
|
|
715
857
|
});
|
|
716
858
|
if (!project.github) return;
|
|
@@ -757,6 +899,7 @@ function configureIndependentRelease(
|
|
|
757
899
|
registerIndependentPublicationJob(workflow, "publish-node");
|
|
758
900
|
}
|
|
759
901
|
new IndependentReleaseFinalizer(project, workflow, options.docs, options.syncBranch || undefined);
|
|
902
|
+
configureReleaseRequestWorkflow(project, branch);
|
|
760
903
|
releaseWorkflows.set(project, workflow);
|
|
761
904
|
}
|
|
762
905
|
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env -S bun
|
|
2
|
+
/** Commit and push a source branch for the generated release-request workflow. */
|
|
3
|
+
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, resolve } from "node:path";
|
|
6
|
+
import { exec, project } from "@dbx-tools/core";
|
|
7
|
+
import { log, string } from "@dbx-tools/shared-core";
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
import { selectReleaseSummary } from "./release-summary.ts";
|
|
10
|
+
|
|
11
|
+
const logger = log.logger("projen:release-request");
|
|
12
|
+
|
|
13
|
+
export interface PrepareReleaseRequestOptions {
|
|
14
|
+
readonly root: string;
|
|
15
|
+
readonly baseBranch: string;
|
|
16
|
+
readonly approve?: boolean;
|
|
17
|
+
readonly message?: string;
|
|
18
|
+
readonly notes?: string;
|
|
19
|
+
readonly notesFile?: string;
|
|
20
|
+
readonly push?: boolean;
|
|
21
|
+
readonly synthesize?: boolean;
|
|
22
|
+
readonly generateNotes?: (root: string, range: string) => Promise<string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Commit real changes, attach notes to one request commit, and push the branch. */
|
|
26
|
+
export async function prepareReleaseRequest(
|
|
27
|
+
options: PrepareReleaseRequestOptions,
|
|
28
|
+
): Promise<boolean> {
|
|
29
|
+
const root = resolve(options.root);
|
|
30
|
+
const branch = capture(root, ["branch", "--show-current"]);
|
|
31
|
+
if (!branch) throw new Error("Release requests require a named source branch");
|
|
32
|
+
if (options.push !== false) {
|
|
33
|
+
run(root, ["fetch", "origin", options.baseBranch]);
|
|
34
|
+
}
|
|
35
|
+
const baseRef = options.push === false ? options.baseBranch : `origin/${options.baseBranch}`;
|
|
36
|
+
if (options.synthesize !== false) {
|
|
37
|
+
exec.spawnSync(process.execPath, ["x", "projen"], {
|
|
38
|
+
cwd: root,
|
|
39
|
+
stdout: "inherit",
|
|
40
|
+
stderr: "inherit",
|
|
41
|
+
stdin: "ignore",
|
|
42
|
+
check: true,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const dirty = capture(root, ["status", "--porcelain"]);
|
|
46
|
+
if (dirty) {
|
|
47
|
+
const message = options.message ?? "fix: prepare release";
|
|
48
|
+
if (!isReleasableMessage(message)) {
|
|
49
|
+
throw new Error("Release commit message must use fix, feat, or a breaking-change marker");
|
|
50
|
+
}
|
|
51
|
+
run(root, ["add", "-A"]);
|
|
52
|
+
run(root, ["commit", "-m", message]);
|
|
53
|
+
}
|
|
54
|
+
const ahead = Number(capture(root, ["rev-list", "--count", `${baseRef}..HEAD`]));
|
|
55
|
+
if (!Number.isInteger(ahead) || ahead < 1) {
|
|
56
|
+
logger.info("no changes from release base; skipping release request", {
|
|
57
|
+
branch,
|
|
58
|
+
base: options.baseBranch,
|
|
59
|
+
});
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
const messages = capture(root, ["log", "--format=%B%x00", `${baseRef}..HEAD`]);
|
|
63
|
+
if (!messages.split("\0").some(isReleasableMessage)) {
|
|
64
|
+
logger.info("branch has no releasable conventional commits; skipping release request", {
|
|
65
|
+
branch,
|
|
66
|
+
});
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
const latest = capture(root, ["log", "-1", "--format=%B"]);
|
|
70
|
+
if (/^Release-Request:\s*true$/m.test(latest)) {
|
|
71
|
+
logger.info("release request already exists at branch head; skipping", { branch });
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (branch === options.baseBranch) {
|
|
75
|
+
throw new Error(`Release requests must run from a branch other than ${options.baseBranch}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const customNotes = string.trimToNull(
|
|
79
|
+
options.notesFile ? readFileSync(resolve(root, options.notesFile), "utf8") : options.notes,
|
|
80
|
+
);
|
|
81
|
+
const notes =
|
|
82
|
+
customNotes ??
|
|
83
|
+
(await (options.generateNotes ?? generateRequestNotes)(root, `${baseRef}..HEAD`));
|
|
84
|
+
const notesPath = `.release-notes/requests/${branch.replace(/[^a-zA-Z0-9-]+/g, "-")}.md`;
|
|
85
|
+
const absoluteNotesPath = resolve(root, notesPath);
|
|
86
|
+
mkdirSync(dirname(absoluteNotesPath), { recursive: true });
|
|
87
|
+
writeFileSync(absoluteNotesPath, `# Release request: ${branch}\n\n${notes.trim()}\n`);
|
|
88
|
+
run(root, ["add", notesPath]);
|
|
89
|
+
run(root, [
|
|
90
|
+
"commit",
|
|
91
|
+
"-m",
|
|
92
|
+
"chore: request release",
|
|
93
|
+
"-m",
|
|
94
|
+
[
|
|
95
|
+
"Release-Request: true",
|
|
96
|
+
`Release-Source-Branch: ${branch}`,
|
|
97
|
+
`Release-Approve: ${options.approve === true}`,
|
|
98
|
+
`Release-Notes-Path: ${notesPath}`,
|
|
99
|
+
].join("\n"),
|
|
100
|
+
]);
|
|
101
|
+
if (options.push !== false) run(root, ["push", "--set-upstream", "origin", branch]);
|
|
102
|
+
logger.success("release request pushed", { branch, approve: options.approve === true });
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether one conventional commit can produce a Release Please increment. */
|
|
107
|
+
export function isReleasableMessage(message: string): boolean {
|
|
108
|
+
return (
|
|
109
|
+
/^(?:fix|feat)(?:\([^)\r\n]+\))?!?:/m.test(message) || /^BREAKING[ -]CHANGE:/m.test(message)
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function generateRequestNotes(root: string, range: string): Promise<string> {
|
|
114
|
+
const commits = capture(root, ["log", "--no-merges", "--format=%s%n%b", range]);
|
|
115
|
+
const files = capture(root, ["diff", "--name-status", range, "--", "."]);
|
|
116
|
+
const result = await selectReleaseSummary(
|
|
117
|
+
root,
|
|
118
|
+
[
|
|
119
|
+
"Write concise release-request notes for the reviewed changes below.",
|
|
120
|
+
"Focus on user-visible behavior and important compatibility details.",
|
|
121
|
+
"",
|
|
122
|
+
"Commits:",
|
|
123
|
+
commits,
|
|
124
|
+
"",
|
|
125
|
+
"Files:",
|
|
126
|
+
files,
|
|
127
|
+
].join("\n"),
|
|
128
|
+
);
|
|
129
|
+
if (result?.summary.trim()) return result.summary.trim();
|
|
130
|
+
const subjects = capture(root, ["log", "--no-merges", "--format=- %s", range]);
|
|
131
|
+
return subjects || "Release source changes are ready for review.";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function capture(root: string, args: string[]): string {
|
|
135
|
+
return (
|
|
136
|
+
exec
|
|
137
|
+
.spawnSync("git", args, {
|
|
138
|
+
cwd: root,
|
|
139
|
+
stdout: "capture",
|
|
140
|
+
stderr: "inherit",
|
|
141
|
+
stdin: "ignore",
|
|
142
|
+
check: true,
|
|
143
|
+
})
|
|
144
|
+
.stdout?.trim() ?? ""
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function run(root: string, args: string[]): void {
|
|
149
|
+
exec.spawnSync("git", args, {
|
|
150
|
+
cwd: root,
|
|
151
|
+
stdout: "inherit",
|
|
152
|
+
stderr: "inherit",
|
|
153
|
+
stdin: "ignore",
|
|
154
|
+
check: true,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (import.meta.main) {
|
|
159
|
+
await new Command()
|
|
160
|
+
.option("--base <branch>", "release target branch", "main")
|
|
161
|
+
.option("--approve", "enable merge-queue or auto-merge for the source PR")
|
|
162
|
+
.option("--message <message>", "commit message for uncommitted source changes")
|
|
163
|
+
.option("--notes <notes>", "custom release notes")
|
|
164
|
+
.option("--notes-file <path>", "read custom release notes from a file")
|
|
165
|
+
.action(
|
|
166
|
+
async (options: {
|
|
167
|
+
base: string;
|
|
168
|
+
approve?: boolean;
|
|
169
|
+
message?: string;
|
|
170
|
+
notes?: string;
|
|
171
|
+
notesFile?: string;
|
|
172
|
+
}) => {
|
|
173
|
+
if (options.notes && options.notesFile) {
|
|
174
|
+
throw new Error("Use --notes or --notes-file, not both");
|
|
175
|
+
}
|
|
176
|
+
await prepareReleaseRequest({
|
|
177
|
+
root: project.root() ?? process.cwd(),
|
|
178
|
+
baseBranch: options.base,
|
|
179
|
+
approve: options.approve,
|
|
180
|
+
message: options.message,
|
|
181
|
+
notes: options.notes,
|
|
182
|
+
notesFile: options.notesFile,
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
)
|
|
186
|
+
.parseAsync();
|
|
187
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env -S bun
|
|
2
2
|
/** Generate component-qualified release notes from an affected release plan. */
|
|
3
3
|
|
|
4
|
-
import { readFileSync } from "node:fs";
|
|
5
|
-
import { resolve } from "node:path";
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { exec } from "@dbx-tools/core";
|
|
6
7
|
import { log } from "@dbx-tools/shared-core";
|
|
7
8
|
import { Command } from "commander";
|
|
8
9
|
import { generateReleaseSummary } from "./release-summary.ts";
|
|
@@ -23,22 +24,59 @@ if (import.meta.main) {
|
|
|
23
24
|
const graph = JSON.parse(
|
|
24
25
|
readFileSync(resolve(root, ".projen/release-units.json"), "utf8"),
|
|
25
26
|
) as ReleaseUnitGraph;
|
|
27
|
+
const request = requestNotes(root);
|
|
26
28
|
for (const planned of plan.units) {
|
|
27
29
|
const unit = graph.units.find((candidate) => candidate.id === planned.id);
|
|
28
30
|
if (!unit) throw new Error(`Release plan references unknown unit ${planned.id}`);
|
|
29
31
|
const paths = graph.projects
|
|
30
32
|
.filter((project) => project.unit === unit.id)
|
|
31
33
|
.map((project) => project.path);
|
|
34
|
+
const componentTag = `${planned.component}-v${planned.oldVersion}`;
|
|
35
|
+
const fromRef = gitCapture(root, ["rev-parse", "--verify", componentTag])
|
|
36
|
+
? componentTag
|
|
37
|
+
: options.fromRef;
|
|
32
38
|
await generateReleaseSummary({
|
|
33
39
|
root,
|
|
34
40
|
component: planned.component,
|
|
35
41
|
version: planned.newVersion,
|
|
36
42
|
paths,
|
|
37
|
-
|
|
43
|
+
customSummary: request.summary,
|
|
44
|
+
outputFile: `.release-notes/final/${planned.component}-v${planned.newVersion}.md`,
|
|
45
|
+
fromRef,
|
|
38
46
|
toRef: options.toRef,
|
|
39
47
|
});
|
|
40
48
|
}
|
|
49
|
+
for (const path of request.paths) rmSync(path);
|
|
41
50
|
logger.success(`generated ${plan.units.length} component release summaries`);
|
|
42
51
|
})
|
|
43
52
|
.parseAsync();
|
|
44
53
|
}
|
|
54
|
+
|
|
55
|
+
function requestNotes(root: string): { summary?: string; paths: string[] } {
|
|
56
|
+
const directory = resolve(root, ".release-notes/requests");
|
|
57
|
+
if (!existsSync(directory)) return { paths: [] };
|
|
58
|
+
const paths = readdirSync(directory)
|
|
59
|
+
.filter((name) => name.endsWith(".md"))
|
|
60
|
+
.sort()
|
|
61
|
+
.map((name) => join(directory, name));
|
|
62
|
+
const summary = paths
|
|
63
|
+
.map((path) =>
|
|
64
|
+
readFileSync(path, "utf8")
|
|
65
|
+
.replace(/^# .+\n+/, "")
|
|
66
|
+
.trim(),
|
|
67
|
+
)
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.join("\n\n");
|
|
70
|
+
return { ...(summary ? { summary } : {}), paths };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function gitCapture(root: string, args: string[]): string {
|
|
74
|
+
const result = exec.spawnSync("git", args, {
|
|
75
|
+
cwd: root,
|
|
76
|
+
stdout: "capture",
|
|
77
|
+
stderr: "ignore",
|
|
78
|
+
stdin: "ignore",
|
|
79
|
+
check: false,
|
|
80
|
+
});
|
|
81
|
+
return result.exitCode === 0 ? (result.stdout?.trim() ?? "") : "";
|
|
82
|
+
}
|
package/tasks/release-summary.ts
CHANGED
|
@@ -265,12 +265,15 @@ export async function generateReleaseSummary(options: {
|
|
|
265
265
|
readonly version: string;
|
|
266
266
|
readonly component?: string;
|
|
267
267
|
readonly paths?: readonly string[];
|
|
268
|
+
readonly customSummary?: string;
|
|
269
|
+
readonly outputFile?: string;
|
|
268
270
|
readonly fromRef?: string;
|
|
269
271
|
readonly toRef?: string;
|
|
270
272
|
readonly providers?: readonly ReleaseSummaryProviderName[];
|
|
271
273
|
readonly runner?: ReleaseSummaryRunner;
|
|
272
274
|
}): Promise<string | undefined> {
|
|
273
|
-
const relativeOutput =
|
|
275
|
+
const relativeOutput =
|
|
276
|
+
options.outputFile ?? releaseSummaryFile(options.version, options.component);
|
|
274
277
|
const output = join(options.root, relativeOutput);
|
|
275
278
|
if (existsSync(output)) rmSync(output);
|
|
276
279
|
const target =
|
|
@@ -306,15 +309,21 @@ export async function generateReleaseSummary(options: {
|
|
|
306
309
|
...scopedPaths,
|
|
307
310
|
`:(exclude)${relativeOutput}`,
|
|
308
311
|
]);
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
312
|
+
const customSummary = string.trimToNull(options.customSummary);
|
|
313
|
+
const result = customSummary
|
|
314
|
+
? undefined
|
|
315
|
+
: await selectReleaseSummary(
|
|
316
|
+
options.root,
|
|
317
|
+
summaryPrompt(options.version, options.fromRef, commits, changedFiles, diffStat),
|
|
318
|
+
options.runner,
|
|
319
|
+
options.providers,
|
|
320
|
+
);
|
|
321
|
+
const summary =
|
|
322
|
+
customSummary ?? result?.summary ?? gitSummary(options.version, commits, changedFiles);
|
|
323
|
+
const provider = customSummary ? "custom" : (result?.provider ?? "git");
|
|
324
|
+
if (!customSummary && !result) {
|
|
325
|
+
logger.info("AI providers unavailable; using Git release summary");
|
|
326
|
+
}
|
|
318
327
|
const content = `# Release ${options.component ? `${options.component} ` : ""}${options.version}\n\n${summary.trim()}\n`;
|
|
319
328
|
mkdirSync(dirname(output), { recursive: true });
|
|
320
329
|
writeFileSync(output, content);
|