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.
- package/dist/action/index.js +4 -0
- package/dist/cli/index.js +227 -26
- 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 +9 -6
- package/dist/release/cohorts.d.ts +6 -0
- package/dist/release/cohorts.js +125 -0
- package/dist/release/plan.d.ts +22 -0
- package/dist/release/plan.js +81 -45
- package/dist/release/pr.d.ts +69 -0
- package/dist/release/pr.js +563 -24
- 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);
|
|
@@ -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
|
-
|
|
262
|
-
|
|
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,
|
|
281
|
-
const packageSection =
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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",
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
437
|
-
headBranch:
|
|
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 ${
|
|
983
|
+
options.logger?.info(`Closed stale release review request #${result.number} for ${releaseBranch}.`);
|
|
445
984
|
}
|
|
446
985
|
return result;
|
|
447
986
|
}
|