@dbx-tools/projen 0.6.230 → 0.8.0

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
@@ -194,6 +194,12 @@ archive content and repository identity, PyPI uses hash-aware existing-file
194
194
  behavior, Cargo checks existing versions, and GitHub assets upload to the
195
195
  component release.
196
196
 
197
+ Set `releaseSyncBranch` when a repository retains a long-lived source branch.
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.
202
+
197
203
  The Rust matrix has one row per target. Each row installs native dependencies,
198
204
  builds the Cargo workspace once, then packages every discovered output from
199
205
  that shared build. Set a source-only crate's or
@@ -441,18 +447,23 @@ a new package is covered without a re-synth. Work from the root:
441
447
  | `bun run release:bootstrap` | initialize writable Release Please state once |
442
448
  | `bun run release:plan` | emit the affected component and publication plan |
443
449
  | `bun run version:check` | verify every package against its owning release unit |
444
- | `bun run release` | refresh Release Please PRs, tags, and GitHub Releases |
445
-
446
- `release` runs the pinned Release Please library with the generated
447
- cross-language graph plugin. Direct changes retain their conventional semantic
448
- increment, while required dependents receive patches. The combined release PR
449
- is reconciled by Projen before review. Merging it creates component tags and
450
- GitHub Releases, then the affected plan selects publication jobs.
451
-
452
- Each changed component receives
453
- `docs/releases/<component>-v<version>.md`. Summary generation tries Cursor,
454
- Codex, and Claude in order, logs provider events, and falls back to Git. The
455
- committed file becomes that component's GitHub Release body.
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.
456
467
 
457
468
  The GitHub PR workflow runs the explicit `pr:validate` task through Projen's
458
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.230";
6
+ export const PACKAGE_VERSION = "0.8.0";
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
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "main": "index.ts",
52
52
  "license": "Apache-2.0",
53
- "version": "0.6.230",
53
+ "version": "0.8.0",
54
54
  "types": "index.ts",
55
55
  "type": "module",
56
56
  "exports": {
package/src/project-js.ts CHANGED
@@ -634,6 +634,8 @@ export type DBXToolsJavaScriptProjectOptions = CommonProjectOptions &
634
634
  readonly releaseValidationTasks?: readonly string[];
635
635
  /** Optional AI-generated release summary. Defaults to enabled. */
636
636
  readonly releaseSummary?: boolean | ReleaseSummaryOptions;
637
+ /** Existing source branch safely fast-forwarded after an independent release. */
638
+ readonly releaseSyncBranch?: string | false;
637
639
  /** Set to `false` to omit normal npm workspace publication. */
638
640
  readonly nodeRelease?: boolean;
639
641
  /** Unified dbx-tools release workflow, or no release surface. Defaults to `dbx-tools`. */
@@ -1601,6 +1603,7 @@ function initProject(
1601
1603
  pythonRoot: options.releasePythonRoot,
1602
1604
  validationTasks: options.releaseValidationTasks,
1603
1605
  summary: options.releaseSummary,
1606
+ syncBranch: options.releaseSyncBranch,
1604
1607
  });
1605
1608
  }
1606
1609
  }
package/src/release.ts CHANGED
@@ -56,6 +56,12 @@ export interface DBXToolsReleaseOptions {
56
56
  readonly pythonRoot?: string;
57
57
  /** Repository task names run in the release worktree before expensive validation and publication. */
58
58
  readonly validationTasks?: readonly string[];
59
+ /**
60
+ * Existing source branch safely fast-forwarded after publication.
61
+ *
62
+ * Missing or diverged branches are left untouched.
63
+ */
64
+ readonly syncBranch?: string | false;
59
65
  /**
60
66
  * Generate a versioned release summary locally. Defaults to enabled with the
61
67
  * standard Cursor, Codex, Claude fallback order.
@@ -389,7 +395,7 @@ function independentReleasePleaseJob(project: DBXToolsJavaScriptProject, branch:
389
395
  id: "changes",
390
396
  shell: "bash",
391
397
  run: [
392
- "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",
393
399
  ' echo "docs_changed=false" >> "$GITHUB_OUTPUT"',
394
400
  "else",
395
401
  ' echo "docs_changed=true" >> "$GITHUB_OUTPUT"',
@@ -438,6 +444,86 @@ function independentReleasePleaseJob(project: DBXToolsJavaScriptProject, branch:
438
444
  };
439
445
  }
440
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.READ,
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=$(git branch --show-current)" >> "$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
+ ' PR="$(gh pr create --head "$SOURCE_BRANCH" --base "$BASE_BRANCH" --title "$TITLE" --body "$BODY" --json number --jq .number)"',
519
+ "fi",
520
+ 'if [ "$APPROVE" = "true" ]; then gh pr merge "$PR" --auto --merge; fi',
521
+ ].join("\n"),
522
+ },
523
+ ],
524
+ });
525
+ }
526
+
441
527
  function independentReleasePlanJob(project: DBXToolsJavaScriptProject): Job {
442
528
  return {
443
529
  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') }}",
@@ -557,10 +643,28 @@ function independentReleaseNotesJob(project: DBXToolsJavaScriptProject): Job {
557
643
  "jq -c '.units[]' dist/release-plan.json | while read -r UNIT; do",
558
644
  ' COMPONENT="$(jq -r .component <<<"$UNIT")"',
559
645
  ' VERSION="$(jq -r .newVersion <<<"$UNIT")"',
560
- ' gh release edit "$COMPONENT-v$VERSION" --notes-file "docs/releases/$COMPONENT-v$VERSION.md"',
646
+ ' gh release edit "$COMPONENT-v$VERSION" --notes-file ".release-notes/final/$COMPONENT-v$VERSION.md"',
561
647
  "done",
562
648
  ].join("\n"),
563
649
  },
650
+ {
651
+ name: "Remove consumed release notes",
652
+ shell: "bash",
653
+ run: [
654
+ `git fetch origin ${JSON.stringify(projectReleaseBranch(project))}`,
655
+ `if [ "$(git rev-parse origin/${projectReleaseBranch(project)})" != "$(git rev-parse HEAD)" ]; then`,
656
+ ' echo "::warning::main advanced before release-note cleanup; leaving temporary notes for the next release"',
657
+ " exit 0",
658
+ "fi",
659
+ "git rm -r --ignore-unmatch .release-notes",
660
+ "if ! git diff --cached --quiet; then",
661
+ ' git config user.name "github-actions[bot]"',
662
+ ' git config user.email "41898282+github-actions[bot]@users.noreply.github.com"',
663
+ ' git commit -m "chore: remove consumed release notes"',
664
+ ` git push origin "HEAD:${projectReleaseBranch(project)}"`,
665
+ "fi",
666
+ ].join("\n"),
667
+ },
564
668
  ],
565
669
  };
566
670
  }
@@ -571,7 +675,7 @@ function addIndependentDocsJobs(
571
675
  options: ReleaseDocsOptions,
572
676
  ): void {
573
677
  workflow.addJob("build-docs", {
574
- if: "${{ needs.release-plan.outputs.docs == 'true' && needs.publication-complete.result == 'success' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'docs') }}",
678
+ if: "${{ always() && needs['release-plan'].outputs.docs == 'true' && needs['publication-complete'].result == 'success' && (github.event_name == 'push' || inputs.stage == 'all' || inputs.stage == 'docs') }}",
575
679
  needs: ["release-plan", "publication-complete"],
576
680
  runsOn: ["ubuntu-latest"],
577
681
  permissions: {
@@ -617,6 +721,49 @@ function addIndependentDocsJobs(
617
721
  });
618
722
  }
619
723
 
724
+ function addIndependentBranchSyncJob(workflow: GithubWorkflow, branch: string): void {
725
+ workflow.addJob("sync-release-branch", {
726
+ if: "${{ always() && github.event_name == 'push' && needs['publication-complete'].result == 'success' }}",
727
+ needs: ["publication-complete"],
728
+ runsOn: ["ubuntu-latest"],
729
+ permissions: { contents: JobPermission.WRITE },
730
+ steps: [
731
+ {
732
+ name: "Checkout released main",
733
+ uses: "actions/checkout@v6",
734
+ with: { "fetch-depth": 0 },
735
+ },
736
+ {
737
+ name: `Safely sync ${branch}`,
738
+ shell: "bash",
739
+ env: { SOURCE_BRANCH: branch },
740
+ run: [
741
+ 'if ! git ls-remote --exit-code --heads origin "refs/heads/$SOURCE_BRANCH" >/dev/null 2>&1; then',
742
+ ' echo "source branch $SOURCE_BRANCH no longer exists; nothing to sync"',
743
+ " exit 0",
744
+ "fi",
745
+ 'git fetch origin "refs/heads/$SOURCE_BRANCH:refs/remotes/origin/$SOURCE_BRANCH"',
746
+ 'if git merge-base --is-ancestor "origin/$SOURCE_BRANCH" HEAD; then',
747
+ ' git push origin "HEAD:refs/heads/$SOURCE_BRANCH"',
748
+ 'elif git merge-base --is-ancestor HEAD "origin/$SOURCE_BRANCH"; then',
749
+ ' echo "source branch $SOURCE_BRANCH already contains released main"',
750
+ "else",
751
+ ' git config user.name "github-actions[bot]"',
752
+ ' git config user.email "41898282+github-actions[bot]@users.noreply.github.com"',
753
+ ' git switch --force-create "$SOURCE_BRANCH" "origin/$SOURCE_BRANCH"',
754
+ ' if git merge --no-ff --no-edit "$GITHUB_SHA"; then',
755
+ ' git push origin "HEAD:refs/heads/$SOURCE_BRANCH"',
756
+ " else",
757
+ " git merge --abort",
758
+ ' echo "::warning::source branch $SOURCE_BRANCH has merge conflicts with released main; leaving it untouched"',
759
+ " fi",
760
+ "fi",
761
+ ].join("\n"),
762
+ },
763
+ ],
764
+ });
765
+ }
766
+
620
767
  class IndependentReleaseFinalizer extends Component {
621
768
  private finalized = false;
622
769
 
@@ -624,6 +771,7 @@ class IndependentReleaseFinalizer extends Component {
624
771
  project: DBXToolsJavaScriptProject,
625
772
  private readonly workflow: GithubWorkflow,
626
773
  private readonly docs: ReleaseDocsOptions | undefined,
774
+ private readonly syncBranch: string | undefined,
627
775
  ) {
628
776
  super(project);
629
777
  }
@@ -645,6 +793,9 @@ class IndependentReleaseFinalizer extends Component {
645
793
  if (this.docs) {
646
794
  addIndependentDocsJobs(this.workflow, this.project as DBXToolsJavaScriptProject, this.docs);
647
795
  }
796
+ if (this.syncBranch) {
797
+ addIndependentBranchSyncJob(this.workflow, this.syncBranch);
798
+ }
648
799
  }
649
800
  }
650
801
 
@@ -665,9 +816,14 @@ function configureIndependentRelease(
665
816
  description: "Build the affected release plan",
666
817
  },
667
818
  release: {
819
+ exec: taskScript(project, "release-request.ts", `--base ${JSON.stringify(branch)}`),
820
+ receiveArgs: true,
821
+ description: "Commit, annotate, and push a source release request",
822
+ },
823
+ "release:refresh": {
668
824
  exec: taskScript(project, "release-please.ts", `--target-branch ${JSON.stringify(branch)}`),
669
825
  receiveArgs: true,
670
- description: "Refresh independent release PRs, tags, and GitHub Releases",
826
+ description: "Refresh Release Please PRs, tags, and GitHub Releases",
671
827
  },
672
828
  });
673
829
  if (!project.github) return;
@@ -713,7 +869,8 @@ function configureIndependentRelease(
713
869
  workflow.addJob("publish-node", independentNodePublishJob(project));
714
870
  registerIndependentPublicationJob(workflow, "publish-node");
715
871
  }
716
- new IndependentReleaseFinalizer(project, workflow, options.docs);
872
+ new IndependentReleaseFinalizer(project, workflow, options.docs, options.syncBranch || undefined);
873
+ configureReleaseRequestWorkflow(project, branch);
717
874
  releaseWorkflows.set(project, workflow);
718
875
  }
719
876
 
@@ -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
- fromRef: options.fromRef,
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
+ }
@@ -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 = releaseSummaryFile(options.version, options.component);
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 result = await selectReleaseSummary(
310
- options.root,
311
- summaryPrompt(options.version, options.fromRef, commits, changedFiles, diffStat),
312
- options.runner,
313
- options.providers,
314
- );
315
- const summary = result?.summary ?? gitSummary(options.version, commits, changedFiles);
316
- const provider = result?.provider ?? "git";
317
- if (!result) logger.info("AI providers unavailable; using Git release summary");
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);