@ucdjs/release-scripts 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # @ucdjs/release-scripts
2
+
3
+ This repository contains release and publish scripts for the UCDJS organization.
@@ -0,0 +1,121 @@
1
+ //#region src/types.d.ts
2
+ type BumpKind = "none" | "patch" | "minor" | "major";
3
+ interface PackageJson {
4
+ name: string;
5
+ version: string;
6
+ dependencies?: Record<string, string>;
7
+ devDependencies?: Record<string, string>;
8
+ peerDependencies?: Record<string, string>;
9
+ [key: string]: unknown;
10
+ }
11
+ interface WorkspacePackage {
12
+ name: string;
13
+ version: string;
14
+ path: string;
15
+ packageJson: PackageJson;
16
+ workspaceDependencies: string[];
17
+ workspaceDevDependencies: string[];
18
+ }
19
+ interface FindWorkspacePackagesOptions {
20
+ /**
21
+ * Package names to exclude
22
+ */
23
+ excluded?: string[];
24
+ /**
25
+ * Only include these packages (if specified, all others are excluded)
26
+ */
27
+ included?: string[];
28
+ /**
29
+ * Whether to exclude private packages (default: false)
30
+ */
31
+ excludePrivate?: boolean;
32
+ }
33
+ interface VersionUpdate {
34
+ /**
35
+ * The package being updated
36
+ */
37
+ package: WorkspacePackage;
38
+ /**
39
+ * Current version
40
+ */
41
+ currentVersion: string;
42
+ /**
43
+ * New version to release
44
+ */
45
+ newVersion: string;
46
+ /**
47
+ * Type of version bump
48
+ */
49
+ bumpType: BumpKind;
50
+ /**
51
+ * Whether this package has direct changes (vs being updated due to dependency changes)
52
+ */
53
+ hasDirectChanges: boolean;
54
+ }
55
+ interface ReleaseOptions {
56
+ /**
57
+ * Repository identifier (e.g., "owner/repo")
58
+ */
59
+ repo: string;
60
+ /**
61
+ * Root directory of the workspace (defaults to process.cwd())
62
+ */
63
+ workspaceRoot?: string;
64
+ /**
65
+ * Specific packages to prepare for release.
66
+ * - true: discover all packages
67
+ * - FindWorkspacePackagesOptions: discover with filters
68
+ * - string[]: specific package names
69
+ */
70
+ packages?: true | FindWorkspacePackagesOptions | string[];
71
+ /**
72
+ * Branch name for the release PR (defaults to "release/next")
73
+ */
74
+ releaseBranch?: string;
75
+ /**
76
+ * Interactive prompt configuration
77
+ */
78
+ prompts?: {
79
+ /**
80
+ * Enable package selection prompt (defaults to true when not in CI)
81
+ */
82
+ packages?: boolean;
83
+ /**
84
+ * Enable version override prompt (defaults to true when not in CI)
85
+ */
86
+ versions?: boolean;
87
+ };
88
+ /**
89
+ * Whether to perform a dry run (no changes pushed or PR created)
90
+ * @default false
91
+ */
92
+ dryRun?: boolean;
93
+ /**
94
+ * Whether to enable safety safeguards (e.g., checking for clean working directory)
95
+ * @default true
96
+ */
97
+ safeguards?: boolean;
98
+ /**
99
+ * GitHub token for authentication
100
+ */
101
+ githubToken: string;
102
+ }
103
+ interface ReleaseResult {
104
+ /**
105
+ * Packages that will be updated
106
+ */
107
+ updates: VersionUpdate[];
108
+ /**
109
+ * URL of the created or updated PR
110
+ */
111
+ prUrl?: string;
112
+ /**
113
+ * Whether a new PR was created (vs updating existing)
114
+ */
115
+ created: boolean;
116
+ }
117
+ //#endregion
118
+ //#region src/release.d.ts
119
+ declare function release(options: ReleaseOptions): Promise<ReleaseResult | null>;
120
+ //#endregion
121
+ export { release };
package/dist/index.mjs ADDED
@@ -0,0 +1,817 @@
1
+ import process from "node:process";
2
+ import { getCommits } from "commit-parser";
3
+ import createDebug from "debug";
4
+ import farver from "farver";
5
+ import { exec } from "tinyexec";
6
+ import { readFile, writeFile } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import prompts from "prompts";
9
+
10
+ //#region src/logger.ts
11
+ function createDebugger(namespace) {
12
+ const debug$2 = createDebug(namespace);
13
+ if (debug$2.enabled) return debug$2;
14
+ }
15
+
16
+ //#endregion
17
+ //#region src/utils.ts
18
+ const globalOptions = { dryRun: false };
19
+ async function run(bin, args, opts = {}) {
20
+ return exec(bin, args, {
21
+ throwOnError: true,
22
+ ...opts,
23
+ nodeOptions: {
24
+ stdio: "inherit",
25
+ ...opts.nodeOptions
26
+ }
27
+ });
28
+ }
29
+ async function dryRun(bin, args, opts) {
30
+ return console.log(farver.blue(`[dryrun] ${bin} ${args.join(" ")}`), opts || "");
31
+ }
32
+ const runIfNotDry = globalOptions.dryRun ? dryRun : run;
33
+
34
+ //#endregion
35
+ //#region src/commits.ts
36
+ const debug$1 = createDebugger("ucdjs:release-scripts:commits");
37
+ async function getLastPackageTag(packageName, workspaceRoot) {
38
+ const { stdout } = await run("git", ["tag", "--list"], { nodeOptions: {
39
+ cwd: workspaceRoot,
40
+ stdio: "pipe"
41
+ } });
42
+ return stdout.split("\n").map((tag) => tag.trim()).filter(Boolean).reverse().find((tag) => tag.startsWith(`${packageName}@`));
43
+ }
44
+ function determineHighestBump(commits) {
45
+ if (commits.length === 0) return "none";
46
+ let highestBump = "none";
47
+ for (const commit of commits) {
48
+ const bump = determineBumpType(commit);
49
+ if (bump === "major") return "major";
50
+ if (bump === "minor") highestBump = "minor";
51
+ else if (bump === "patch" && highestBump === "none") highestBump = "patch";
52
+ }
53
+ return highestBump;
54
+ }
55
+ async function getPackageCommits(pkg, workspaceRoot) {
56
+ const lastTag = await getLastPackageTag(pkg.name, workspaceRoot);
57
+ const allCommits = getCommits({
58
+ from: lastTag,
59
+ to: "HEAD"
60
+ });
61
+ debug$1?.(`Found ${allCommits.length} commits for ${pkg.name} since ${lastTag || "beginning"}`);
62
+ const touchedCommitHashes = await getCommitsTouchingPackage(lastTag || "HEAD", "HEAD", pkg.path, workspaceRoot);
63
+ const touchedSet = new Set(touchedCommitHashes);
64
+ const packageCommits = allCommits.filter((commit) => touchedSet.has(commit.shortHash));
65
+ debug$1?.(`${packageCommits.length} commits affect ${pkg.name}`);
66
+ return packageCommits;
67
+ }
68
+ async function analyzePackageCommits(pkg, workspaceRoot) {
69
+ return determineHighestBump(await getPackageCommits(pkg, workspaceRoot));
70
+ }
71
+ function determineBumpType(commit) {
72
+ if (commit.isBreaking) return "major";
73
+ if (!commit.isConventional || !commit.type) return "none";
74
+ switch (commit.type) {
75
+ case "feat": return "minor";
76
+ case "fix":
77
+ case "perf": return "patch";
78
+ case "docs":
79
+ case "style":
80
+ case "refactor":
81
+ case "test":
82
+ case "build":
83
+ case "ci":
84
+ case "chore":
85
+ case "revert": return "none";
86
+ default: return "none";
87
+ }
88
+ }
89
+ async function getCommitsTouchingPackage(from, to, packagePath, workspaceRoot) {
90
+ try {
91
+ const { stdout } = await run("git", [
92
+ "log",
93
+ "--pretty=format:%h",
94
+ from === "HEAD" ? "HEAD" : `${from}...${to}`,
95
+ "--",
96
+ packagePath
97
+ ], { nodeOptions: {
98
+ cwd: workspaceRoot,
99
+ stdio: "pipe"
100
+ } });
101
+ return stdout.split("\n").map((line) => line.trim()).filter(Boolean);
102
+ } catch (error) {
103
+ debug$1?.(`Error getting commits touching package: ${error}`);
104
+ return [];
105
+ }
106
+ }
107
+
108
+ //#endregion
109
+ //#region src/validation.ts
110
+ /**
111
+ * Validation utilities for release scripts
112
+ */
113
+ function isValidSemver(version) {
114
+ return /^\d+\.\d+\.\d+(?:[-+].+)?$/.test(version);
115
+ }
116
+ function validateSemver(version) {
117
+ if (!isValidSemver(version)) throw new Error(`Invalid semver version: ${version}`);
118
+ }
119
+
120
+ //#endregion
121
+ //#region src/version.ts
122
+ /**
123
+ * Calculate the new version based on current version and bump type
124
+ * Pure function - no side effects, easily testable
125
+ */
126
+ function calculateNewVersion(currentVersion, bump) {
127
+ if (bump === "none") return currentVersion;
128
+ validateSemver(currentVersion);
129
+ const match = currentVersion.match(/^(\d+)\.(\d+)\.(\d+)(.*)$/);
130
+ if (!match) throw new Error(`Invalid semver version: ${currentVersion}`);
131
+ const [, major, minor, patch, suffix] = match;
132
+ let newMajor = Number.parseInt(major, 10);
133
+ let newMinor = Number.parseInt(minor, 10);
134
+ let newPatch = Number.parseInt(patch, 10);
135
+ switch (bump) {
136
+ case "major":
137
+ newMajor += 1;
138
+ newMinor = 0;
139
+ newPatch = 0;
140
+ break;
141
+ case "minor":
142
+ newMinor += 1;
143
+ newPatch = 0;
144
+ break;
145
+ case "patch":
146
+ newPatch += 1;
147
+ break;
148
+ }
149
+ return `${newMajor}.${newMinor}.${newPatch}`;
150
+ }
151
+ /**
152
+ * Create a version update object
153
+ */
154
+ function createVersionUpdate(pkg, bump, hasDirectChanges) {
155
+ const newVersion = calculateNewVersion(pkg.version, bump);
156
+ return {
157
+ package: pkg,
158
+ currentVersion: pkg.version,
159
+ newVersion,
160
+ bumpType: bump,
161
+ hasDirectChanges
162
+ };
163
+ }
164
+ /**
165
+ * Update a package.json file with new version and dependency versions
166
+ */
167
+ async function updatePackageJson(pkg, newVersion, dependencyUpdates) {
168
+ const packageJsonPath = join(pkg.path, "package.json");
169
+ const content = await readFile(packageJsonPath, "utf-8");
170
+ const packageJson = JSON.parse(content);
171
+ packageJson.version = newVersion;
172
+ for (const [depName, depVersion] of dependencyUpdates) {
173
+ if (packageJson.dependencies?.[depName]) {
174
+ if (packageJson.dependencies[depName] === "workspace:*") continue;
175
+ packageJson.dependencies[depName] = `^${depVersion}`;
176
+ }
177
+ if (packageJson.devDependencies?.[depName]) {
178
+ if (packageJson.devDependencies[depName] === "workspace:*") continue;
179
+ packageJson.devDependencies[depName] = `^${depVersion}`;
180
+ }
181
+ if (packageJson.peerDependencies?.[depName]) {
182
+ if (packageJson.peerDependencies[depName] === "workspace:*") continue;
183
+ packageJson.peerDependencies[depName] = `^${depVersion}`;
184
+ }
185
+ }
186
+ await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf-8");
187
+ }
188
+ /**
189
+ * Get all dependency updates needed for a package
190
+ */
191
+ function getDependencyUpdates(pkg, allUpdates) {
192
+ const updates = /* @__PURE__ */ new Map();
193
+ const allDeps = [...pkg.workspaceDependencies, ...pkg.workspaceDevDependencies];
194
+ for (const dep of allDeps) {
195
+ const update = allUpdates.find((u) => u.package.name === dep);
196
+ if (update) updates.set(dep, update.newVersion);
197
+ }
198
+ return updates;
199
+ }
200
+
201
+ //#endregion
202
+ //#region src/dependencies.ts
203
+ /**
204
+ * Pure function: Determine which packages need updates due to dependency changes
205
+ *
206
+ * When a package is updated, all packages that depend on it should also be updated.
207
+ * This function calculates which additional packages need patch bumps.
208
+ *
209
+ * @param updateOrder - Packages in topological order with their dependency levels
210
+ * @param directUpdates - Packages with direct code changes
211
+ * @returns All updates including dependent packages
212
+ */
213
+ function createDependentUpdates(updateOrder, directUpdates) {
214
+ const allUpdates = [...directUpdates];
215
+ const updatedPackages = new Set(directUpdates.map((u) => u.package.name));
216
+ for (const { package: pkg } of updateOrder) {
217
+ if (updatedPackages.has(pkg.name)) continue;
218
+ if (hasUpdatedDependencies(pkg, updatedPackages)) {
219
+ allUpdates.push(createVersionUpdate(pkg, "patch", false));
220
+ updatedPackages.add(pkg.name);
221
+ }
222
+ }
223
+ return allUpdates;
224
+ }
225
+ /**
226
+ * Pure function: Check if a package has any updated dependencies
227
+ */
228
+ function hasUpdatedDependencies(pkg, updatedPackages) {
229
+ return [...pkg.workspaceDependencies, ...pkg.workspaceDevDependencies].some((dep) => updatedPackages.has(dep));
230
+ }
231
+
232
+ //#endregion
233
+ //#region src/git.ts
234
+ /**
235
+ * Check if the working directory is clean (no uncommitted changes)
236
+ * @param {string} workspaceRoot - The root directory of the workspace
237
+ * @returns {Promise<boolean>} A Promise resolving to true if clean, false otherwise
238
+ */
239
+ async function isWorkingDirectoryClean(workspaceRoot) {
240
+ try {
241
+ if ((await run("git", ["status", "--porcelain"], { nodeOptions: {
242
+ cwd: workspaceRoot,
243
+ stdio: "pipe"
244
+ } })).stdout.trim() !== "") return false;
245
+ return true;
246
+ } catch (err) {
247
+ console.error("Error checking git status:", err);
248
+ return false;
249
+ }
250
+ }
251
+ /**
252
+ * Check if a git branch exists locally
253
+ * @param {string} branch - The branch name to check
254
+ * @param {string} workspaceRoot - The root directory of the workspace
255
+ * @returns {Promise<boolean>} Promise resolving to true if branch exists, false otherwise
256
+ */
257
+ async function doesBranchExist(branch, workspaceRoot) {
258
+ try {
259
+ await run("git", [
260
+ "rev-parse",
261
+ "--verify",
262
+ branch
263
+ ], { nodeOptions: {
264
+ cwd: workspaceRoot,
265
+ stdio: "pipe"
266
+ } });
267
+ return true;
268
+ } catch {
269
+ return false;
270
+ }
271
+ }
272
+ /**
273
+ * Pull latest changes from remote branch
274
+ * @param branch - The branch name to pull from
275
+ * @param workspaceRoot - The root directory of the workspace
276
+ * @returns Promise resolving to true if pull succeeded, false otherwise
277
+ */
278
+ async function pullLatestChanges(branch, workspaceRoot) {
279
+ try {
280
+ await run("git", [
281
+ "pull",
282
+ "origin",
283
+ branch
284
+ ], { nodeOptions: {
285
+ cwd: workspaceRoot,
286
+ stdio: "pipe"
287
+ } });
288
+ return true;
289
+ } catch {
290
+ return false;
291
+ }
292
+ }
293
+ /**
294
+ * Create a new git branch
295
+ * @param branch - The new branch name
296
+ * @param base - The base branch to create from
297
+ * @param workspaceRoot - The root directory of the workspace
298
+ */
299
+ async function createBranch(branch, base, workspaceRoot) {
300
+ await runIfNotDry("git", [
301
+ "checkout",
302
+ "-b",
303
+ branch,
304
+ base
305
+ ], { nodeOptions: { cwd: workspaceRoot } });
306
+ }
307
+ /**
308
+ * Checkout a git branch
309
+ * @param branch - The branch name to checkout
310
+ * @param workspaceRoot - The root directory of the workspace
311
+ * @returns Promise resolving to true if checkout succeeded, false otherwise
312
+ */
313
+ async function checkoutBranch(branch, workspaceRoot) {
314
+ try {
315
+ await run("git", ["checkout", branch], { nodeOptions: { cwd: workspaceRoot } });
316
+ return true;
317
+ } catch {
318
+ return false;
319
+ }
320
+ }
321
+ /**
322
+ * Get the current branch name
323
+ * @param workspaceRoot - The root directory of the workspace
324
+ * @returns Promise resolving to the current branch name
325
+ */
326
+ async function getCurrentBranch(workspaceRoot) {
327
+ return (await run("git", [
328
+ "rev-parse",
329
+ "--abbrev-ref",
330
+ "HEAD"
331
+ ], { nodeOptions: {
332
+ cwd: workspaceRoot,
333
+ stdio: "pipe"
334
+ } })).stdout.trim();
335
+ }
336
+ /**
337
+ * Rebase current branch onto another branch
338
+ * @param ontoBranch - The target branch to rebase onto
339
+ * @param workspaceRoot - The root directory of the workspace
340
+ */
341
+ async function rebaseBranch(ontoBranch, workspaceRoot) {
342
+ await run("git", ["rebase", ontoBranch], { nodeOptions: { cwd: workspaceRoot } });
343
+ }
344
+ /**
345
+ * Check if there are any changes to commit (staged or unstaged)
346
+ * @param workspaceRoot - The root directory of the workspace
347
+ * @returns Promise resolving to true if there are changes, false otherwise
348
+ */
349
+ async function hasChangesToCommit(workspaceRoot) {
350
+ return (await run("git", ["status", "--porcelain"], { nodeOptions: {
351
+ cwd: workspaceRoot,
352
+ stdio: "pipe"
353
+ } })).stdout.trim() !== "";
354
+ }
355
+ /**
356
+ * Commit changes with a message
357
+ * @param message - The commit message
358
+ * @param workspaceRoot - The root directory of the workspace
359
+ * @returns Promise resolving to true if commit was made, false if there were no changes
360
+ */
361
+ async function commitChanges(message, workspaceRoot) {
362
+ await run("git", ["add", "."], { nodeOptions: { cwd: workspaceRoot } });
363
+ if (!await hasChangesToCommit(workspaceRoot)) return false;
364
+ await run("git", [
365
+ "commit",
366
+ "-m",
367
+ message
368
+ ], { nodeOptions: { cwd: workspaceRoot } });
369
+ return true;
370
+ }
371
+ /**
372
+ * Push branch to remote
373
+ * @param branch - The branch name to push
374
+ * @param workspaceRoot - The root directory of the workspace
375
+ * @param options - Push options
376
+ * @param options.force - Force push (overwrite remote)
377
+ * @param options.forceWithLease - Force push with safety check (won't overwrite unexpected changes)
378
+ */
379
+ async function pushBranch(branch, workspaceRoot, options) {
380
+ const args = [
381
+ "push",
382
+ "origin",
383
+ branch
384
+ ];
385
+ if (options?.forceWithLease) args.push("--force-with-lease");
386
+ else if (options?.force) args.push("--force");
387
+ await run("git", args, { nodeOptions: { cwd: workspaceRoot } });
388
+ }
389
+ /**
390
+ * Generate PR body from version updates
391
+ * @param updates - Array of version updates to include in the PR body
392
+ * @returns Formatted PR body as a string
393
+ */
394
+ function generatePRBody(updates) {
395
+ const lines = [];
396
+ lines.push("## Packages");
397
+ lines.push("");
398
+ const directChanges = updates.filter((u) => u.hasDirectChanges);
399
+ const dependencyUpdates = updates.filter((u) => !u.hasDirectChanges);
400
+ if (directChanges.length > 0) {
401
+ lines.push("### Direct Changes");
402
+ lines.push("");
403
+ for (const update of directChanges) lines.push(`- **${update.package.name}**: ${update.currentVersion} → ${update.newVersion} (${update.bumpType})`);
404
+ lines.push("");
405
+ }
406
+ if (dependencyUpdates.length > 0) {
407
+ lines.push("### Dependency Updates");
408
+ lines.push("");
409
+ for (const update of dependencyUpdates) lines.push(`- **${update.package.name}**: ${update.currentVersion} → ${update.newVersion} (dependencies changed)`);
410
+ lines.push("");
411
+ }
412
+ lines.push("---");
413
+ lines.push("");
414
+ lines.push("This release PR was automatically generated.");
415
+ return lines.join("\n");
416
+ }
417
+
418
+ //#endregion
419
+ //#region src/github.ts
420
+ async function getExistingPullRequest({ owner, repo, branch, githubToken }) {
421
+ try {
422
+ const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls?state=open&head=${branch}`, { headers: {
423
+ Accept: "application/vnd.github.v3+json",
424
+ Authorization: `token ${githubToken}`
425
+ } });
426
+ if (!res.ok) throw new Error(`GitHub API request failed with status ${res.status}`);
427
+ const pulls = await res.json();
428
+ if (pulls == null || !Array.isArray(pulls) || pulls.length === 0) return null;
429
+ const firstPullRequest = pulls[0];
430
+ if (typeof firstPullRequest !== "object" || firstPullRequest === null || !("number" in firstPullRequest) || typeof firstPullRequest.number !== "number" || !("title" in firstPullRequest) || typeof firstPullRequest.title !== "string" || !("body" in firstPullRequest) || typeof firstPullRequest.body !== "string" || !("draft" in firstPullRequest) || typeof firstPullRequest.draft !== "boolean" || !("html_url" in firstPullRequest) || typeof firstPullRequest.html_url !== "string") throw new TypeError("Pull request data validation failed");
431
+ const pullRequest = {
432
+ number: firstPullRequest.number,
433
+ title: firstPullRequest.title,
434
+ body: firstPullRequest.body,
435
+ draft: firstPullRequest.draft,
436
+ html_url: firstPullRequest.html_url
437
+ };
438
+ console.info(`Found existing pull request: ${farver.yellow(`#${pullRequest.number}`)}`);
439
+ return pullRequest;
440
+ } catch (err) {
441
+ console.error("Error fetching pull request:", err);
442
+ return null;
443
+ }
444
+ }
445
+ async function upsertPullRequest({ owner, repo, title, body, head, base, pullNumber, githubToken }) {
446
+ try {
447
+ const isUpdate = pullNumber != null;
448
+ const url = isUpdate ? `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}` : `https://api.github.com/repos/${owner}/${repo}/pulls`;
449
+ const method = isUpdate ? "PATCH" : "POST";
450
+ const requestBody = isUpdate ? {
451
+ title,
452
+ body
453
+ } : {
454
+ title,
455
+ body,
456
+ head,
457
+ base
458
+ };
459
+ const res = await fetch(url, {
460
+ method,
461
+ headers: {
462
+ Accept: "application/vnd.github.v3+json",
463
+ Authorization: `token ${githubToken}`
464
+ },
465
+ body: JSON.stringify(requestBody)
466
+ });
467
+ if (!res.ok) throw new Error(`GitHub API request failed with status ${res.status}`);
468
+ const pr = await res.json();
469
+ if (typeof pr !== "object" || pr === null || !("number" in pr) || typeof pr.number !== "number" || !("title" in pr) || typeof pr.title !== "string" || !("body" in pr) || typeof pr.body !== "string" || !("draft" in pr) || typeof pr.draft !== "boolean" || !("html_url" in pr) || typeof pr.html_url !== "string") throw new TypeError("Pull request data validation failed");
470
+ const action = isUpdate ? "Updated" : "Created";
471
+ console.info(`${action} pull request: ${farver.yellow(`#${pr.number}`)}`);
472
+ return {
473
+ number: pr.number,
474
+ title: pr.title,
475
+ body: pr.body,
476
+ draft: pr.draft,
477
+ html_url: pr.html_url
478
+ };
479
+ } catch (err) {
480
+ console.error(`Error upserting pull request:`, err);
481
+ throw err;
482
+ }
483
+ }
484
+
485
+ //#endregion
486
+ //#region src/prompts.ts
487
+ async function promptPackageSelection(packages) {
488
+ const response = await prompts({
489
+ type: "multiselect",
490
+ name: "selectedPackages",
491
+ message: "Select packages to release",
492
+ choices: packages.map((pkg) => ({
493
+ title: `${pkg.name} (${pkg.version})`,
494
+ value: pkg.name,
495
+ selected: true
496
+ })),
497
+ min: 1,
498
+ hint: "Space to select/deselect. Return to submit."
499
+ });
500
+ if (!response.selectedPackages || response.selectedPackages.length === 0) throw new Error("No packages selected");
501
+ return response.selectedPackages;
502
+ }
503
+ async function promptVersionOverride(packageName, currentVersion, suggestedVersion, suggestedBumpType) {
504
+ const choices = [{
505
+ title: `Use suggested: ${suggestedVersion} (${suggestedBumpType})`,
506
+ value: "suggested"
507
+ }];
508
+ for (const bumpType of [
509
+ "patch",
510
+ "minor",
511
+ "major"
512
+ ]) if (bumpType !== suggestedBumpType) {
513
+ const version = calculateNewVersion(currentVersion, bumpType);
514
+ choices.push({
515
+ title: `${bumpType}: ${version}`,
516
+ value: bumpType
517
+ });
518
+ }
519
+ choices.push({
520
+ title: "Custom version",
521
+ value: "custom"
522
+ });
523
+ const response = await prompts([{
524
+ type: "select",
525
+ name: "choice",
526
+ message: `${packageName} (${currentVersion}):`,
527
+ choices,
528
+ initial: 0
529
+ }, {
530
+ type: (prev) => prev === "custom" ? "text" : null,
531
+ name: "customVersion",
532
+ message: "Enter custom version:",
533
+ initial: suggestedVersion,
534
+ validate: (value) => {
535
+ return /^\d+\.\d+\.\d+(?:[-+].+)?$/.test(value) || "Invalid semver version (e.g., 1.0.0)";
536
+ }
537
+ }]);
538
+ if (response.choice === "suggested") return suggestedVersion;
539
+ else if (response.choice === "custom") return response.customVersion;
540
+ else return calculateNewVersion(currentVersion, response.choice);
541
+ }
542
+ async function promptVersionOverrides(packages) {
543
+ const overrides = /* @__PURE__ */ new Map();
544
+ for (const pkg of packages) {
545
+ const newVersion = await promptVersionOverride(pkg.name, pkg.currentVersion, pkg.suggestedVersion, pkg.bumpType);
546
+ overrides.set(pkg.name, newVersion);
547
+ }
548
+ return overrides;
549
+ }
550
+
551
+ //#endregion
552
+ //#region src/workspace.ts
553
+ const debug = createDebugger("ucdjs:release-scripts:workspace");
554
+ function shouldIncludePackage(pkg, options) {
555
+ if (!options) return true;
556
+ if (options.excludePrivate && pkg.private) return false;
557
+ if (options.included && options.included.length > 0) {
558
+ if (!options.included.includes(pkg.name)) return false;
559
+ }
560
+ if (options.excluded?.includes(pkg.name)) return false;
561
+ return true;
562
+ }
563
+ async function findWorkspacePackages(workspaceRoot, options) {
564
+ const result = await run("pnpm", [
565
+ "-r",
566
+ "ls",
567
+ "--json"
568
+ ], { nodeOptions: {
569
+ cwd: workspaceRoot,
570
+ stdio: "pipe"
571
+ } });
572
+ const rawProjects = JSON.parse(result.stdout);
573
+ const packages = [];
574
+ const allPackageNames = new Set(rawProjects.map((p) => p.name));
575
+ for (const rawProject of rawProjects) {
576
+ const content = await readFile(join(rawProject.path, "package.json"), "utf-8");
577
+ const packageJson = JSON.parse(content);
578
+ if (!shouldIncludePackage(packageJson, options)) {
579
+ debug?.(`Excluding package ${rawProject.name}`);
580
+ continue;
581
+ }
582
+ const workspaceDeps = extractWorkspaceDependencies(rawProject.dependencies, allPackageNames);
583
+ const workspaceDevDeps = extractWorkspaceDependencies(rawProject.devDependencies, allPackageNames);
584
+ packages.push({
585
+ name: rawProject.name,
586
+ version: rawProject.version,
587
+ path: rawProject.path,
588
+ packageJson,
589
+ workspaceDependencies: workspaceDeps,
590
+ workspaceDevDependencies: workspaceDevDeps
591
+ });
592
+ }
593
+ return packages;
594
+ }
595
+ function extractWorkspaceDependencies(dependencies, workspacePackages) {
596
+ if (!dependencies) return [];
597
+ return Object.keys(dependencies).filter((dep) => {
598
+ return workspacePackages.has(dep);
599
+ });
600
+ }
601
+ function buildDependencyGraph(packages) {
602
+ const packagesMap = /* @__PURE__ */ new Map();
603
+ const dependents = /* @__PURE__ */ new Map();
604
+ for (const pkg of packages) {
605
+ packagesMap.set(pkg.name, pkg);
606
+ dependents.set(pkg.name, /* @__PURE__ */ new Set());
607
+ }
608
+ for (const pkg of packages) {
609
+ const allDeps = [...pkg.workspaceDependencies, ...pkg.workspaceDevDependencies];
610
+ for (const dep of allDeps) {
611
+ const depSet = dependents.get(dep);
612
+ if (depSet) depSet.add(pkg.name);
613
+ }
614
+ }
615
+ return {
616
+ packages: packagesMap,
617
+ dependents
618
+ };
619
+ }
620
+ function getPackageUpdateOrder(graph, changedPackages) {
621
+ const result = [];
622
+ const visited = /* @__PURE__ */ new Set();
623
+ const toUpdate = new Set(changedPackages);
624
+ const packagesToProcess = new Set(changedPackages);
625
+ for (const pkg of changedPackages) {
626
+ const deps = graph.dependents.get(pkg);
627
+ if (deps) for (const dep of deps) {
628
+ packagesToProcess.add(dep);
629
+ toUpdate.add(dep);
630
+ }
631
+ }
632
+ function visit(pkgName, level) {
633
+ if (visited.has(pkgName)) return;
634
+ visited.add(pkgName);
635
+ const pkg = graph.packages.get(pkgName);
636
+ if (!pkg) return;
637
+ const allDeps = [...pkg.workspaceDependencies, ...pkg.workspaceDevDependencies];
638
+ let maxDepLevel = level;
639
+ for (const dep of allDeps) if (toUpdate.has(dep)) {
640
+ visit(dep, level);
641
+ const depResult = result.find((r) => r.package.name === dep);
642
+ if (depResult && depResult.level >= maxDepLevel) maxDepLevel = depResult.level + 1;
643
+ }
644
+ result.push({
645
+ package: pkg,
646
+ level: maxDepLevel
647
+ });
648
+ }
649
+ for (const pkg of toUpdate) visit(pkg, 0);
650
+ result.sort((a, b) => a.level - b.level);
651
+ return result;
652
+ }
653
+
654
+ //#endregion
655
+ //#region src/release.ts
656
+ const isCI = process.env.CI === "true";
657
+ async function release(options) {
658
+ const { dryRun: dryRun$1 = false, safeguards = true, workspaceRoot = process.cwd(), releaseBranch = "release/next", githubToken } = options;
659
+ globalOptions.dryRun = dryRun$1;
660
+ if (githubToken.trim() === "" || githubToken == null) throw new Error("GitHub token is required");
661
+ const [owner, repo] = options.repo.split("/");
662
+ if (!owner || !repo) throw new Error(`Invalid repo format: ${options.repo}. Expected "owner/repo".`);
663
+ if (safeguards && !isWorkingDirectoryClean(workspaceRoot)) {
664
+ console.error("Working directory is not clean. Please commit or stash your changes before proceeding.");
665
+ return null;
666
+ }
667
+ const { workspacePackages, packagesToAnalyze: initialPackages } = await discoverPackages(workspaceRoot, options);
668
+ if (initialPackages.length === 0) return null;
669
+ const isPackagePromptEnabled = options.prompts?.packages !== false;
670
+ const isPackagesPreConfigured = Array.isArray(options.packages) || typeof options.packages === "object" && options.packages.included != null;
671
+ let packagesToAnalyze = initialPackages;
672
+ if (!isCI && isPackagePromptEnabled && !isPackagesPreConfigured) {
673
+ const selectedNames = await promptPackageSelection(initialPackages);
674
+ packagesToAnalyze = initialPackages.filter((pkg) => selectedNames.includes(pkg.name));
675
+ }
676
+ const changedPackages = await analyzeCommits(packagesToAnalyze, workspaceRoot);
677
+ if (changedPackages.size === 0) throw new Error("No packages have changes requiring a release");
678
+ let versionUpdates = calculateVersions(workspacePackages, changedPackages);
679
+ const isVersionPromptEnabled = options.prompts?.versions !== false;
680
+ if (!isCI && isVersionPromptEnabled) {
681
+ const versionOverrides = await promptVersionOverrides(versionUpdates.map((u) => ({
682
+ name: u.package.name,
683
+ currentVersion: u.currentVersion,
684
+ suggestedVersion: u.newVersion,
685
+ bumpType: u.bumpType
686
+ })));
687
+ versionUpdates = versionUpdates.map((update) => {
688
+ const overriddenVersion = versionOverrides.get(update.package.name);
689
+ if (overriddenVersion && overriddenVersion !== update.newVersion) return {
690
+ ...update,
691
+ newVersion: overriddenVersion
692
+ };
693
+ return update;
694
+ });
695
+ }
696
+ const allUpdates = createDependentUpdates(getPackageUpdateOrder(buildDependencyGraph(workspacePackages), new Set(versionUpdates.map((u) => u.package.name))), versionUpdates);
697
+ const currentBranch = await getCurrentBranch(workspaceRoot);
698
+ const existingPullRequest = await getExistingPullRequest({
699
+ owner,
700
+ repo,
701
+ branch: releaseBranch,
702
+ githubToken
703
+ });
704
+ const prExists = !!existingPullRequest;
705
+ if (prExists) console.log("Existing pull request found:", existingPullRequest.html_url);
706
+ else console.log("No existing pull request found, will create new one");
707
+ const branchExists = await doesBranchExist(releaseBranch, workspaceRoot);
708
+ if (!branchExists) {
709
+ console.log("Creating release branch:", releaseBranch);
710
+ await createBranch(releaseBranch, currentBranch, workspaceRoot);
711
+ }
712
+ if (!await checkoutBranch(releaseBranch, workspaceRoot)) throw new Error(`Failed to checkout branch: ${releaseBranch}`);
713
+ if (branchExists) {
714
+ console.log("Pulling latest changes from remote");
715
+ if (!await pullLatestChanges(releaseBranch, workspaceRoot)) console.log("Warning: Failed to pull latest changes, continuing anyway");
716
+ }
717
+ console.log("Rebasing release branch onto", currentBranch);
718
+ await rebaseBranch(currentBranch, workspaceRoot);
719
+ await updatePackageJsonFiles(allUpdates);
720
+ if (!await commitChanges("chore: update release versions", workspaceRoot)) {
721
+ console.log("No changes to commit");
722
+ await checkoutBranch(currentBranch, workspaceRoot);
723
+ if (prExists) {
724
+ console.log("No updates needed, PR is already up to date");
725
+ return {
726
+ updates: allUpdates,
727
+ prUrl: existingPullRequest.html_url,
728
+ created: false
729
+ };
730
+ } else {
731
+ console.error("No changes to commit, and no existing PR. Nothing to do.");
732
+ return null;
733
+ }
734
+ }
735
+ console.log("Pushing changes to remote");
736
+ await pushBranch(releaseBranch, workspaceRoot, { forceWithLease: true });
737
+ const prTitle = existingPullRequest?.title || "Release: Update package versions";
738
+ const prBody = generatePRBody(allUpdates);
739
+ const pullRequest = await upsertPullRequest({
740
+ owner,
741
+ repo,
742
+ pullNumber: existingPullRequest?.number,
743
+ title: prTitle,
744
+ body: prBody,
745
+ head: releaseBranch,
746
+ base: currentBranch,
747
+ githubToken
748
+ });
749
+ console.log(prExists ? "Updated pull request:" : "Created pull request:", pullRequest?.html_url);
750
+ await checkoutBranch(currentBranch, workspaceRoot);
751
+ return {
752
+ updates: allUpdates,
753
+ prUrl: pullRequest?.html_url,
754
+ created: !prExists
755
+ };
756
+ }
757
+ async function discoverPackages(workspaceRoot, options) {
758
+ let workspacePackages;
759
+ let packagesToAnalyze;
760
+ if (typeof options.packages === "boolean" && options.packages === true) {
761
+ workspacePackages = await findWorkspacePackages(workspaceRoot, { excludePrivate: false });
762
+ packagesToAnalyze = workspacePackages;
763
+ return {
764
+ workspacePackages,
765
+ packagesToAnalyze
766
+ };
767
+ }
768
+ if (Array.isArray(options.packages)) {
769
+ const packageNames = options.packages;
770
+ workspacePackages = await findWorkspacePackages(workspaceRoot, {
771
+ excludePrivate: false,
772
+ included: packageNames
773
+ });
774
+ packagesToAnalyze = workspacePackages.filter((pkg) => packageNames.includes(pkg.name));
775
+ if (packagesToAnalyze.length !== packageNames.length) {
776
+ const found = new Set(packagesToAnalyze.map((p) => p.name));
777
+ const missing = packageNames.filter((p) => !found.has(p));
778
+ throw new Error(`Packages not found in workspace: ${missing.join(", ")}`);
779
+ }
780
+ return {
781
+ workspacePackages,
782
+ packagesToAnalyze
783
+ };
784
+ }
785
+ workspacePackages = await findWorkspacePackages(workspaceRoot, options.packages);
786
+ packagesToAnalyze = workspacePackages;
787
+ return {
788
+ workspacePackages,
789
+ packagesToAnalyze
790
+ };
791
+ }
792
+ async function analyzeCommits(packages, workspaceRoot) {
793
+ const changedPackages = /* @__PURE__ */ new Map();
794
+ for (const pkg of packages) {
795
+ const bump = await analyzePackageCommits(pkg, workspaceRoot);
796
+ if (bump !== "none") changedPackages.set(pkg.name, bump);
797
+ }
798
+ return changedPackages;
799
+ }
800
+ function calculateVersions(allPackages, changedPackages) {
801
+ const updates = [];
802
+ for (const [pkgName, bump] of changedPackages) {
803
+ const pkg = allPackages.find((p) => p.name === pkgName);
804
+ if (!pkg) continue;
805
+ updates.push(createVersionUpdate(pkg, bump, true));
806
+ }
807
+ return updates;
808
+ }
809
+ async function updatePackageJsonFiles(updates) {
810
+ await Promise.all(updates.map(async (update) => {
811
+ const depUpdates = getDependencyUpdates(update.package, updates);
812
+ await updatePackageJson(update.package, update.newVersion, depUpdates);
813
+ }));
814
+ }
815
+
816
+ //#endregion
817
+ export { release };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@ucdjs/release-scripts",
3
+ "version": "0.0.0",
4
+ "description": "@ucdjs release scripts",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ucdjs/release-scripts.git"
10
+ },
11
+ "exports": {
12
+ ".": "./dist/index.mjs",
13
+ "./package.json": "./package.json"
14
+ },
15
+ "main": "./dist/index.mjs",
16
+ "module": "./dist/index.mjs",
17
+ "types": "./dist/index.d.mts",
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "dependencies": {
22
+ "commit-parser": "0.4.5",
23
+ "debug": "4.4.3",
24
+ "farver": "1.0.0-beta.1",
25
+ "prompts": "2.4.2",
26
+ "tinyexec": "1.0.2"
27
+ },
28
+ "devDependencies": {
29
+ "@luxass/eslint-config": "6.0.1",
30
+ "@types/debug": "4.1.12",
31
+ "@types/node": "22.18.12",
32
+ "@types/prompts": "2.4.9",
33
+ "eslint": "9.39.1",
34
+ "tsdown": "0.16.0",
35
+ "typescript": "5.9.3",
36
+ "vitest": "4.0.4"
37
+ },
38
+ "scripts": {
39
+ "build": "tsdown",
40
+ "dev": "tsdown --watch",
41
+ "test": "vitest --run",
42
+ "test:watch": "vitest",
43
+ "lint": "eslint .",
44
+ "typecheck": "tsc --noEmit"
45
+ }
46
+ }