versionary 1.0.0 → 1.1.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);
@@ -258,15 +419,21 @@ export function prepareReleasePr(cwd = process.cwd(), options = {}) {
258
419
  const highlightsResult = resolveReleaseHighlights(cwd, plan.changelogFile, plan.changelogFormat);
259
420
  const highlights = highlightsResult.highlights;
260
421
  const section = renderReleasePlanChangelog(plan, { highlights, cwd });
261
- prependChangelog(cwd, plan.changelogFile, section, plan.changelogFormat);
262
- const updatedChangelogFiles = [plan.changelogFile];
422
+ const updatedChangelogFiles = [];
423
+ // An empty section means root itself is not releasing, only siblings are.
424
+ // Writing then would leave a bare heading behind and, worse, strip the
425
+ // manual-notes block that nothing has folded in yet.
426
+ if (section.length > 0) {
427
+ prependChangelog(cwd, plan.changelogFile, section, plan.changelogFormat);
428
+ updatedChangelogFiles.push(plan.changelogFile);
429
+ }
263
430
  for (const packagePlan of plan.packages ?? []) {
264
431
  if (!packagePlan.nextVersion || packagePlan.path === ".") {
265
432
  continue;
266
433
  }
267
434
  const packageConfig = loaded.config.packages?.[packagePlan.path] ?? {};
268
435
  const packageContext = resolvePackageStrategyContext(loaded.config, packagePlan.path, packageConfig);
269
- const { changelogFile: packageChangelogFile } = getChangelogDefaults({
436
+ const { changelogFile: packageChangelogFile, changelogFormat: packageChangelogFormat, } = getChangelogDefaults({
270
437
  "release-type": packageConfig["release-type"] ?? loaded.config["release-type"],
271
438
  "changelog-file": packageConfig["changelog-file"] ?? loaded.config["changelog-file"],
272
439
  "changelog-format": packageConfig["changelog-format"] ?? loaded.config["changelog-format"],
@@ -277,17 +444,25 @@ export function prepareReleasePr(cwd = process.cwd(), options = {}) {
277
444
  continue;
278
445
  }
279
446
  const packageChangelogPath = path.posix.join(packagePlan.path, packageChangelogFile);
280
- const packageHighlights = readChangelogHighlights(cwd, packageChangelogPath, "markdown-changelog");
281
- const packageSection = renderReleaseNotesSection({
282
- currentVersion: packagePlan.currentVersion,
283
- nextVersion: packagePlan.nextVersion,
284
- commits: packagePlan.commits,
285
- tagPrefix: packageMetadata.tagPrefix,
286
- cwd,
287
- dependencies: resolvePackageDependencies(plan, packagePlan.path),
288
- highlights: packageHighlights,
289
- });
290
- 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);
291
466
  updatedChangelogFiles.push(packageChangelogPath);
292
467
  }
293
468
  const releaseTargets = buildReleaseTargets(cwd, plan, loaded.config);
@@ -317,8 +492,8 @@ export function prepareReleasePr(cwd = process.cwd(), options = {}) {
317
492
  cwd,
318
493
  stdio: ["ignore", "pipe", "ignore"],
319
494
  });
320
- writeBaselineSha(cwd, releaseBaselineSha, releaseTargets);
321
- execFileSync("git", ["add", getBaselineStatePath(cwd)], {
495
+ const updatedStateFiles = writeBaselineSha(cwd, releaseBaselineSha, releaseTargets);
496
+ execFileSync("git", ["add", ...updatedStateFiles], {
322
497
  cwd,
323
498
  stdio: ["ignore", "pipe", "ignore"],
324
499
  });
@@ -339,8 +514,280 @@ export function prepareReleasePr(cwd = process.cwd(), options = {}) {
339
514
  highlights,
340
515
  };
341
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
+ }
342
787
  export function renderSimpleReviewRequestBody(version, previousVersion, commits, plan = null, cwd = process.cwd(), highlights = "", loadedConfig) {
343
- 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);
344
791
  const formatPackageLabel = (packagePath) => packagePath === "." ? rootPackageLabel : packagePath;
345
792
  const resolveTagPrefix = (packagePath) => {
346
793
  if (packagePath === ".") {
@@ -411,7 +858,8 @@ export async function openOrUpdateReviewRequest(cwd, branch, title, version, pre
411
858
  baseBranch: process.env.VERSIONARY_BASE_BRANCH ?? "main",
412
859
  headBranch: branch,
413
860
  title,
414
- 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),
415
863
  labels: ["release"],
416
864
  }, {
417
865
  cwd,
@@ -419,6 +867,70 @@ export async function openOrUpdateReviewRequest(cwd, branch, title, version, pre
419
867
  });
420
868
  return result.url;
421
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
+ }
422
934
  function hasScmRuntimeContext() {
423
935
  const hasRepository = Boolean(process.env.GITHUB_REPOSITORY);
424
936
  const hasToken = Boolean(process.env.VERSIONARY_PR_TOKEN ??
@@ -430,18 +942,45 @@ export async function closeStaleReviewRequestIfExists(cwd = process.cwd(), optio
430
942
  if (!hasScmRuntimeContext()) {
431
943
  return { closed: false };
432
944
  }
433
- const plan = createReleasePlan(cwd);
945
+ const loaded = loadConfig(cwd);
434
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
+ }
435
974
  const result = await scmClient.closeReviewRequestIfExists({
436
- baseBranch: process.env.VERSIONARY_BASE_BRANCH ?? "main",
437
- headBranch: plan.releaseBranchPrefix,
975
+ baseBranch,
976
+ headBranch: releaseBranch,
438
977
  reason: "Closing stale release PR because no releasable commits remain for the current baseline/tag state.",
439
978
  }, {
440
979
  cwd,
441
980
  logger: options.logger,
442
981
  });
443
982
  if (result.closed && result.number) {
444
- 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}.`);
445
984
  }
446
985
  return result;
447
986
  }