versionary 1.0.1 → 1.2.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.
@@ -1,14 +1,16 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { loadConfig } from "../config/load-config.js";
5
6
  import { ensureGitIdentity } from "../git/identity.js";
6
7
  import { getScmClient } from "../scm/client.js";
7
8
  import { resolvePackageStrategyContext, resolveReleaseName, } from "../strategy/package-context.js";
8
9
  import { applyConfiguredArtifactRules } from "./artifact-rules.js";
9
- import { extractUnreleasedNotes, prependChangelog, renderReleaseNotesSection, renderReleasePlanChangelog, renderReviewRequestFooter, } from "./changelog.js";
10
+ import { extractUnreleasedNotes, prependChangelog, renderReleaseNotesSection, renderReleasePlanChangelog, renderReviewRequestFooter, renderRNewsReleaseNotes, } from "./changelog.js";
11
+ import { buildInitialReleaseCohorts, resolveSeparateReleaseBranch, resolveSeparateReleaseBranchPrefix, stabilizeReleaseCohorts, } from "./cohorts.js";
10
12
  import { createReleasePlan, getChangelogDefaults, resolvePackageDependencies, } from "./plan.js";
11
- import { getBaselineStatePath, writeBaselineSha, } from "./state.js";
13
+ import { hasFullyUntaggedPendingRelease, readPendingReleaseCohorts, readPendingReleaseTargets, writeBaselineSha, writePackageReleaseState, } from "./state.js";
12
14
  const SAFE_DIRTY_FILES = new Set([
13
15
  "pnpm-lock.yaml",
14
16
  "package-lock.json",
@@ -132,15 +134,21 @@ function fetchRemoteReleaseBranch(cwd, branch) {
132
134
  return remoteRef;
133
135
  }
134
136
  function buildReleaseTargets(cwd, plan, loadedConfig) {
137
+ const releasingPaths = new Set(plan.packages?.filter((pkg) => pkg.nextVersion).map((pkg) => pkg.path) ?? [
138
+ ".",
139
+ ]);
135
140
  const releaseTargets = plan.packages
136
141
  ? plan.packages
137
142
  .filter((pkg) => pkg.nextVersion)
138
143
  .map((pkg) => {
144
+ const dependencies = (pkg.dependencySourcePaths ?? []).filter((dependencyPath) => releasingPaths.has(dependencyPath));
145
+ const releaseDependencies = dependencies.length > 0 ? { dependencies } : {};
139
146
  if (pkg.path === ".") {
140
147
  return {
141
148
  path: pkg.path,
142
149
  version: pkg.nextVersion ?? "",
143
150
  tag: `v${pkg.nextVersion ?? ""}`,
151
+ ...releaseDependencies,
144
152
  };
145
153
  }
146
154
  const packageConfig = loadedConfig.packages?.[pkg.path] ?? {};
@@ -151,6 +159,7 @@ function buildReleaseTargets(cwd, plan, loadedConfig) {
151
159
  path: pkg.path,
152
160
  version: pkg.nextVersion ?? "",
153
161
  tag: `${tagPrefix}-v${pkg.nextVersion ?? ""}`,
162
+ ...releaseDependencies,
154
163
  };
155
164
  })
156
165
  : [
@@ -196,6 +205,158 @@ function formatReleaseCommitTitle(releaseTargets) {
196
205
  }
197
206
  return `chore(release): ${tags[0]} (+${tags.length - 1} more)`;
198
207
  }
208
+ function getCommitSubject(cwd, revision) {
209
+ return execFileSync("git", ["show", "-s", "--format=%s", revision], {
210
+ cwd,
211
+ encoding: "utf8",
212
+ stdio: ["ignore", "pipe", "ignore"],
213
+ }).trim();
214
+ }
215
+ function getFirstParent(cwd, revision) {
216
+ return execFileSync("git", ["rev-parse", `${revision}^`], {
217
+ cwd,
218
+ encoding: "utf8",
219
+ stdio: ["ignore", "pipe", "ignore"],
220
+ }).trim();
221
+ }
222
+ export function renderPendingReleaseReviewRequestBody(targets) {
223
+ const releases = targets
224
+ .map((target) => `- \`${target.tag}\` (${target.path})`)
225
+ .join("\n");
226
+ return `## Pending release recovery\n\nThis PR retries a release that did not reach the publish stage. Its empty release commit records the corrected CI-tested commit as the release target; it does not advance any versions.\n\n${releases}\n\n${renderReviewRequestFooter()}`;
227
+ }
228
+ /**
229
+ * Recreate the release marker on the corrected base without advancing an
230
+ * untagged version. The empty commit is intentional: the version and
231
+ * changelog changes were already reviewed in the original release PR.
232
+ */
233
+ export function preparePendingReleasePr(cwd = process.cwd(), options = {}) {
234
+ const targets = readPendingReleaseTargets(cwd);
235
+ if (!hasFullyUntaggedPendingRelease(cwd)) {
236
+ throw new Error("No unpublished pending release found to recover.");
237
+ }
238
+ ensureCleanWorktree(cwd, options.logger);
239
+ const loaded = loadConfig(cwd);
240
+ const branch = loaded.config["release-branch"] ?? "versionary/release";
241
+ const title = formatReleaseCommitTitle(targets);
242
+ const releaseBaselineSha = execFileSync("git", ["rev-parse", "HEAD"], {
243
+ cwd,
244
+ encoding: "utf8",
245
+ stdio: ["ignore", "pipe", "ignore"],
246
+ }).trim();
247
+ const hasRemoteReleaseBranch = remoteReleaseBranchExists(cwd, branch);
248
+ const remoteReleaseRef = hasRemoteReleaseBranch
249
+ ? fetchRemoteReleaseBranch(cwd, branch)
250
+ : null;
251
+ execFileSync("git", ["checkout", "-B", branch], {
252
+ cwd,
253
+ stdio: ["ignore", "pipe", "ignore"],
254
+ });
255
+ ensureGitIdentity(cwd);
256
+ execFileSync("git", ["commit", "--allow-empty", "-m", title, "-m", VERSIONARY_RELEASE_TRAILER], { cwd, stdio: ["ignore", "pipe", "ignore"] });
257
+ const updated = !remoteReleaseRef ||
258
+ getFirstParent(cwd, remoteReleaseRef) !== releaseBaselineSha ||
259
+ getCommitSubject(cwd, remoteReleaseRef) !== title;
260
+ return {
261
+ branch,
262
+ title,
263
+ updated,
264
+ targets,
265
+ body: renderPendingReleaseReviewRequestBody(targets),
266
+ };
267
+ }
268
+ /**
269
+ * Recreate each unpublished package cohort on its own corrected-base branch.
270
+ * A temporary worktree keeps the caller on the trunk commit that triggered
271
+ * recovery while still leaving local branch refs available for pushing.
272
+ */
273
+ export function preparePendingSeparateReleasePrs(cwd = process.cwd(), options = {}) {
274
+ const loaded = loadConfig(cwd);
275
+ if (!loaded.config["separate-release-prs"]) {
276
+ throw new Error('preparePendingSeparateReleasePrs requires "separate-release-prs" to be enabled.');
277
+ }
278
+ const cohorts = readPendingReleaseCohorts(cwd);
279
+ if (cohorts.length === 0) {
280
+ throw new Error("No unpublished pending release found to recover.");
281
+ }
282
+ const candidates = cohorts.map((cohort) => {
283
+ const title = formatReleaseCommitTitle(cohort.targets);
284
+ return {
285
+ branch: cohort.branch,
286
+ title,
287
+ updated: true,
288
+ packagePaths: cohort.targets.map((target) => target.path),
289
+ targets: cohort.targets,
290
+ body: renderPendingReleaseReviewRequestBody(cohort.targets),
291
+ };
292
+ });
293
+ if (options["dry-run"]) {
294
+ return candidates;
295
+ }
296
+ ensureCleanWorktree(cwd, options.logger);
297
+ const baselineSha = execFileSync("git", ["rev-parse", "HEAD"], {
298
+ cwd,
299
+ encoding: "utf8",
300
+ stdio: ["ignore", "pipe", "ignore"],
301
+ }).trim();
302
+ const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "versionary-recovery-worktree-"));
303
+ const worktree = path.join(temporaryRoot, "worktree");
304
+ execFileSync("git", ["worktree", "add", "--detach", worktree, baselineSha], {
305
+ cwd,
306
+ stdio: ["ignore", "pipe", "ignore"],
307
+ });
308
+ try {
309
+ for (const candidate of candidates) {
310
+ resetTemporaryWorktree(worktree, baselineSha);
311
+ const remoteRef = remoteReleaseBranchExists(cwd, candidate.branch)
312
+ ? fetchRemoteReleaseBranch(cwd, candidate.branch)
313
+ : null;
314
+ execFileSync("git", ["checkout", "-B", candidate.branch, baselineSha], {
315
+ cwd: worktree,
316
+ stdio: ["ignore", "pipe", "ignore"],
317
+ });
318
+ ensureGitIdentity(worktree);
319
+ execFileSync("git", [
320
+ "commit",
321
+ "--allow-empty",
322
+ "-m",
323
+ candidate.title,
324
+ "-m",
325
+ VERSIONARY_RELEASE_TRAILER,
326
+ ], { cwd: worktree, stdio: ["ignore", "pipe", "ignore"] });
327
+ candidate.updated =
328
+ !remoteRef ||
329
+ getFirstParent(worktree, remoteRef) !== baselineSha ||
330
+ getCommitSubject(worktree, remoteRef) !== candidate.title;
331
+ resetTemporaryWorktree(worktree, baselineSha);
332
+ }
333
+ return candidates;
334
+ }
335
+ finally {
336
+ // Cleanup is best effort: a throwing `worktree remove` must neither replace
337
+ // the error we may be unwinding with nor skip the prune that drops the
338
+ // stale administrative entry.
339
+ try {
340
+ execFileSync("git", ["worktree", "remove", "--force", worktree], {
341
+ cwd,
342
+ stdio: ["ignore", "pipe", "ignore"],
343
+ });
344
+ }
345
+ catch {
346
+ // Ignored; the prune below still clears the registration.
347
+ }
348
+ try {
349
+ execFileSync("git", ["worktree", "prune"], {
350
+ cwd,
351
+ stdio: ["ignore", "pipe", "ignore"],
352
+ });
353
+ }
354
+ catch {
355
+ // Ignored.
356
+ }
357
+ fs.rmSync(temporaryRoot, { recursive: true, force: true });
358
+ }
359
+ }
199
360
  export function prepareReleasePr(cwd = process.cwd(), options = {}) {
200
361
  const plan = createReleasePlan(cwd);
201
362
  const loaded = loadConfig(cwd);
@@ -272,7 +433,7 @@ export function prepareReleasePr(cwd = process.cwd(), options = {}) {
272
433
  }
273
434
  const packageConfig = loaded.config.packages?.[packagePlan.path] ?? {};
274
435
  const packageContext = resolvePackageStrategyContext(loaded.config, packagePlan.path, packageConfig);
275
- const { changelogFile: packageChangelogFile } = getChangelogDefaults({
436
+ const { changelogFile: packageChangelogFile, changelogFormat: packageChangelogFormat, } = getChangelogDefaults({
276
437
  "release-type": packageConfig["release-type"] ?? loaded.config["release-type"],
277
438
  "changelog-file": packageConfig["changelog-file"] ?? loaded.config["changelog-file"],
278
439
  "changelog-format": packageConfig["changelog-format"] ?? loaded.config["changelog-format"],
@@ -283,17 +444,25 @@ export function prepareReleasePr(cwd = process.cwd(), options = {}) {
283
444
  continue;
284
445
  }
285
446
  const packageChangelogPath = path.posix.join(packagePlan.path, packageChangelogFile);
286
- const packageHighlights = readChangelogHighlights(cwd, packageChangelogPath, "markdown-changelog");
287
- const packageSection = renderReleaseNotesSection({
288
- currentVersion: packagePlan.currentVersion,
289
- nextVersion: packagePlan.nextVersion,
290
- commits: packagePlan.commits,
291
- tagPrefix: packageMetadata.tagPrefix,
292
- cwd,
293
- dependencies: resolvePackageDependencies(plan, packagePlan.path),
294
- highlights: packageHighlights,
295
- });
296
- prependChangelog(cwd, packageChangelogPath, packageSection, "markdown-changelog");
447
+ const packageHighlights = readChangelogHighlights(cwd, packageChangelogPath, packageChangelogFormat);
448
+ const packageSection = packageChangelogFormat === "r-news"
449
+ ? renderRNewsReleaseNotes({
450
+ packageName: packageMetadata.releaseName,
451
+ nextVersion: packagePlan.nextVersion,
452
+ commits: packagePlan.commits,
453
+ cwd,
454
+ highlights: packageHighlights,
455
+ })
456
+ : renderReleaseNotesSection({
457
+ currentVersion: packagePlan.currentVersion,
458
+ nextVersion: packagePlan.nextVersion,
459
+ commits: packagePlan.commits,
460
+ tagPrefix: packageMetadata.tagPrefix,
461
+ cwd,
462
+ dependencies: resolvePackageDependencies(plan, packagePlan.path),
463
+ highlights: packageHighlights,
464
+ });
465
+ prependChangelog(cwd, packageChangelogPath, packageSection, packageChangelogFormat);
297
466
  updatedChangelogFiles.push(packageChangelogPath);
298
467
  }
299
468
  const releaseTargets = buildReleaseTargets(cwd, plan, loaded.config);
@@ -323,8 +492,8 @@ export function prepareReleasePr(cwd = process.cwd(), options = {}) {
323
492
  cwd,
324
493
  stdio: ["ignore", "pipe", "ignore"],
325
494
  });
326
- writeBaselineSha(cwd, releaseBaselineSha, releaseTargets);
327
- execFileSync("git", ["add", getBaselineStatePath(cwd)], {
495
+ const updatedStateFiles = writeBaselineSha(cwd, releaseBaselineSha, releaseTargets);
496
+ execFileSync("git", ["add", ...updatedStateFiles], {
328
497
  cwd,
329
498
  stdio: ["ignore", "pipe", "ignore"],
330
499
  });
@@ -345,8 +514,280 @@ export function prepareReleasePr(cwd = process.cwd(), options = {}) {
345
514
  highlights,
346
515
  };
347
516
  }
517
+ function scopeReleasePlan(plan, packagePaths) {
518
+ const selected = new Set(packagePaths);
519
+ const packages = (plan.packages ?? []).map((pkg) => selected.has(pkg.path)
520
+ ? pkg
521
+ : {
522
+ ...pkg,
523
+ releaseType: null,
524
+ nextVersion: null,
525
+ bumpReason: undefined,
526
+ dependencySourcePaths: undefined,
527
+ });
528
+ const releasing = packages.filter((pkg) => pkg.nextVersion);
529
+ const first = releasing[0];
530
+ return {
531
+ ...plan,
532
+ releaseType: first?.releaseType ?? null,
533
+ currentVersion: first?.currentVersion ?? plan.currentVersion,
534
+ nextVersion: first?.nextVersion ?? null,
535
+ commits: releasing.flatMap((pkg) => pkg.commits),
536
+ packages,
537
+ };
538
+ }
539
+ function materializeCandidateChanges(cwd, plan, options) {
540
+ const loaded = loadConfig(cwd);
541
+ const finalizeContext = {
542
+ releaseCommitSha: options.baselineSha,
543
+ releaseDate: resolveCommitDate(cwd, options.baselineSha),
544
+ };
545
+ const updatedVersionFiles = [];
546
+ const writesByStrategy = new Map();
547
+ for (const packagePlan of plan.packages ?? []) {
548
+ if (!packagePlan.nextVersion) {
549
+ continue;
550
+ }
551
+ const packageConfig = loaded.config.packages?.[packagePlan.path] ?? {};
552
+ const packageContext = resolvePackageStrategyContext(loaded.config, packagePlan.path, packageConfig);
553
+ updatedVersionFiles.push(...packageContext.strategy.writeVersion(cwd, packageContext.config, packagePlan.nextVersion));
554
+ const existing = writesByStrategy.get(packageContext.strategy.name);
555
+ const write = {
556
+ packagePath: packagePlan.path,
557
+ versionFile: packageContext.versionFile,
558
+ version: packagePlan.nextVersion,
559
+ };
560
+ if (existing) {
561
+ existing.writes.push(write);
562
+ }
563
+ else {
564
+ writesByStrategy.set(packageContext.strategy.name, {
565
+ strategy: packageContext.strategy,
566
+ writes: [write],
567
+ });
568
+ }
569
+ }
570
+ for (const strategyGroup of writesByStrategy.values()) {
571
+ updatedVersionFiles.push(...(strategyGroup.strategy.finalizeVersionWrites?.(cwd, strategyGroup.writes, finalizeContext) ?? []));
572
+ }
573
+ const updatedArtifactFiles = applyConfiguredArtifactRules(cwd, loaded.config, plan);
574
+ const packageReleaseMetadata = buildPackageReleaseMetadata(cwd, plan, loaded.config);
575
+ const rootIsReleasing = Boolean(plan.packages?.find((pkg) => pkg.path === ".")?.nextVersion);
576
+ const highlights = rootIsReleasing
577
+ ? resolveReleaseHighlights(cwd, plan.changelogFile, plan.changelogFormat)
578
+ .highlights
579
+ : "";
580
+ const updatedChangelogFiles = [];
581
+ const rootSection = rootIsReleasing
582
+ ? renderReleasePlanChangelog(plan, { highlights, cwd })
583
+ : "";
584
+ if (rootSection.length > 0) {
585
+ prependChangelog(cwd, plan.changelogFile, rootSection, plan.changelogFormat);
586
+ updatedChangelogFiles.push(plan.changelogFile);
587
+ }
588
+ for (const packagePlan of plan.packages ?? []) {
589
+ if (!packagePlan.nextVersion || packagePlan.path === ".") {
590
+ continue;
591
+ }
592
+ const packageConfig = loaded.config.packages?.[packagePlan.path] ?? {};
593
+ const packageContext = resolvePackageStrategyContext(loaded.config, packagePlan.path, packageConfig);
594
+ const { changelogFile: packageChangelogFile, changelogFormat: packageChangelogFormat, } = getChangelogDefaults({
595
+ "release-type": packageConfig["release-type"] ?? loaded.config["release-type"],
596
+ "changelog-file": packageConfig["changelog-file"] ?? loaded.config["changelog-file"],
597
+ "changelog-format": packageConfig["changelog-format"] ?? loaded.config["changelog-format"],
598
+ defaultChangelogFormat: packageContext.strategy.getDefaultChangelogFormat?.(),
599
+ });
600
+ const metadata = packageReleaseMetadata[packagePlan.path];
601
+ if (!metadata) {
602
+ continue;
603
+ }
604
+ const packageChangelogPath = path.posix.join(packagePlan.path, packageChangelogFile);
605
+ const packageHighlights = readChangelogHighlights(cwd, packageChangelogPath, packageChangelogFormat);
606
+ const section = packageChangelogFormat === "r-news"
607
+ ? renderRNewsReleaseNotes({
608
+ packageName: metadata.releaseName,
609
+ nextVersion: packagePlan.nextVersion,
610
+ commits: packagePlan.commits,
611
+ cwd,
612
+ highlights: packageHighlights,
613
+ })
614
+ : renderReleaseNotesSection({
615
+ currentVersion: packagePlan.currentVersion,
616
+ nextVersion: packagePlan.nextVersion,
617
+ commits: packagePlan.commits,
618
+ tagPrefix: metadata.tagPrefix,
619
+ cwd,
620
+ dependencies: resolvePackageDependencies(plan, packagePlan.path),
621
+ highlights: packageHighlights,
622
+ });
623
+ prependChangelog(cwd, packageChangelogPath, section, packageChangelogFormat);
624
+ updatedChangelogFiles.push(packageChangelogPath);
625
+ }
626
+ const targets = buildReleaseTargets(cwd, plan, loaded.config);
627
+ const stateFiles = options.writePackageState && options.branch
628
+ ? writePackageReleaseState(cwd, options.baselineSha, targets, options.branch)
629
+ : [];
630
+ return {
631
+ files: [
632
+ ...new Set([
633
+ ...updatedVersionFiles,
634
+ ...updatedArtifactFiles,
635
+ ...updatedChangelogFiles,
636
+ ...stateFiles,
637
+ ]),
638
+ ].sort((a, b) => a.localeCompare(b)),
639
+ highlights,
640
+ targets,
641
+ };
642
+ }
643
+ function packageReleaseName(cwd, plan, packagePath, config) {
644
+ if (packagePath === ".") {
645
+ return plan.packageName;
646
+ }
647
+ const packageConfig = config.packages?.[packagePath] ?? {};
648
+ const packageContext = resolvePackageStrategyContext(config, packagePath, packageConfig);
649
+ return resolveReleaseName(cwd, packagePath, packageConfig, packageContext.strategy, packageContext.config);
650
+ }
651
+ function resetTemporaryWorktree(cwd, baselineSha) {
652
+ execFileSync("git", ["checkout", "--detach", baselineSha], {
653
+ cwd,
654
+ stdio: ["ignore", "pipe", "ignore"],
655
+ });
656
+ execFileSync("git", ["reset", "--hard", baselineSha], {
657
+ cwd,
658
+ stdio: ["ignore", "pipe", "ignore"],
659
+ });
660
+ execFileSync("git", ["clean", "-fdx"], {
661
+ cwd,
662
+ stdio: ["ignore", "pipe", "ignore"],
663
+ });
664
+ }
665
+ /**
666
+ * Build every independently mergeable release branch from the same immutable
667
+ * base without moving or modifying the caller's worktree.
668
+ */
669
+ export function prepareSeparateReleasePrs(cwd = process.cwd(), options = {}) {
670
+ const plan = createReleasePlan(cwd);
671
+ const loaded = loadConfig(cwd);
672
+ if (!loaded.config["separate-release-prs"]) {
673
+ throw new Error('prepareSeparateReleasePrs requires "separate-release-prs" to be enabled.');
674
+ }
675
+ const initialCohorts = buildInitialReleaseCohorts(plan);
676
+ if (initialCohorts.length === 0) {
677
+ return [];
678
+ }
679
+ buildReleaseTargets(cwd, plan, loaded.config);
680
+ ensureCleanWorktree(cwd, options.logger);
681
+ const baselineSha = execFileSync("git", ["rev-parse", "HEAD"], {
682
+ cwd,
683
+ encoding: "utf8",
684
+ stdio: ["ignore", "pipe", "ignore"],
685
+ }).trim();
686
+ const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "versionary-release-worktree-"));
687
+ const worktree = path.join(temporaryRoot, "worktree");
688
+ execFileSync("git", ["worktree", "add", "--detach", worktree, baselineSha], {
689
+ cwd,
690
+ stdio: ["ignore", "pipe", "ignore"],
691
+ });
692
+ try {
693
+ const cohorts = stabilizeReleaseCohorts(initialCohorts, (packagePaths) => {
694
+ resetTemporaryWorktree(worktree, baselineSha);
695
+ return materializeCandidateChanges(worktree, scopeReleasePlan(plan, packagePaths), { baselineSha }).files;
696
+ });
697
+ const prepared = [];
698
+ for (const packagePaths of cohorts) {
699
+ resetTemporaryWorktree(worktree, baselineSha);
700
+ const leader = packagePaths[0];
701
+ if (!leader) {
702
+ continue;
703
+ }
704
+ const branch = resolveSeparateReleaseBranch(plan.releaseBranchPrefix, packageReleaseName(worktree, plan, leader, loaded.config), leader);
705
+ const remoteRef = !options["dry-run"] && remoteReleaseBranchExists(cwd, branch)
706
+ ? fetchRemoteReleaseBranch(cwd, branch)
707
+ : null;
708
+ if (!options["dry-run"]) {
709
+ execFileSync("git", ["checkout", "-B", branch, baselineSha], {
710
+ cwd: worktree,
711
+ stdio: ["ignore", "pipe", "ignore"],
712
+ });
713
+ }
714
+ const scopedPlan = scopeReleasePlan(plan, packagePaths);
715
+ const materialized = materializeCandidateChanges(worktree, scopedPlan, {
716
+ baselineSha,
717
+ branch,
718
+ writePackageState: true,
719
+ });
720
+ const title = formatReleaseCommitTitle(materialized.targets);
721
+ if (!options["dry-run"]) {
722
+ execFileSync("git", ["add", ...materialized.files], {
723
+ cwd: worktree,
724
+ stdio: ["ignore", "pipe", "ignore"],
725
+ });
726
+ ensureGitIdentity(worktree);
727
+ execFileSync("git", ["commit", "-m", title, "-m", VERSIONARY_RELEASE_TRAILER], {
728
+ cwd: worktree,
729
+ stdio: ["ignore", "pipe", "ignore"],
730
+ });
731
+ }
732
+ const updated = options["dry-run"] ||
733
+ !remoteRef ||
734
+ getCommitTreeSha(worktree, "HEAD") !==
735
+ getCommitTreeSha(worktree, remoteRef);
736
+ const firstPackage = scopedPlan.packages?.find((pkg) => pkg.nextVersion);
737
+ const commits = [
738
+ ...new Map((scopedPlan.packages ?? [])
739
+ .filter((pkg) => pkg.nextVersion)
740
+ .flatMap((pkg) => pkg.commits)
741
+ .map((commit) => [commit.hash, commit])).values(),
742
+ ];
743
+ const version = materialized.targets[0]?.version ?? "";
744
+ const body = renderSimpleReviewRequestBody(version, firstPackage?.currentVersion ?? version, commits, scopedPlan, worktree, materialized.highlights, loaded.config);
745
+ prepared.push({
746
+ branch,
747
+ title,
748
+ version,
749
+ previousVersion: firstPackage?.currentVersion ?? version,
750
+ commits,
751
+ plan: scopedPlan,
752
+ updated,
753
+ highlights: materialized.highlights,
754
+ packagePaths,
755
+ targets: materialized.targets,
756
+ body,
757
+ });
758
+ resetTemporaryWorktree(worktree, baselineSha);
759
+ }
760
+ return prepared;
761
+ }
762
+ finally {
763
+ // Cleanup is best effort: a throwing `worktree remove` must neither replace
764
+ // the error we may be unwinding with nor skip the prune that drops the
765
+ // stale administrative entry.
766
+ try {
767
+ execFileSync("git", ["worktree", "remove", "--force", worktree], {
768
+ cwd,
769
+ stdio: ["ignore", "pipe", "ignore"],
770
+ });
771
+ }
772
+ catch {
773
+ // Ignored; the prune below still clears the registration.
774
+ }
775
+ try {
776
+ execFileSync("git", ["worktree", "prune"], {
777
+ cwd,
778
+ stdio: ["ignore", "pipe", "ignore"],
779
+ });
780
+ }
781
+ catch {
782
+ // Ignored.
783
+ }
784
+ fs.rmSync(temporaryRoot, { recursive: true, force: true });
785
+ }
786
+ }
348
787
  export function renderSimpleReviewRequestBody(version, previousVersion, commits, plan = null, cwd = process.cwd(), highlights = "", loadedConfig) {
349
- const rootPackageLabel = path.basename(cwd);
788
+ // `cwd` can be a temporary worktree, whose basename would leak into the PR
789
+ // body; the plan carries the repository-derived name.
790
+ const rootPackageLabel = plan?.packageName ?? path.basename(cwd);
350
791
  const formatPackageLabel = (packagePath) => packagePath === "." ? rootPackageLabel : packagePath;
351
792
  const resolveTagPrefix = (packagePath) => {
352
793
  if (packagePath === ".") {
@@ -417,7 +858,8 @@ export async function openOrUpdateReviewRequest(cwd, branch, title, version, pre
417
858
  baseBranch: process.env.VERSIONARY_BASE_BRANCH ?? "main",
418
859
  headBranch: branch,
419
860
  title,
420
- body: renderSimpleReviewRequestBody(version, previousVersion, commits, plan, cwd, options.highlights ?? "", loaded.config),
861
+ body: options.body ??
862
+ renderSimpleReviewRequestBody(version, previousVersion, commits, plan, cwd, options.highlights ?? "", loaded.config),
421
863
  labels: ["release"],
422
864
  }, {
423
865
  cwd,
@@ -425,6 +867,70 @@ export async function openOrUpdateReviewRequest(cwd, branch, title, version, pre
425
867
  });
426
868
  return result.url;
427
869
  }
870
+ export async function reconcileSeparateReviewRequests(cwd, prepared, options = {}) {
871
+ if (options["dry-run"]) {
872
+ return prepared.map((candidate) => ({
873
+ packagePaths: candidate.packagePaths,
874
+ branch: candidate.branch,
875
+ title: candidate.title,
876
+ status: "dry-run",
877
+ targets: candidate.targets,
878
+ }));
879
+ }
880
+ const loaded = loadConfig(cwd);
881
+ const baseBranch = process.env.VERSIONARY_BASE_BRANCH ?? "main";
882
+ const branchPrefix = resolveSeparateReleaseBranchPrefix(loaded.config["release-branch"] ?? "versionary/release");
883
+ const scmClient = getScmClient();
884
+ const existing = await scmClient.listOpenReviewRequests({
885
+ baseBranch,
886
+ headBranchPrefix: branchPrefix,
887
+ labels: ["release"],
888
+ }, { cwd, logger: options.logger });
889
+ const activeBranches = new Set(prepared.map((candidate) => candidate.branch));
890
+ const results = [];
891
+ for (const candidate of prepared) {
892
+ const existingRequest = existing.find((request) => request.headBranch === candidate.branch);
893
+ let reviewUrl = existingRequest?.url;
894
+ if (candidate.updated) {
895
+ pushReleaseBranch(cwd, candidate.branch);
896
+ }
897
+ if (candidate.updated || !existingRequest) {
898
+ const result = await scmClient.createOrUpdateReviewRequest({
899
+ baseBranch,
900
+ headBranch: candidate.branch,
901
+ title: candidate.title,
902
+ body: candidate.body,
903
+ labels: ["release"],
904
+ }, { cwd, logger: options.logger });
905
+ reviewUrl = result.url;
906
+ }
907
+ results.push({
908
+ packagePaths: candidate.packagePaths,
909
+ branch: candidate.branch,
910
+ title: candidate.title,
911
+ reviewUrl,
912
+ status: candidate.updated || !existingRequest
913
+ ? options.recovered
914
+ ? "recovered"
915
+ : "prepared"
916
+ : "up-to-date",
917
+ targets: candidate.targets,
918
+ });
919
+ }
920
+ if (!options.recovered) {
921
+ for (const stale of existing) {
922
+ if (activeBranches.has(stale.headBranch)) {
923
+ continue;
924
+ }
925
+ await scmClient.closeReviewRequestIfExists({
926
+ baseBranch,
927
+ headBranch: stale.headBranch,
928
+ reason: "Closing stale release PR because its package cohort is no longer releasable for the current baseline/tag state.",
929
+ }, { cwd, logger: options.logger });
930
+ }
931
+ }
932
+ return results.sort((a, b) => a.branch.localeCompare(b.branch));
933
+ }
428
934
  function hasScmRuntimeContext() {
429
935
  const hasRepository = Boolean(process.env.GITHUB_REPOSITORY);
430
936
  const hasToken = Boolean(process.env.VERSIONARY_PR_TOKEN ??
@@ -436,18 +942,45 @@ export async function closeStaleReviewRequestIfExists(cwd = process.cwd(), optio
436
942
  if (!hasScmRuntimeContext()) {
437
943
  return { closed: false };
438
944
  }
439
- const plan = createReleasePlan(cwd);
945
+ const loaded = loadConfig(cwd);
440
946
  const scmClient = getScmClient();
947
+ const baseBranch = process.env.VERSIONARY_BASE_BRANCH ?? "main";
948
+ const releaseBranch = loaded.config["release-branch"] ?? "versionary/release";
949
+ if (loaded.config["separate-release-prs"]) {
950
+ const branchPrefix = resolveSeparateReleaseBranchPrefix(releaseBranch);
951
+ const openRequests = await scmClient.listOpenReviewRequests({
952
+ baseBranch,
953
+ headBranchPrefix: branchPrefix,
954
+ labels: ["release"],
955
+ }, { cwd, logger: options.logger });
956
+ let firstClosed = {
957
+ closed: false,
958
+ };
959
+ for (const request of openRequests) {
960
+ const result = await scmClient.closeReviewRequestIfExists({
961
+ baseBranch,
962
+ headBranch: request.headBranch,
963
+ reason: "Closing stale release PR because no releasable commits remain for the current baseline/tag state.",
964
+ }, { cwd, logger: options.logger });
965
+ if (result.closed && !firstClosed.closed) {
966
+ firstClosed = result;
967
+ }
968
+ if (result.closed && result.number) {
969
+ options.logger?.info(`Closed stale release review request #${result.number} for ${request.headBranch}.`);
970
+ }
971
+ }
972
+ return firstClosed;
973
+ }
441
974
  const result = await scmClient.closeReviewRequestIfExists({
442
- baseBranch: process.env.VERSIONARY_BASE_BRANCH ?? "main",
443
- headBranch: plan.releaseBranchPrefix,
975
+ baseBranch,
976
+ headBranch: releaseBranch,
444
977
  reason: "Closing stale release PR because no releasable commits remain for the current baseline/tag state.",
445
978
  }, {
446
979
  cwd,
447
980
  logger: options.logger,
448
981
  });
449
982
  if (result.closed && result.number) {
450
- options.logger?.info(`Closed stale release review request #${result.number} for ${plan.releaseBranchPrefix}.`);
983
+ options.logger?.info(`Closed stale release review request #${result.number} for ${releaseBranch}.`);
451
984
  }
452
985
  return result;
453
986
  }