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.
- package/dist/action/index.js +45 -18
- package/dist/cli/index.js +211 -24
- package/dist/config/schema.d.ts +2 -0
- package/dist/config/schema.js +32 -0
- package/dist/index.d.ts +2 -2
- package/dist/release/artifact-rules.js +27 -28
- package/dist/release/changelog.js +1 -2
- package/dist/release/cohorts.d.ts +6 -0
- package/dist/release/cohorts.js +125 -0
- package/dist/release/plan.js +49 -45
- package/dist/release/pr.d.ts +69 -0
- package/dist/release/pr.js +555 -22
- package/dist/release/release.d.ts +12 -0
- package/dist/release/release.js +26 -7
- package/dist/release/state.d.ts +16 -1
- package/dist/release/state.js +182 -9
- package/dist/release/targets.d.ts +21 -0
- package/dist/release/targets.js +85 -0
- package/dist/scm/github-plugin.js +44 -0
- package/dist/scm/types.d.ts +11 -0
- package/dist/strategy/cmake.d.ts +2 -0
- package/dist/strategy/cmake.js +283 -0
- package/dist/strategy/resolve.js +2 -0
- package/dist/types/config.d.ts +2 -0
- package/dist/types/plugins.d.ts +3 -1
- package/package.json +3 -1
package/dist/release/pr.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
287
|
-
const packageSection =
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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",
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
443
|
-
headBranch:
|
|
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 ${
|
|
983
|
+
options.logger?.info(`Closed stale release review request #${result.number} for ${releaseBranch}.`);
|
|
451
984
|
}
|
|
452
985
|
return result;
|
|
453
986
|
}
|