@ucdjs/release-scripts 0.1.0-beta.22 → 0.1.0-beta.24

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/index.mjs CHANGED
@@ -1,1762 +1,947 @@
1
- import { t as Eta } from "./eta-j5TFRbI4.mjs";
2
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
- import { join, relative } from "node:path";
1
+ import { Console, Context, Data, Effect, Layer, Schema } from "effect";
2
+ import { Command, CommandExecutor } from "@effect/platform";
3
+ import { NodeCommandExecutor, NodeFileSystem } from "@effect/platform-node";
4
+ import * as CommitParser from "commit-parser";
4
5
  import process from "node:process";
5
- import readline from "node:readline";
6
- import farver from "farver";
7
- import mri from "mri";
8
- import { exec } from "tinyexec";
9
- import { dedent } from "@luxass/utils";
10
- import { getCommits, groupByType } from "commit-parser";
11
- import prompts from "prompts";
12
- import { compare, gt } from "semver";
6
+ import semver from "semver";
7
+ import fs from "node:fs/promises";
8
+ import path from "node:path";
13
9
 
14
- //#region src/publish.ts
15
- function publish(_options) {}
16
-
17
- //#endregion
18
- //#region src/shared/utils.ts
19
- const args = mri(process.argv.slice(2));
20
- const isDryRun = !!args.dry;
21
- const isVerbose = !!args.verbose;
22
- const isForce = !!args.force;
23
- const ucdjsReleaseOverridesPath = ".github/ucdjs-release.overrides.json";
24
- const isCI = typeof process.env.CI === "string" && process.env.CI !== "" && process.env.CI.toLowerCase() !== "false";
25
- const logger = {
26
- info: (...args$1) => {
27
- console.info(...args$1);
28
- },
29
- warn: (...args$1) => {
30
- console.warn(` ${farver.yellow("⚠")}`, ...args$1);
31
- },
32
- error: (...args$1) => {
33
- console.error(` ${farver.red("✖")}`, ...args$1);
34
- },
35
- verbose: (...args$1) => {
36
- if (!isVerbose) return;
37
- if (args$1.length === 0) {
38
- console.log();
39
- return;
40
- }
41
- if (args$1.length > 1 && typeof args$1[0] === "string") {
42
- console.log(farver.dim(args$1[0]), ...args$1.slice(1));
43
- return;
10
+ //#region src/services/dependency-graph.service.ts
11
+ var DependencyGraphService = class extends Effect.Service()("@ucdjs/release-scripts/DependencyGraphService", {
12
+ effect: Effect.gen(function* () {
13
+ function buildGraph(packages) {
14
+ const nameToPackage = /* @__PURE__ */ new Map();
15
+ const adjacency = /* @__PURE__ */ new Map();
16
+ const inDegree = /* @__PURE__ */ new Map();
17
+ for (const pkg of packages) {
18
+ nameToPackage.set(pkg.name, pkg);
19
+ adjacency.set(pkg.name, /* @__PURE__ */ new Set());
20
+ inDegree.set(pkg.name, 0);
21
+ }
22
+ for (const pkg of packages) {
23
+ const deps = new Set([...pkg.workspaceDependencies, ...pkg.workspaceDevDependencies]);
24
+ for (const depName of deps) {
25
+ if (!nameToPackage.has(depName)) continue;
26
+ adjacency.get(depName)?.add(pkg.name);
27
+ inDegree.set(pkg.name, (inDegree.get(pkg.name) ?? 0) + 1);
28
+ }
29
+ }
30
+ return {
31
+ nameToPackage,
32
+ adjacency,
33
+ inDegree
34
+ };
44
35
  }
45
- console.log(...args$1);
46
- },
47
- section: (title) => {
48
- console.log();
49
- console.log(` ${farver.bold(title)}`);
50
- console.log(` ${farver.gray("─".repeat(title.length + 2))}`);
51
- },
52
- emptyLine: () => {
53
- console.log();
54
- },
55
- item: (message, ...args$1) => {
56
- console.log(` ${message}`, ...args$1);
57
- },
58
- step: (message) => {
59
- console.log(` ${farver.blue("→")} ${message}`);
60
- },
61
- success: (message) => {
62
- console.log(` ${farver.green("✓")} ${message}`);
63
- },
64
- clearScreen: () => {
65
- const repeatCount = process.stdout.rows - 2;
66
- const blank = repeatCount > 0 ? "\n".repeat(repeatCount) : "";
67
- console.log(blank);
68
- readline.cursorTo(process.stdout, 0, 0);
69
- readline.clearScreenDown(process.stdout);
70
- }
71
- };
72
- async function run(bin, args$1, opts = {}) {
73
- return exec(bin, args$1, {
74
- throwOnError: true,
75
- ...opts,
76
- nodeOptions: {
77
- stdio: "inherit",
78
- ...opts.nodeOptions
36
+ function topologicalOrder(packages) {
37
+ return Effect.gen(function* () {
38
+ const { nameToPackage, adjacency, inDegree } = buildGraph(packages);
39
+ const queue = [];
40
+ const levels = /* @__PURE__ */ new Map();
41
+ for (const [name, degree] of inDegree) if (degree === 0) {
42
+ queue.push(name);
43
+ levels.set(name, 0);
44
+ }
45
+ let queueIndex = 0;
46
+ const ordered = [];
47
+ while (queueIndex < queue.length) {
48
+ const current = queue[queueIndex++];
49
+ const currentLevel = levels.get(current) ?? 0;
50
+ const pkg = nameToPackage.get(current);
51
+ if (pkg) ordered.push({
52
+ package: pkg,
53
+ level: currentLevel
54
+ });
55
+ for (const neighbor of adjacency.get(current) ?? []) {
56
+ const nextLevel = currentLevel + 1;
57
+ if (nextLevel > (levels.get(neighbor) ?? 0)) levels.set(neighbor, nextLevel);
58
+ const newDegree = (inDegree.get(neighbor) ?? 0) - 1;
59
+ inDegree.set(neighbor, newDegree);
60
+ if (newDegree === 0) queue.push(neighbor);
61
+ }
62
+ }
63
+ if (ordered.length !== packages.length) {
64
+ const processed = new Set(ordered.map((o) => o.package.name));
65
+ const unprocessed = packages.filter((p) => !processed.has(p.name)).map((p) => p.name);
66
+ return yield* Effect.fail(/* @__PURE__ */ new Error(`Cycle detected in workspace dependencies. Packages involved: ${unprocessed.join(", ")}`));
67
+ }
68
+ return ordered;
69
+ });
79
70
  }
80
- });
81
- }
82
- async function dryRun(bin, args$1, opts) {
83
- return logger.verbose(farver.blue(`[dryrun] ${bin} ${args$1.join(" ")}`), opts || "");
84
- }
85
- const runIfNotDry = isDryRun ? dryRun : run;
86
- function exitWithError(message, hint) {
87
- logger.error(farver.bold(message));
88
- if (hint) console.error(farver.gray(` ${hint}`));
89
- process.exit(1);
90
- }
91
- if (isDryRun || isVerbose || isForce) {
92
- logger.verbose(farver.inverse(farver.yellow(" Running with special flags ")));
93
- logger.verbose({
94
- isDryRun,
95
- isVerbose,
96
- isForce
97
- });
98
- logger.verbose();
99
- }
71
+ return { topologicalOrder };
72
+ }),
73
+ dependencies: []
74
+ }) {};
100
75
 
101
76
  //#endregion
102
- //#region src/core/git.ts
103
- /**
104
- * Check if the working directory is clean (no uncommitted changes)
105
- * @param {string} workspaceRoot - The root directory of the workspace
106
- * @returns {Promise<boolean>} A Promise resolving to true if clean, false otherwise
107
- */
108
- async function isWorkingDirectoryClean(workspaceRoot) {
109
- try {
110
- if ((await run("git", ["status", "--porcelain"], { nodeOptions: {
111
- cwd: workspaceRoot,
112
- stdio: "pipe"
113
- } })).stdout.trim() !== "") return false;
114
- return true;
115
- } catch (err) {
116
- logger.error("Error checking git status:", err);
117
- return false;
118
- }
119
- }
120
- /**
121
- * Check if a git branch exists locally
122
- * @param {string} branch - The branch name to check
123
- * @param {string} workspaceRoot - The root directory of the workspace
124
- * @returns {Promise<boolean>} Promise resolving to true if branch exists, false otherwise
125
- */
126
- async function doesBranchExist(branch, workspaceRoot) {
127
- try {
128
- await run("git", [
129
- "rev-parse",
130
- "--verify",
131
- branch
132
- ], { nodeOptions: {
133
- cwd: workspaceRoot,
134
- stdio: "pipe"
135
- } });
136
- return true;
137
- } catch {
138
- return false;
139
- }
140
- }
141
- /**
142
- * Retrieves the default branch name from the remote repository.
143
- * Falls back to "main" if the default branch cannot be determined.
144
- * @returns {Promise<string>} A Promise resolving to the default branch name as a string.
145
- */
146
- async function getDefaultBranch(workspaceRoot) {
147
- try {
148
- const match = (await run("git", ["symbolic-ref", "refs/remotes/origin/HEAD"], { nodeOptions: {
149
- cwd: workspaceRoot,
150
- stdio: "pipe"
151
- } })).stdout.trim().match(/^refs\/remotes\/origin\/(.+)$/);
152
- if (match && match[1]) return match[1];
153
- return "main";
154
- } catch {
155
- return "main";
156
- }
157
- }
158
- /**
159
- * Retrieves the name of the current branch in the repository.
160
- * @param {string} workspaceRoot - The root directory of the workspace
161
- * @returns {Promise<string>} A Promise resolving to the current branch name as a string
162
- */
163
- async function getCurrentBranch(workspaceRoot) {
164
- try {
165
- return (await run("git", [
166
- "rev-parse",
167
- "--abbrev-ref",
168
- "HEAD"
169
- ], { nodeOptions: {
170
- cwd: workspaceRoot,
171
- stdio: "pipe"
172
- } })).stdout.trim();
173
- } catch (err) {
174
- logger.error("Error getting current branch:", err);
175
- throw err;
176
- }
177
- }
178
- /**
179
- * Retrieves the list of available branches in the repository.
180
- * @param {string} workspaceRoot - The root directory of the workspace
181
- * @returns {Promise<string[]>} A Promise resolving to an array of branch names
182
- */
183
- async function getAvailableBranches(workspaceRoot) {
184
- try {
185
- return (await run("git", ["branch", "--list"], { nodeOptions: {
186
- cwd: workspaceRoot,
187
- stdio: "pipe"
188
- } })).stdout.split("\n").map((line) => line.replace("*", "").trim()).filter((line) => line.length > 0);
189
- } catch (err) {
190
- logger.error("Error getting available branches:", err);
191
- throw err;
192
- }
193
- }
194
- /**
195
- * Creates a new branch from the specified base branch.
196
- * @param {string} branch - The name of the new branch to create
197
- * @param {string} base - The base branch to create the new branch from
198
- * @param {string} workspaceRoot - The root directory of the workspace
199
- * @returns {Promise<void>} A Promise that resolves when the branch is created
200
- */
201
- async function createBranch(branch, base, workspaceRoot) {
202
- try {
203
- logger.info(`Creating branch: ${farver.green(branch)} from ${farver.cyan(base)}`);
204
- await runIfNotDry("git", [
205
- "branch",
206
- branch,
207
- base
208
- ], { nodeOptions: {
209
- cwd: workspaceRoot,
210
- stdio: "pipe"
211
- } });
212
- } catch {
213
- exitWithError(`Failed to create branch: ${branch}`, `Make sure the branch doesn't already exist and you have a clean working directory`);
214
- }
215
- }
216
- async function checkoutBranch(branch, workspaceRoot) {
217
- try {
218
- logger.info(`Switching to branch: ${farver.green(branch)}`);
219
- const match = (await run("git", ["checkout", branch], { nodeOptions: {
220
- cwd: workspaceRoot,
221
- stdio: "pipe"
222
- } })).stderr.trim().match(/Switched to branch '(.+)'/);
223
- if (match && match[1] === branch) {
224
- logger.info(`Successfully switched to branch: ${farver.green(branch)}`);
225
- return true;
226
- }
227
- return false;
228
- } catch {
229
- return false;
230
- }
231
- }
232
- async function pullLatestChanges(branch, workspaceRoot) {
233
- try {
234
- await run("git", [
235
- "pull",
236
- "origin",
237
- branch
238
- ], { nodeOptions: {
239
- cwd: workspaceRoot,
240
- stdio: "pipe"
241
- } });
242
- return true;
243
- } catch {
244
- return false;
245
- }
246
- }
247
- async function rebaseBranch(ontoBranch, workspaceRoot) {
248
- try {
249
- logger.info(`Rebasing onto: ${farver.cyan(ontoBranch)}`);
250
- await runIfNotDry("git", ["rebase", ontoBranch], { nodeOptions: {
251
- cwd: workspaceRoot,
252
- stdio: "pipe"
253
- } });
254
- return true;
255
- } catch {
256
- exitWithError(`Failed to rebase onto: ${ontoBranch}`, `You may have merge conflicts. Run 'git rebase --abort' to undo the rebase`);
257
- }
258
- }
259
- async function isBranchAheadOfRemote(branch, workspaceRoot) {
260
- try {
261
- const result = await run("git", [
262
- "rev-list",
263
- `origin/${branch}..${branch}`,
264
- "--count"
265
- ], { nodeOptions: {
266
- cwd: workspaceRoot,
267
- stdio: "pipe"
268
- } });
269
- return Number.parseInt(result.stdout.trim(), 10) > 0;
270
- } catch {
271
- return true;
272
- }
273
- }
274
- async function commitChanges(message, workspaceRoot) {
275
- try {
276
- await run("git", ["add", "."], { nodeOptions: {
277
- cwd: workspaceRoot,
278
- stdio: "pipe"
279
- } });
280
- if (await isWorkingDirectoryClean(workspaceRoot)) return false;
281
- logger.info(`Committing changes: ${farver.dim(message)}`);
282
- await runIfNotDry("git", [
283
- "commit",
284
- "-m",
285
- message
286
- ], { nodeOptions: {
287
- cwd: workspaceRoot,
288
- stdio: "pipe"
289
- } });
290
- return true;
291
- } catch {
292
- exitWithError(`Failed to commit changes`, `Make sure you have git configured properly with user.name and user.email`);
293
- }
294
- }
295
- async function pushBranch(branch, workspaceRoot, options) {
296
- try {
297
- const args$1 = [
298
- "push",
299
- "origin",
300
- branch
301
- ];
302
- if (options?.forceWithLease) {
303
- args$1.push("--force-with-lease");
304
- logger.info(`Pushing branch: ${farver.green(branch)} ${farver.dim("(with lease)")}`);
305
- } else if (options?.force) {
306
- args$1.push("--force");
307
- logger.info(`Force pushing branch: ${farver.green(branch)}`);
308
- } else logger.info(`Pushing branch: ${farver.green(branch)}`);
309
- await runIfNotDry("git", args$1, { nodeOptions: {
310
- cwd: workspaceRoot,
311
- stdio: "pipe"
312
- } });
313
- return true;
314
- } catch {
315
- exitWithError(`Failed to push branch: ${branch}`, `Make sure you have permission to push to the remote repository`);
316
- }
317
- }
318
- async function readFileFromGit(workspaceRoot, ref, filePath) {
319
- try {
320
- return (await run("git", ["show", `${ref}:${filePath}`], { nodeOptions: {
321
- cwd: workspaceRoot,
322
- stdio: "pipe"
323
- } })).stdout;
324
- } catch {
325
- return null;
326
- }
327
- }
328
- async function getMostRecentPackageTag(workspaceRoot, packageName) {
329
- try {
330
- const { stdout } = await run("git", [
331
- "tag",
332
- "--list",
333
- `${packageName}@*`
334
- ], { nodeOptions: {
335
- cwd: workspaceRoot,
336
- stdio: "pipe"
337
- } });
338
- const tags = stdout.split("\n").map((tag) => tag.trim()).filter(Boolean);
339
- if (tags.length === 0) return;
340
- return tags.reverse()[0];
341
- } catch (err) {
342
- logger.warn(`Failed to get tags for package ${packageName}: ${err.message}`);
343
- return;
344
- }
345
- }
346
- /**
347
- * Builds a mapping of commit SHAs to the list of files changed in each commit
348
- * within a given inclusive range.
349
- *
350
- * Internally runs:
351
- * git log --name-only --format=%H <from>^..<to>
352
- *
353
- * Notes
354
- * - This includes the commit identified by `from` (via `from^..to`).
355
- * - Order of commits in the resulting Map follows `git log` output
356
- * (reverse chronological, newest first).
357
- * - On failure (e.g., invalid refs), the function returns null.
358
- *
359
- * @param {string} workspaceRoot Absolute path to the git repository root used as cwd.
360
- * @param {string} from Starting commit/ref (inclusive).
361
- * @param {string} to Ending commit/ref (inclusive).
362
- * @returns {Promise<Map<string, string[]> | null>} Promise resolving to a Map where keys are commit SHAs and values are
363
- * arrays of file paths changed by that commit, or null on error.
364
- */
365
- async function getGroupedFilesByCommitSha(workspaceRoot, from, to) {
366
- const commitsMap = /* @__PURE__ */ new Map();
367
- try {
368
- const { stdout } = await run("git", [
369
- "log",
370
- "--name-only",
371
- "--format=%H",
372
- `${from}^..${to}`
373
- ], { nodeOptions: {
374
- cwd: workspaceRoot,
375
- stdio: "pipe"
376
- } });
377
- const lines = stdout.trim().split("\n").filter((line) => line.trim() !== "");
378
- let currentSha = null;
379
- const HASH_REGEX = /^[0-9a-f]{40}$/i;
380
- for (const line of lines) {
381
- const trimmedLine = line.trim();
382
- if (HASH_REGEX.test(trimmedLine)) {
383
- currentSha = trimmedLine;
384
- commitsMap.set(currentSha, []);
385
- continue;
386
- }
387
- if (currentSha === null) continue;
388
- commitsMap.get(currentSha).push(trimmedLine);
389
- }
390
- return commitsMap;
391
- } catch {
392
- return null;
393
- }
394
- }
77
+ //#region src/errors.ts
78
+ var GitCommandError = class extends Data.TaggedError("GitCommandError") {};
79
+ var ExternalCommitParserError = class extends Data.TaggedError("ExternalCommitParserError") {};
80
+ var WorkspaceError = class extends Data.TaggedError("WorkspaceError") {};
81
+ var GitHubError = class extends Data.TaggedError("GitHubError") {};
82
+ var VersionCalculationError = class extends Data.TaggedError("VersionCalculationError") {};
83
+ var OverridesLoadError = class extends Data.TaggedError("OverridesLoadError") {};
395
84
 
396
85
  //#endregion
397
- //#region src/core/changelog.ts
398
- const globalAuthorCache = /* @__PURE__ */ new Map();
399
- const DEFAULT_CHANGELOG_TEMPLATE = dedent`
400
- <% if (it.previousVersion) { -%>
401
- ## [<%= it.version %>](<%= it.compareUrl %>) (<%= it.date %>)
402
- <% } else { -%>
403
- ## <%= it.version %> (<%= it.date %>)
404
- <% } %>
405
-
406
- <% it.groups.forEach((group) => { %>
407
- <% if (group.commits.length > 0) { %>
408
-
409
- ### <%= group.title %>
410
- <% group.commits.forEach((commit) => { %>
411
-
412
- * <%= commit.line %>
413
- <% }); %>
414
-
415
- <% } %>
416
- <% }); %>
417
- `;
418
- async function generateChangelogEntry(options) {
419
- const { packageName, version, previousVersion, date, commits, owner, repo, groups, template, githubClient } = options;
420
- const compareUrl = previousVersion ? `https://github.com/${owner}/${repo}/compare/${packageName}@${previousVersion}...${packageName}@${version}` : void 0;
421
- const grouped = groupByType(commits, {
422
- includeNonConventional: false,
423
- mergeKeys: Object.fromEntries(groups.map((g) => [g.name, g.types]))
424
- });
425
- const commitAuthors = await resolveCommitAuthors(commits, githubClient);
426
- const templateData = {
427
- packageName,
428
- version,
429
- previousVersion,
430
- date,
431
- compareUrl,
86
+ //#region src/options.ts
87
+ const DEFAULT_PR_BODY_TEMPLATE = `## Summary\n\nThis PR contains the following changes:\n\n- Updated package versions\n- Updated changelogs\n\n## Packages\n\nThe following packages will be released:\n\n{{packages}}`;
88
+ const DEFAULT_CHANGELOG_TEMPLATE = `# Changelog\n\n{{releases}}`;
89
+ const DEFAULT_TYPES = {
90
+ feat: { title: "🚀 Features" },
91
+ fix: { title: "🐞 Bug Fixes" },
92
+ refactor: { title: "🔧 Code Refactoring" },
93
+ perf: { title: "🏎 Performance" },
94
+ docs: { title: "📚 Documentation" },
95
+ style: { title: "🎨 Styles" }
96
+ };
97
+ function normalizeReleaseScriptsOptions(options) {
98
+ const { workspaceRoot = process.cwd(), githubToken = "", repo: fullRepo, packages = true, branch = {}, globalCommitMode = "dependencies", pullRequest = {}, changelog = {}, types = {}, dryRun = false } = options;
99
+ const token = githubToken.trim();
100
+ if (!token) throw new Error("GitHub token is required. Pass it in via options.");
101
+ if (!fullRepo || !fullRepo.trim() || !fullRepo.includes("/")) throw new Error("Repository (repo) is required. Specify in 'owner/repo' format (e.g., 'octocat/hello-world').");
102
+ const [owner, repo] = fullRepo.split("/");
103
+ if (!owner || !repo) throw new Error(`Invalid repo format: "${fullRepo}". Expected format: "owner/repo" (e.g., "octocat/hello-world").`);
104
+ return {
105
+ dryRun,
106
+ workspaceRoot,
107
+ githubToken: token,
432
108
  owner,
433
109
  repo,
434
- groups: groups.map((group) => {
435
- const commitsInGroup = grouped.get(group.name) ?? [];
436
- if (commitsInGroup.length > 0) logger.verbose(`Found ${commitsInGroup.length} commits for group "${group.name}".`);
437
- const formattedCommits = commitsInGroup.map((commit) => ({ line: formatCommitLine({
438
- commit,
439
- owner,
440
- repo,
441
- authors: commitAuthors.get(commit.hash) ?? []
442
- }) }));
443
- return {
444
- name: group.name,
445
- title: group.title,
446
- commits: formattedCommits
447
- };
448
- })
110
+ packages: typeof packages === "object" && !Array.isArray(packages) ? {
111
+ exclude: packages.exclude ?? [],
112
+ include: packages.include ?? [],
113
+ excludePrivate: packages.excludePrivate ?? false
114
+ } : packages,
115
+ branch: {
116
+ release: branch.release ?? "release/next",
117
+ default: branch.default ?? "main"
118
+ },
119
+ globalCommitMode,
120
+ pullRequest: {
121
+ title: pullRequest.title ?? "chore: release new version",
122
+ body: pullRequest.body ?? DEFAULT_PR_BODY_TEMPLATE
123
+ },
124
+ changelog: {
125
+ enabled: changelog.enabled ?? true,
126
+ template: changelog.template ?? DEFAULT_CHANGELOG_TEMPLATE,
127
+ emojis: changelog.emojis ?? true
128
+ },
129
+ types: options.types ? {
130
+ ...DEFAULT_TYPES,
131
+ ...types
132
+ } : DEFAULT_TYPES
449
133
  };
450
- const eta = new Eta();
451
- const templateToUse = template || DEFAULT_CHANGELOG_TEMPLATE;
452
- return eta.renderString(templateToUse, templateData).trim();
453
134
  }
454
- async function updateChangelog(options) {
455
- const { version, previousVersion, commits, date, normalizedOptions, workspacePackage, githubClient } = options;
456
- const changelogPath = join(workspacePackage.path, "CHANGELOG.md");
457
- const changelogRelativePath = relative(normalizedOptions.workspaceRoot, join(workspacePackage.path, "CHANGELOG.md"));
458
- const existingContent = await readFileFromGit(normalizedOptions.workspaceRoot, normalizedOptions.branch.default, changelogRelativePath);
459
- logger.verbose("Existing content found: ", Boolean(existingContent));
460
- const newEntry = await generateChangelogEntry({
461
- packageName: workspacePackage.name,
462
- version,
463
- previousVersion,
464
- date,
465
- commits,
466
- owner: normalizedOptions.owner,
467
- repo: normalizedOptions.repo,
468
- groups: normalizedOptions.groups,
469
- template: normalizedOptions.changelog?.template,
470
- githubClient
471
- });
472
- let updatedContent;
473
- if (!existingContent) {
474
- updatedContent = `# ${workspacePackage.name}\n\n${newEntry}\n`;
475
- await writeFile(changelogPath, updatedContent, "utf-8");
476
- return;
477
- }
478
- const parsed = parseChangelog(existingContent);
479
- const lines = existingContent.split("\n");
480
- const existingVersionIndex = parsed.versions.findIndex((v) => v.version === version);
481
- if (existingVersionIndex !== -1) {
482
- const existingVersion = parsed.versions[existingVersionIndex];
483
- const before = lines.slice(0, existingVersion.lineStart);
484
- const after = lines.slice(existingVersion.lineEnd + 1);
485
- updatedContent = [
486
- ...before,
487
- newEntry,
488
- ...after
489
- ].join("\n");
490
- } else {
491
- const insertAt = parsed.headerLineEnd + 1;
492
- const before = lines.slice(0, insertAt);
493
- const after = lines.slice(insertAt);
494
- if (before.length > 0 && before[before.length - 1] !== "") before.push("");
495
- updatedContent = [
496
- ...before,
497
- newEntry,
498
- "",
499
- ...after
500
- ].join("\n");
501
- }
502
- await writeFile(changelogPath, updatedContent, "utf-8");
503
- }
504
- async function resolveCommitAuthors(commits, githubClient) {
505
- const authorsToResolve = /* @__PURE__ */ new Set();
506
- const commitAuthors = /* @__PURE__ */ new Map();
507
- for (const commit of commits) {
508
- const authorsForCommit = [];
509
- commit.authors.forEach((author, idx) => {
510
- if (!author.email || !author.name) return;
511
- let info = globalAuthorCache.get(author.email);
512
- if (!info) {
513
- info = {
514
- commits: [],
515
- name: author.name,
516
- email: author.email
517
- };
518
- globalAuthorCache.set(author.email, info);
519
- }
520
- if (idx === 0) info.commits.push(commit.shortHash);
521
- authorsForCommit.push(info);
522
- if (!info.login) authorsToResolve.add(info);
135
+ var ReleaseScriptsOptions = class extends Context.Tag("@ucdjs/release-scripts/ReleaseScriptsOptions")() {};
136
+
137
+ //#endregion
138
+ //#region src/services/git.service.ts
139
+ var GitService = class extends Effect.Service()("@ucdjs/release-scripts/GitService", {
140
+ effect: Effect.gen(function* () {
141
+ const executor = yield* CommandExecutor.CommandExecutor;
142
+ const config = yield* ReleaseScriptsOptions;
143
+ const execGitCommand = (args) => executor.string(Command.make("git", ...args).pipe(Command.workingDirectory(config.workspaceRoot))).pipe(Effect.mapError((err) => {
144
+ return new GitCommandError({
145
+ command: `git ${args.join(" ")}`,
146
+ stderr: err.message
147
+ });
148
+ }));
149
+ const execGitCommandIfNotDry = config.dryRun ? (args) => Effect.succeed(`Dry run mode: skipping git command "git ${args.join(" ")}"`) : execGitCommand;
150
+ const isWithinRepository = Effect.gen(function* () {
151
+ return (yield* execGitCommand(["rev-parse", "--is-inside-work-tree"]).pipe(Effect.catchAll(() => Effect.succeed("false")))).trim() === "true";
523
152
  });
524
- commitAuthors.set(commit.hash, authorsForCommit);
525
- }
526
- await Promise.all(Array.from(authorsToResolve).map((info) => githubClient.resolveAuthorInfo(info)));
527
- return commitAuthors;
528
- }
529
- function formatCommitLine({ commit, owner, repo, authors }) {
530
- const commitUrl = `https://github.com/${owner}/${repo}/commit/${commit.hash}`;
531
- let line = `${commit.description}`;
532
- const references = commit.references ?? [];
533
- if (references.length > 0) logger.verbose("Located references in commit", references.length);
534
- for (const ref of references) {
535
- if (!ref.value) continue;
536
- const number = Number.parseInt(ref.value.replace(/^#/, ""), 10);
537
- if (Number.isNaN(number)) continue;
538
- if (ref.type === "issue") {
539
- line += ` ([Issue ${ref.value}](https://github.com/${owner}/${repo}/issues/${number}))`;
540
- continue;
153
+ const listBranches = Effect.gen(function* () {
154
+ return (yield* execGitCommand(["branch", "--list"])).trim().split("\n").filter((line) => line.length > 0).map((line) => line.replace(/^\* /, "").trim()).map((line) => line.trim());
155
+ });
156
+ const isWorkingDirectoryClean = Effect.gen(function* () {
157
+ return (yield* execGitCommand(["status", "--porcelain"])).trim().length === 0;
158
+ });
159
+ function doesBranchExist(branch) {
160
+ return listBranches.pipe(Effect.map((branches) => branches.includes(branch)));
541
161
  }
542
- line += ` ([PR ${ref.value}](https://github.com/${owner}/${repo}/pull/${number}))`;
543
- }
544
- line += ` ([${commit.shortHash}](${commitUrl}))`;
545
- if (authors.length > 0) {
546
- const authorList = authors.map((author) => {
547
- if (author.login) return `[@${author.login}](https://github.com/${author.login})`;
548
- return author.name;
549
- }).join(", ");
550
- line += ` (by ${authorList})`;
551
- }
552
- return line;
553
- }
554
- function parseChangelog(content) {
555
- const lines = content.split("\n");
556
- let packageName = null;
557
- let headerLineEnd = -1;
558
- const versions = [];
559
- for (let i = 0; i < lines.length; i++) {
560
- const line = lines[i].trim();
561
- if (line.startsWith("# ")) {
562
- packageName = line.slice(2).trim();
563
- headerLineEnd = i;
564
- break;
162
+ function createBranch(branch, base = config.branch.default) {
163
+ return execGitCommandIfNotDry([
164
+ "branch",
165
+ branch,
166
+ base
167
+ ]);
565
168
  }
566
- }
567
- for (let i = headerLineEnd + 1; i < lines.length; i++) {
568
- const line = lines[i].trim();
569
- if (line.startsWith("## ")) {
570
- const versionMatch = line.match(/##\s+(?:<small>)?\[?([^\](\s<]+)/);
571
- if (versionMatch) {
572
- const version = versionMatch[1];
573
- const lineStart = i;
574
- let lineEnd = lines.length - 1;
575
- for (let j = i + 1; j < lines.length; j++) if (lines[j].trim().startsWith("## ")) {
576
- lineEnd = j - 1;
577
- break;
169
+ const getBranch = Effect.gen(function* () {
170
+ return (yield* execGitCommand([
171
+ "rev-parse",
172
+ "--abbrev-ref",
173
+ "HEAD"
174
+ ])).trim();
175
+ });
176
+ function checkoutBranch(branch) {
177
+ return execGitCommand(["checkout", branch]);
178
+ }
179
+ function stageChanges(files) {
180
+ return Effect.gen(function* () {
181
+ if (files.length === 0) return yield* Effect.fail(/* @__PURE__ */ new Error("No files to stage."));
182
+ return yield* execGitCommandIfNotDry(["add", ...files]);
183
+ });
184
+ }
185
+ function writeCommit(message) {
186
+ return execGitCommandIfNotDry([
187
+ "commit",
188
+ "-m",
189
+ message
190
+ ]);
191
+ }
192
+ function pushChanges(branch, remote = "origin") {
193
+ return execGitCommandIfNotDry([
194
+ "push",
195
+ remote,
196
+ branch
197
+ ]);
198
+ }
199
+ function readFile(filePath, ref = "HEAD") {
200
+ return execGitCommand(["show", `${ref}:${filePath}`]);
201
+ }
202
+ function getMostRecentPackageTag(packageName) {
203
+ return execGitCommand([
204
+ "tag",
205
+ "--list",
206
+ "--sort=-version:refname",
207
+ `${packageName}@*`
208
+ ]).pipe(Effect.map((tags) => {
209
+ return tags.trim().split("\n").map((tag) => tag.trim()).filter((tag) => tag.length > 0)[0] || null;
210
+ }), Effect.flatMap((tag) => {
211
+ if (tag === null) return Effect.succeed(null);
212
+ return execGitCommand(["rev-parse", tag]).pipe(Effect.map((sha) => ({
213
+ name: tag,
214
+ sha: sha.trim()
215
+ })));
216
+ }));
217
+ }
218
+ function getCommits(options) {
219
+ return Effect.tryPromise({
220
+ try: async () => CommitParser.getCommits({
221
+ from: options?.from,
222
+ to: options?.to,
223
+ folder: options?.folder,
224
+ cwd: config.workspaceRoot
225
+ }),
226
+ catch: (e) => new ExternalCommitParserError({
227
+ message: `commit-parser getCommits`,
228
+ cause: e instanceof Error ? e.message : String(e)
229
+ })
230
+ });
231
+ }
232
+ function filesChangesBetweenRefs(from, to) {
233
+ const commitsMap = /* @__PURE__ */ new Map();
234
+ return execGitCommand([
235
+ "log",
236
+ "--name-only",
237
+ "--format=%H",
238
+ `${from}^..${to}`
239
+ ]).pipe(Effect.map((output) => {
240
+ const lines = output.trim().split("\n").filter((line) => line.trim() !== "");
241
+ let currentSha = null;
242
+ const HASH_REGEX = /^[0-9a-f]{40}$/i;
243
+ for (const line of lines) {
244
+ const trimmedLine = line.trim();
245
+ if (HASH_REGEX.test(trimmedLine)) {
246
+ currentSha = trimmedLine;
247
+ commitsMap.set(currentSha, []);
248
+ continue;
249
+ }
250
+ if (currentSha === null) continue;
251
+ commitsMap.get(currentSha).push(trimmedLine);
578
252
  }
579
- const versionContent = lines.slice(lineStart, lineEnd + 1).join("\n");
580
- versions.push({
581
- version,
582
- lineStart,
583
- lineEnd,
584
- content: versionContent
585
- });
253
+ return commitsMap;
254
+ }));
255
+ }
256
+ const assertWorkspaceReady = Effect.gen(function* () {
257
+ if (!(yield* isWithinRepository)) return yield* Effect.fail(/* @__PURE__ */ new Error("Not within a Git repository."));
258
+ if (!(yield* isWorkingDirectoryClean)) return yield* Effect.fail(/* @__PURE__ */ new Error("Working directory is not clean."));
259
+ return true;
260
+ });
261
+ return {
262
+ branches: {
263
+ list: listBranches,
264
+ exists: doesBranchExist,
265
+ create: createBranch,
266
+ checkout: checkoutBranch,
267
+ get: getBranch
268
+ },
269
+ commits: {
270
+ stage: stageChanges,
271
+ write: writeCommit,
272
+ push: pushChanges,
273
+ get: getCommits,
274
+ filesChangesBetweenRefs
275
+ },
276
+ tags: { mostRecentForPackage: getMostRecentPackageTag },
277
+ workspace: {
278
+ readFile,
279
+ isWithinRepository,
280
+ isWorkingDirectoryClean,
281
+ assertWorkspaceReady
586
282
  }
283
+ };
284
+ }),
285
+ dependencies: [NodeCommandExecutor.layer]
286
+ }) {};
287
+
288
+ //#endregion
289
+ //#region src/services/github.service.ts
290
+ const PullRequestSchema = Schema.Struct({
291
+ number: Schema.Number,
292
+ title: Schema.String,
293
+ body: Schema.String,
294
+ head: Schema.Struct({
295
+ ref: Schema.String,
296
+ sha: Schema.String
297
+ }),
298
+ base: Schema.Struct({
299
+ ref: Schema.String,
300
+ sha: Schema.String
301
+ }),
302
+ state: Schema.Literal("open", "closed", "merged"),
303
+ draft: Schema.Boolean,
304
+ mergeable: Schema.NullOr(Schema.Boolean),
305
+ url: Schema.String,
306
+ html_url: Schema.String
307
+ });
308
+ const CreatePullRequestOptionsSchema = Schema.Struct({
309
+ title: Schema.String,
310
+ body: Schema.NullOr(Schema.String),
311
+ head: Schema.String,
312
+ base: Schema.String,
313
+ draft: Schema.optional(Schema.Boolean)
314
+ });
315
+ const UpdatePullRequestOptionsSchema = Schema.Struct({
316
+ title: Schema.optional(Schema.String),
317
+ body: Schema.optional(Schema.String),
318
+ state: Schema.optional(Schema.Literal("open", "closed"))
319
+ });
320
+ const CommitStatusSchema = Schema.Struct({
321
+ state: Schema.Literal("pending", "success", "error", "failure"),
322
+ target_url: Schema.optional(Schema.String),
323
+ description: Schema.optional(Schema.String),
324
+ context: Schema.String
325
+ });
326
+ const RepositoryInfoSchema = Schema.Struct({
327
+ owner: Schema.String,
328
+ repo: Schema.String
329
+ });
330
+ var GitHubService = class extends Effect.Service()("@ucdjs/release-scripts/GitHubService", {
331
+ effect: Effect.gen(function* () {
332
+ const config = yield* ReleaseScriptsOptions;
333
+ function makeRequest(endpoint, schema, options = {}) {
334
+ const url = `https://api.github.com/repos/${config.owner}/${config.repo}/${endpoint}`;
335
+ return Effect.tryPromise({
336
+ try: async () => {
337
+ const res = await fetch(url, {
338
+ ...options,
339
+ headers: {
340
+ "Authorization": `token ${config.githubToken}`,
341
+ "Accept": "application/vnd.github.v3+json",
342
+ "Content-Type": "application/json",
343
+ "User-Agent": "ucdjs-release-scripts (https://github.com/ucdjs/release-scripts)",
344
+ ...options.headers
345
+ }
346
+ });
347
+ if (!res.ok) {
348
+ const text = await res.text();
349
+ throw new Error(`GitHub API request failed with status ${res.status}: ${text}`);
350
+ }
351
+ if (res.status === 204) return;
352
+ return res.json();
353
+ },
354
+ catch: (e) => new GitHubError({
355
+ message: String(e),
356
+ operation: "request",
357
+ cause: e
358
+ })
359
+ }).pipe(Effect.flatMap((json) => json === void 0 ? Effect.succeed(void 0) : Schema.decodeUnknown(schema)(json).pipe(Effect.mapError((e) => new GitHubError({
360
+ message: "Failed to decode GitHub response",
361
+ operation: "request",
362
+ cause: e
363
+ })))));
587
364
  }
588
- }
589
- return {
590
- packageName,
591
- versions,
592
- headerLineEnd
593
- };
594
- }
365
+ function getPullRequestByBranch(branch) {
366
+ const head = branch.includes(":") ? branch : `${config.owner}:${branch}`;
367
+ return makeRequest(`pulls?state=open&head=${encodeURIComponent(head)}`, Schema.Array(PullRequestSchema)).pipe(Effect.map((pulls) => pulls.length > 0 ? pulls[0] : null), Effect.mapError((e) => new GitHubError({
368
+ message: e.message,
369
+ operation: "getPullRequestByBranch",
370
+ cause: e.cause
371
+ })));
372
+ }
373
+ return { getPullRequestByBranch };
374
+ }),
375
+ dependencies: []
376
+ }) {};
595
377
 
596
378
  //#endregion
597
- //#region src/core/github.ts
598
- var GitHubClient = class {
599
- owner;
600
- repo;
601
- githubToken;
602
- apiBase = "https://api.github.com";
603
- constructor({ owner, repo, githubToken }) {
604
- this.owner = owner;
605
- this.repo = repo;
606
- this.githubToken = githubToken;
607
- }
608
- async request(path, init = {}) {
609
- const url = path.startsWith("http") ? path : `${this.apiBase}${path}`;
610
- const res = await fetch(url, {
611
- ...init,
612
- headers: {
613
- ...init.headers,
614
- "Accept": "application/vnd.github.v3+json",
615
- "Authorization": `token ${this.githubToken}`,
616
- "User-Agent": "ucdjs-release-scripts (+https://github.com/ucdjs/ucdjs-release-scripts)"
379
+ //#region src/services/workspace.service.ts
380
+ const DependencyObjectSchema = Schema.Record({
381
+ key: Schema.String,
382
+ value: Schema.String
383
+ });
384
+ const PackageJsonSchema = Schema.Struct({
385
+ name: Schema.String,
386
+ private: Schema.optional(Schema.Boolean),
387
+ version: Schema.optional(Schema.String),
388
+ dependencies: Schema.optional(DependencyObjectSchema),
389
+ devDependencies: Schema.optional(DependencyObjectSchema),
390
+ peerDependencies: Schema.optional(DependencyObjectSchema)
391
+ });
392
+ const WorkspacePackageSchema = Schema.Struct({
393
+ name: Schema.String,
394
+ version: Schema.String,
395
+ path: Schema.String,
396
+ packageJson: PackageJsonSchema,
397
+ workspaceDependencies: Schema.Array(Schema.String),
398
+ workspaceDevDependencies: Schema.Array(Schema.String)
399
+ });
400
+ const WorkspaceListSchema = Schema.Array(Schema.Struct({
401
+ name: Schema.String,
402
+ path: Schema.String,
403
+ version: Schema.String,
404
+ private: Schema.Boolean,
405
+ dependencies: Schema.optional(DependencyObjectSchema),
406
+ devDependencies: Schema.optional(DependencyObjectSchema),
407
+ peerDependencies: Schema.optional(DependencyObjectSchema)
408
+ }));
409
+ var WorkspaceService = class extends Effect.Service()("@ucdjs/release-scripts/WorkspaceService", {
410
+ effect: Effect.gen(function* () {
411
+ const executor = yield* CommandExecutor.CommandExecutor;
412
+ const config = yield* ReleaseScriptsOptions;
413
+ const workspacePackageListOutput = yield* executor.string(Command.make("pnpm", "-r", "ls", "--json").pipe(Command.workingDirectory(config.workspaceRoot))).pipe(Effect.flatMap((stdout) => Effect.try({
414
+ try: () => JSON.parse(stdout),
415
+ catch: (e) => new WorkspaceError({
416
+ message: "Failed to parse pnpm JSON output",
417
+ operation: "discover",
418
+ cause: e
419
+ })
420
+ })), Effect.flatMap((json) => Schema.decodeUnknown(WorkspaceListSchema)(json).pipe(Effect.mapError((e) => new WorkspaceError({
421
+ message: "Failed to decode pnpm output",
422
+ operation: "discover",
423
+ cause: e
424
+ })))), Effect.cached);
425
+ function readPackageJson(pkgPath) {
426
+ return Effect.tryPromise({
427
+ try: async () => JSON.parse(await fs.readFile(path.join(pkgPath, "package.json"), "utf8")),
428
+ catch: (e) => new WorkspaceError({
429
+ message: `Failed to read package.json for ${pkgPath}`,
430
+ cause: e,
431
+ operation: "readPackageJson"
432
+ })
433
+ }).pipe(Effect.flatMap((json) => Schema.decodeUnknown(PackageJsonSchema)(json).pipe(Effect.mapError((e) => new WorkspaceError({
434
+ message: `Invalid package.json for ${pkgPath}`,
435
+ cause: e,
436
+ operation: "readPackageJson"
437
+ })))));
438
+ }
439
+ function writePackageJson(pkgPath, json) {
440
+ const fullPath = path.join(pkgPath, "package.json");
441
+ const content = `${JSON.stringify(json, null, 2)}\n`;
442
+ if (config.dryRun) return Effect.succeed(`Dry run: skip writing ${fullPath}`);
443
+ return Effect.tryPromise({
444
+ try: async () => await fs.writeFile(fullPath, content, "utf8"),
445
+ catch: (e) => new WorkspaceError({
446
+ message: `Failed to write package.json for ${pkgPath}`,
447
+ cause: e,
448
+ operation: "writePackageJson"
449
+ })
450
+ });
451
+ }
452
+ const discoverWorkspacePackages = Effect.gen(function* () {
453
+ let workspaceOptions;
454
+ let explicitPackages;
455
+ if (config.packages == null || config.packages === true) workspaceOptions = { excludePrivate: false };
456
+ else if (Array.isArray(config.packages)) {
457
+ workspaceOptions = {
458
+ excludePrivate: false,
459
+ include: config.packages
460
+ };
461
+ explicitPackages = config.packages;
462
+ } else {
463
+ workspaceOptions = config.packages;
464
+ if (config.packages.include) explicitPackages = config.packages.include;
465
+ }
466
+ const workspacePackages = yield* findWorkspacePackages(workspaceOptions);
467
+ if (explicitPackages) {
468
+ const foundNames = new Set(workspacePackages.map((p) => p.name));
469
+ const missing = explicitPackages.filter((p) => !foundNames.has(p));
470
+ if (missing.length > 0) return yield* Effect.fail(/* @__PURE__ */ new Error(`Package${missing.length > 1 ? "s" : ""} not found in workspace: ${missing.join(", ")}`));
617
471
  }
472
+ return workspacePackages;
618
473
  });
619
- if (!res.ok) {
620
- const errorText = await res.text();
621
- throw new Error(`GitHub API request failed with status ${res.status}: ${errorText || "No response body"}`);
474
+ function findWorkspacePackages(options) {
475
+ return workspacePackageListOutput.pipe(Effect.flatMap((rawProjects) => {
476
+ const allPackageNames = new Set(rawProjects.map((p) => p.name));
477
+ return Effect.all(rawProjects.map((rawProject) => readPackageJson(rawProject.path).pipe(Effect.flatMap((packageJson) => {
478
+ if (!shouldIncludePackage(packageJson, options)) return Effect.succeed(null);
479
+ const pkg = {
480
+ name: rawProject.name,
481
+ version: rawProject.version,
482
+ path: rawProject.path,
483
+ packageJson,
484
+ workspaceDependencies: Object.keys(rawProject.dependencies || {}).filter((dep) => allPackageNames.has(dep)),
485
+ workspaceDevDependencies: Object.keys(rawProject.devDependencies || {}).filter((dep) => allPackageNames.has(dep))
486
+ };
487
+ return Schema.decodeUnknown(WorkspacePackageSchema)(pkg).pipe(Effect.mapError((e) => new WorkspaceError({
488
+ message: `Invalid workspace package structure for ${rawProject.name}`,
489
+ cause: e,
490
+ operation: "findWorkspacePackages"
491
+ })));
492
+ }), Effect.catchAll(() => {
493
+ return Effect.logWarning(`Skipping invalid package ${rawProject.name}`).pipe(Effect.as(null));
494
+ })))).pipe(Effect.map((packages) => packages.filter((pkg) => pkg !== null)));
495
+ }));
496
+ }
497
+ function shouldIncludePackage(pkg, options) {
498
+ if (!options) return true;
499
+ if (options.excludePrivate && pkg.private) return false;
500
+ if (options.include && options.include.length > 0) {
501
+ if (!options.include.includes(pkg.name)) return false;
502
+ }
503
+ if (options.exclude?.includes(pkg.name)) return false;
504
+ return true;
505
+ }
506
+ function findPackageByName(packageName) {
507
+ return discoverWorkspacePackages.pipe(Effect.map((packages) => packages.find((pkg) => pkg.name === packageName) || null));
622
508
  }
623
- if (res.status === 204) return;
624
- return res.json();
625
- }
626
- async getExistingPullRequest(branch) {
627
- const head = branch.includes(":") ? branch : `${this.owner}:${branch}`;
628
- const endpoint = `/repos/${this.owner}/${this.repo}/pulls?state=open&head=${encodeURIComponent(head)}`;
629
- logger.verbose(`Requesting pull request for branch: ${branch} (url: ${this.apiBase}${endpoint})`);
630
- const pulls = await this.request(endpoint);
631
- if (!Array.isArray(pulls) || pulls.length === 0) return null;
632
- const firstPullRequest = pulls[0];
633
- 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");
634
- const pullRequest = {
635
- number: firstPullRequest.number,
636
- title: firstPullRequest.title,
637
- body: firstPullRequest.body,
638
- draft: firstPullRequest.draft,
639
- html_url: firstPullRequest.html_url,
640
- head: "head" in firstPullRequest && typeof firstPullRequest.head === "object" && firstPullRequest.head !== null && "sha" in firstPullRequest.head && typeof firstPullRequest.head.sha === "string" ? { sha: firstPullRequest.head.sha } : void 0
641
- };
642
- logger.info(`Found existing pull request: ${farver.yellow(`#${pullRequest.number}`)}`);
643
- return pullRequest;
644
- }
645
- async upsertPullRequest({ title, body, head, base, pullNumber }) {
646
- const isUpdate = typeof pullNumber === "number";
647
- const endpoint = isUpdate ? `/repos/${this.owner}/${this.repo}/pulls/${pullNumber}` : `/repos/${this.owner}/${this.repo}/pulls`;
648
- const requestBody = isUpdate ? {
649
- title,
650
- body
651
- } : {
652
- title,
653
- body,
654
- head,
655
- base,
656
- draft: true
657
- };
658
- logger.verbose(`${isUpdate ? "Updating" : "Creating"} pull request (url: ${this.apiBase}${endpoint})`);
659
- const pr = await this.request(endpoint, {
660
- method: isUpdate ? "PATCH" : "POST",
661
- body: JSON.stringify(requestBody)
662
- });
663
- 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");
664
- const action = isUpdate ? "Updated" : "Created";
665
- logger.info(`${action} pull request: ${farver.yellow(`#${pr.number}`)}`);
666
509
  return {
667
- number: pr.number,
668
- title: pr.title,
669
- body: pr.body,
670
- draft: pr.draft,
671
- html_url: pr.html_url
510
+ readPackageJson,
511
+ writePackageJson,
512
+ findWorkspacePackages,
513
+ discoverWorkspacePackages,
514
+ findPackageByName
672
515
  };
673
- }
674
- async setCommitStatus({ sha, state, targetUrl, description, context }) {
675
- const endpoint = `/repos/${this.owner}/${this.repo}/statuses/${sha}`;
676
- logger.verbose(`Setting commit status on ${sha} to ${state} (url: ${this.apiBase}${endpoint})`);
677
- await this.request(endpoint, {
678
- method: "POST",
679
- body: JSON.stringify({
680
- state,
681
- target_url: targetUrl,
682
- description: description || "",
683
- context
684
- })
685
- });
686
- logger.info(`Commit status set to ${farver.cyan(state)} for ${farver.gray(sha.substring(0, 7))}`);
687
- }
688
- async resolveAuthorInfo(info) {
689
- if (info.login) return info;
690
- try {
691
- const q = encodeURIComponent(`${info.email} type:user in:email`);
692
- const data = await this.request(`/search/users?q=${q}`);
693
- if (!data.items || data.items.length === 0) return info;
694
- info.login = data.items[0].login;
695
- } catch (err) {
696
- logger.warn(`Failed to resolve author info for email ${info.email}: ${err.message}`);
697
- }
698
- if (info.login) return info;
699
- if (info.commits.length > 0) try {
700
- const data = await this.request(`/repos/${this.owner}/${this.repo}/commits/${info.commits[0]}`);
701
- if (data.author && data.author.login) info.login = data.author.login;
702
- } catch (err) {
703
- logger.warn(`Failed to resolve author info from commits for email ${info.email}: ${err.message}`);
516
+ }),
517
+ dependencies: []
518
+ }) {};
519
+
520
+ //#endregion
521
+ //#region src/services/package-updater.service.ts
522
+ const DASH_RE = / - /;
523
+ const RANGE_OPERATION_RE = /^(?:>=|<=|[><=])/;
524
+ function nextRange(oldRange, newVersion) {
525
+ const workspacePrefix = oldRange.startsWith("workspace:") ? "workspace:" : "";
526
+ const raw = workspacePrefix ? oldRange.slice(10) : oldRange;
527
+ if (raw === "*" || raw === "latest") return `${workspacePrefix}${raw}`;
528
+ if (raw.includes("||") || DASH_RE.test(raw) || RANGE_OPERATION_RE.test(raw) || raw.includes(" ") && !DASH_RE.test(raw)) {
529
+ if (semver.satisfies(newVersion, raw)) return `${workspacePrefix}${raw}`;
530
+ throw new Error(`Cannot update range "${oldRange}" to version ${newVersion}: new version is outside the existing range. Complex range updating is not yet implemented.`);
531
+ }
532
+ return `${workspacePrefix}${raw.startsWith("^") || raw.startsWith("~") ? raw[0] : ""}${newVersion}`;
533
+ }
534
+ function updateDependencyRecord(record, releaseMap) {
535
+ if (!record) return {
536
+ updated: false,
537
+ next: void 0
538
+ };
539
+ let changed = false;
540
+ const next = { ...record };
541
+ for (const [dep, currentRange] of Object.entries(record)) {
542
+ const bumped = releaseMap.get(dep);
543
+ if (!bumped) continue;
544
+ const updatedRange = nextRange(currentRange, bumped);
545
+ if (updatedRange !== currentRange) {
546
+ next[dep] = updatedRange;
547
+ changed = true;
704
548
  }
705
- return info;
706
549
  }
707
- };
708
- function createGitHubClient(options) {
709
- return new GitHubClient(options);
550
+ return {
551
+ updated: changed,
552
+ next: changed ? next : record
553
+ };
710
554
  }
711
- const DEFAULT_PR_BODY_TEMPLATE = dedent`
712
- This PR was automatically generated by the release script.
713
-
714
- The following packages have been prepared for release:
715
-
716
- <% it.packages.forEach((pkg) => { %>
717
- - **<%= pkg.name %>**: <%= pkg.currentVersion %> → <%= pkg.newVersion %> (<%= pkg.bumpType %>)
718
- <% }) %>
719
-
720
- Please review the changes and merge when ready.
721
-
722
- For a more in-depth look at the changes, please refer to the individual package changelogs.
555
+ var PackageUpdaterService = class extends Effect.Service()("@ucdjs/release-scripts/PackageUpdaterService", {
556
+ effect: Effect.gen(function* () {
557
+ const workspace = yield* WorkspaceService;
558
+ function applyReleases(allPackages, releases) {
559
+ const releaseMap = /* @__PURE__ */ new Map();
560
+ for (const release of releases) releaseMap.set(release.package.name, release.newVersion);
561
+ return Effect.all(allPackages.map((pkg) => Effect.gen(function* () {
562
+ const releaseVersion = releaseMap.get(pkg.name);
563
+ const nextJson = { ...pkg.packageJson };
564
+ let updated = false;
565
+ if (releaseVersion && pkg.packageJson.version !== releaseVersion) {
566
+ nextJson.version = releaseVersion;
567
+ updated = true;
568
+ }
569
+ const depsResult = updateDependencyRecord(pkg.packageJson.dependencies, releaseMap);
570
+ if (depsResult.updated) {
571
+ nextJson.dependencies = depsResult.next;
572
+ updated = true;
573
+ }
574
+ const devDepsResult = updateDependencyRecord(pkg.packageJson.devDependencies, releaseMap);
575
+ if (devDepsResult.updated) {
576
+ nextJson.devDependencies = devDepsResult.next;
577
+ updated = true;
578
+ }
579
+ const peerDepsResult = updateDependencyRecord(pkg.packageJson.peerDependencies, releaseMap);
580
+ if (peerDepsResult.updated) {
581
+ nextJson.peerDependencies = peerDepsResult.next;
582
+ updated = true;
583
+ }
584
+ if (!updated) return "skipped";
585
+ return yield* workspace.writePackageJson(pkg.path, nextJson).pipe(Effect.map(() => "written"));
586
+ })));
587
+ }
588
+ return { applyReleases };
589
+ }),
590
+ dependencies: [WorkspaceService.Default]
591
+ }) {};
723
592
 
724
- > [!NOTE]
725
- > When this PR is merged, the release process will be triggered automatically, publishing the new package versions to the registry.
726
- `;
727
- function dedentString(str) {
728
- const lines = str.split("\n");
729
- const minIndent = lines.filter((line) => line.trim().length > 0).reduce((min, line) => Math.min(min, line.search(/\S/)), Infinity);
730
- return lines.map((line) => minIndent === Infinity ? line : line.slice(minIndent)).join("\n").trim();
593
+ //#endregion
594
+ //#region src/services/version-calculator.service.ts
595
+ const BUMP_PRIORITY = {
596
+ none: 0,
597
+ patch: 1,
598
+ minor: 2,
599
+ major: 3
600
+ };
601
+ function maxBump(current, incoming) {
602
+ return (BUMP_PRIORITY[incoming] ?? 0) > (BUMP_PRIORITY[current] ?? 0) ? incoming : current;
731
603
  }
732
- function generatePullRequestBody(updates, body) {
733
- const eta = new Eta();
734
- const bodyTemplate = body ? dedentString(body) : DEFAULT_PR_BODY_TEMPLATE;
735
- return eta.renderString(bodyTemplate, { packages: updates.map((u) => ({
736
- name: u.package.name,
737
- currentVersion: u.currentVersion,
738
- newVersion: u.newVersion,
739
- bumpType: u.bumpType,
740
- hasDirectChanges: u.hasDirectChanges
741
- })) });
604
+ function bumpFromCommit(commit) {
605
+ if (commit.isBreaking) return "major";
606
+ if (commit.type === "feat") return "minor";
607
+ if (commit.type === "fix" || commit.type === "perf") return "patch";
608
+ return "none";
742
609
  }
610
+ function determineBump(commits) {
611
+ return commits.reduce((acc, commit) => maxBump(acc, bumpFromCommit(commit)), "none");
612
+ }
613
+ var VersionCalculatorService = class extends Effect.Service()("@ucdjs/release-scripts/VersionCalculatorService", {
614
+ effect: Effect.gen(function* () {
615
+ function calculateBumps(packages, overrides) {
616
+ return Effect.all(packages.map((pkg) => Effect.gen(function* () {
617
+ const bumpType = determineBump([...pkg.commits, ...pkg.globalCommits]);
618
+ const hasDirectChanges = pkg.commits.length > 0;
619
+ let nextVersion = null;
620
+ const override = overrides[pkg.name];
621
+ if (override) {
622
+ if (!semver.valid(override)) return yield* Effect.fail(new VersionCalculationError({
623
+ message: `Invalid override version for ${pkg.name}: ${override}`,
624
+ packageName: pkg.name
625
+ }));
626
+ nextVersion = override;
627
+ }
628
+ if (nextVersion === null) if (bumpType === "none") nextVersion = pkg.version;
629
+ else {
630
+ const bumped = semver.inc(pkg.version, bumpType);
631
+ if (!bumped) return yield* Effect.fail(new VersionCalculationError({
632
+ message: `Failed to bump version for ${pkg.name} using bump type ${bumpType}`,
633
+ packageName: pkg.name
634
+ }));
635
+ nextVersion = bumped;
636
+ }
637
+ return {
638
+ package: {
639
+ name: pkg.name,
640
+ version: pkg.version,
641
+ path: pkg.path,
642
+ packageJson: pkg.packageJson,
643
+ workspaceDependencies: pkg.workspaceDependencies,
644
+ workspaceDevDependencies: pkg.workspaceDevDependencies
645
+ },
646
+ currentVersion: pkg.version,
647
+ newVersion: nextVersion,
648
+ bumpType,
649
+ hasDirectChanges
650
+ };
651
+ })), { concurrency: 10 });
652
+ }
653
+ return { calculateBumps };
654
+ }),
655
+ dependencies: []
656
+ }) {};
743
657
 
744
658
  //#endregion
745
- //#region src/versioning/commits.ts
746
- function determineHighestBump(commits) {
747
- if (commits.length === 0) return "none";
748
- let highestBump = "none";
749
- for (const commit of commits) {
750
- const bump = determineBumpType(commit);
751
- if (bump === "major") return "major";
752
- if (bump === "minor") highestBump = "minor";
753
- else if (bump === "patch" && highestBump === "none") highestBump = "patch";
754
- }
755
- return highestBump;
659
+ //#region src/utils/helpers.ts
660
+ function loadOverrides(options) {
661
+ return Effect.gen(function* () {
662
+ return yield* (yield* GitService).workspace.readFile(options.overridesPath, options.sha).pipe(Effect.flatMap((content) => Effect.try({
663
+ try: () => JSON.parse(content),
664
+ catch: (err) => new OverridesLoadError({
665
+ message: "Failed to parse overrides file.",
666
+ cause: err
667
+ })
668
+ })), Effect.catchAll(() => Effect.succeed({})));
669
+ });
670
+ }
671
+ const GitCommitSchema = Schema.Struct({
672
+ isConventional: Schema.Boolean,
673
+ isBreaking: Schema.Boolean,
674
+ type: Schema.String,
675
+ scope: Schema.Union(Schema.String, Schema.Undefined),
676
+ description: Schema.String,
677
+ references: Schema.Array(Schema.Struct({
678
+ type: Schema.Union(Schema.Literal("issue"), Schema.Literal("pull-request")),
679
+ value: Schema.String
680
+ })),
681
+ authors: Schema.Array(Schema.Struct({
682
+ name: Schema.String,
683
+ email: Schema.String,
684
+ profile: Schema.optional(Schema.String)
685
+ })),
686
+ hash: Schema.String,
687
+ shortHash: Schema.String,
688
+ body: Schema.String,
689
+ message: Schema.String,
690
+ date: Schema.String
691
+ });
692
+ const WorkspacePackageWithCommitsSchema = Schema.Struct({
693
+ ...WorkspacePackageSchema.fields,
694
+ commits: Schema.Array(GitCommitSchema),
695
+ globalCommits: Schema.Array(GitCommitSchema).pipe(Schema.propertySignature, Schema.withConstructorDefault(() => []))
696
+ });
697
+ function mergePackageCommitsIntoPackages(packages) {
698
+ return Effect.gen(function* () {
699
+ const git = yield* GitService;
700
+ return yield* Effect.forEach(packages, (pkg) => Effect.gen(function* () {
701
+ const lastTag = yield* git.tags.mostRecentForPackage(pkg.name);
702
+ const commits = yield* git.commits.get({
703
+ from: lastTag?.name || void 0,
704
+ to: "HEAD",
705
+ folder: pkg.path
706
+ });
707
+ const withCommits = {
708
+ ...pkg,
709
+ commits,
710
+ globalCommits: []
711
+ };
712
+ return yield* Schema.decode(WorkspacePackageWithCommitsSchema)(withCommits).pipe(Effect.mapError((e) => /* @__PURE__ */ new Error(`Failed to decode package with commits for ${pkg.name}: ${e}`)));
713
+ }));
714
+ });
756
715
  }
757
716
  /**
758
- * Get commits grouped by workspace package.
759
- * For each package, retrieves all commits since its last release tag that affect that package.
717
+ * Retrieves global commits that affect all packages in a monorepo.
718
+ *
719
+ * This function handles an important edge case in monorepo releases:
720
+ * When pkg-a is released, then a global change is made, and then pkg-b is released,
721
+ * we need to ensure that the global change is only attributed to pkg-a's release,
722
+ * not re-counted for pkg-b.
723
+ *
724
+ * Algorithm:
725
+ * 1. Find the overall commit range across all packages
726
+ * 2. Fetch all commits and file changes once for this range
727
+ * 3. For each package, filter commits based on its last tag cutoff
728
+ * 4. Apply mode-specific filtering for global commits
729
+ *
730
+ * Example scenario:
731
+ * - pkg-a: last released at commit A
732
+ * - global change at commit B (after A)
733
+ * - pkg-b: last released at commit C (after B)
760
734
  *
761
- * @param {string} workspaceRoot - The root directory of the workspace
762
- * @param {WorkspacePackage[]} packages - Array of workspace packages to analyze
763
- * @returns {Promise<Map<string, GitCommit[]>>} A map of package names to their commits since their last release
735
+ * Result:
736
+ * - For pkg-a: includes commits from A to HEAD (including B)
737
+ * - For pkg-b: includes commits from C to HEAD (excluding B, since it was already in pkg-b's release range)
738
+ *
739
+ * @param packages - Array of workspace packages with their associated commits
740
+ * @param mode - Determines which global commits to include:
741
+ * - "none": No global commits (returns empty map)
742
+ * - "all": All commits that touch files outside any package directory
743
+ * - "dependencies": Only commits that touch dependency-related files (package.json, lock files, etc.)
744
+ *
745
+ * @returns A map of package names to their relevant global commits
764
746
  */
765
- async function getWorkspacePackageGroupedCommits(workspaceRoot, packages) {
766
- const changedPackages = /* @__PURE__ */ new Map();
767
- const promises = packages.map(async (pkg) => {
768
- const lastTag = await getMostRecentPackageTag(workspaceRoot, pkg.name);
769
- const allCommits = await getCommits({
770
- from: lastTag,
771
- to: "HEAD",
772
- cwd: workspaceRoot,
773
- folder: pkg.path
747
+ function mergeCommitsAffectingGloballyIntoPackage(packages, mode) {
748
+ return Effect.gen(function* () {
749
+ const git = yield* GitService;
750
+ if (mode === "none") return packages;
751
+ const [oldestCommitSha, newestCommitSha] = findCommitRange(packages);
752
+ if (oldestCommitSha == null || newestCommitSha == null) return packages;
753
+ const allCommits = yield* git.commits.get({
754
+ from: oldestCommitSha,
755
+ to: newestCommitSha,
756
+ folder: "."
774
757
  });
775
- logger.verbose(`Found ${farver.cyan(allCommits.length)} commits for package ${farver.bold(pkg.name)} since tag ${farver.cyan(lastTag ?? "N/A")}`);
776
- return {
777
- pkgName: pkg.name,
778
- commits: allCommits
779
- };
758
+ const affectedFilesPerCommit = yield* git.commits.filesChangesBetweenRefs(oldestCommitSha, newestCommitSha);
759
+ const commitTimestamps = new Map(allCommits.map((c) => [c.hash, new Date(c.date).getTime()]));
760
+ const packagePaths = new Set(packages.map((p) => p.path));
761
+ const result = /* @__PURE__ */ new Map();
762
+ for (const pkg of packages) {
763
+ const lastTag = yield* git.tags.mostRecentForPackage(pkg.name);
764
+ const cutoffTimestamp = lastTag ? commitTimestamps.get(lastTag.sha) ?? 0 : 0;
765
+ const globalCommits = [];
766
+ for (const commit of allCommits) {
767
+ const commitTimestamp = commitTimestamps.get(commit.hash);
768
+ if (commitTimestamp == null || commitTimestamp <= cutoffTimestamp) continue;
769
+ const files = affectedFilesPerCommit.get(commit.hash);
770
+ if (!files) continue;
771
+ if (isGlobalCommit(files, packagePaths)) if (mode === "dependencies") {
772
+ if (files.some((file) => isDependencyFile(file))) globalCommits.push(commit);
773
+ } else globalCommits.push(commit);
774
+ }
775
+ result.set(pkg.name, globalCommits);
776
+ }
777
+ return yield* Effect.succeed(packages.map((pkg) => ({
778
+ ...pkg,
779
+ globalCommits: result.get(pkg.name) || []
780
+ })));
780
781
  });
781
- const results = await Promise.all(promises);
782
- for (const { pkgName, commits } of results) changedPackages.set(pkgName, commits);
783
- return changedPackages;
784
782
  }
785
783
  /**
786
- * Check if a file path touches any package folder.
787
- * @param file - The file path to check
788
- * @param packagePaths - Set of normalized package paths
789
- * @param workspaceRoot - The workspace root for path normalization
790
- * @returns true if the file is inside a package folder
784
+ * Determines if a commit is "global" (affects files outside any package directory).
785
+ *
786
+ * @param files - List of files changed in the commit
787
+ * @param packagePaths - Set of package directory paths
788
+ * @returns true if at least one file is outside all package directories
791
789
  */
792
- function fileMatchesPackageFolder(file, packagePaths, workspaceRoot) {
793
- const normalizedFile = file.startsWith("./") ? file.slice(2) : file;
794
- for (const pkgPath of packagePaths) {
795
- const normalizedPkgPath = pkgPath.startsWith(workspaceRoot) ? pkgPath.slice(workspaceRoot.length + 1) : pkgPath;
796
- if (normalizedFile.startsWith(`${normalizedPkgPath}/`) || normalizedFile === normalizedPkgPath) return true;
797
- }
798
- return false;
790
+ function isGlobalCommit(files, packagePaths) {
791
+ return files.some((file) => {
792
+ const normalized = file.startsWith("./") ? file.slice(2) : file;
793
+ for (const pkgPath of packagePaths) if (normalized === pkgPath || normalized.startsWith(`${pkgPath}/`)) return false;
794
+ return true;
795
+ });
799
796
  }
800
797
  /**
801
- * Check if a commit is a "global" commit (doesn't touch any package folder).
802
- * @param workspaceRoot - The workspace root
803
- * @param files - Array of files changed in the commit
804
- * @param packagePaths - Set of normalized package paths
805
- * @returns true if this is a global commit
798
+ * Files that are considered dependency-related in a monorepo.
806
799
  */
807
- function isGlobalCommit(workspaceRoot, files, packagePaths) {
808
- if (!files || files.length === 0) return false;
809
- return !files.some((file) => fileMatchesPackageFolder(file, packagePaths, workspaceRoot));
810
- }
811
- const DEPENDENCY_FILES = [
800
+ const DEPENDENCY_FILES = new Set([
812
801
  "package.json",
813
802
  "pnpm-lock.yaml",
814
- "pnpm-workspace.yaml",
815
803
  "yarn.lock",
816
- "package-lock.json"
817
- ];
818
- /**
819
- * Find the oldest and newest commits across all packages.
820
- * @param packageCommits - Map of package commits
821
- * @returns Object with oldest and newest commit SHAs, or null if no commits
822
- */
823
- function findCommitRange(packageCommits) {
824
- let oldestCommit = null;
825
- let newestCommit = null;
826
- for (const commits of packageCommits.values()) {
827
- if (commits.length === 0) continue;
828
- const firstCommit = commits[0].shortHash;
829
- const lastCommit = commits[commits.length - 1].shortHash;
830
- if (!newestCommit) newestCommit = firstCommit;
831
- oldestCommit = lastCommit;
832
- }
833
- if (!oldestCommit || !newestCommit) return null;
834
- return {
835
- oldest: oldestCommit,
836
- newest: newestCommit
837
- };
838
- }
804
+ "package-lock.json",
805
+ "pnpm-workspace.yaml"
806
+ ]);
839
807
  /**
840
- * Get global commits for each package based on their individual commit timelines.
841
- * This solves the problem where packages with different release histories need different global commits.
842
- *
843
- * A "global commit" is a commit that doesn't touch any package folder but may affect all packages
844
- * (e.g., root package.json, CI config, README).
808
+ * Determines if a file is dependency-related.
845
809
  *
846
- * Performance: Makes ONE batched git call to get files for all commits across all packages.
847
- *
848
- * @param workspaceRoot - The root directory of the workspace
849
- * @param packageCommits - Map of package name to their commits (from getWorkspacePackageCommits)
850
- * @param allPackages - All workspace packages (used to identify package folders)
851
- * @param mode - Filter mode: false (disabled), "all" (all global commits), or "dependencies" (only dependency-related)
852
- * @returns Map of package name to their global commits
810
+ * @param file - File path to check
811
+ * @returns true if the file is a dependency file (package.json, lock files, etc.)
853
812
  */
854
- async function getGlobalCommitsPerPackage(workspaceRoot, packageCommits, allPackages, mode) {
855
- const result = /* @__PURE__ */ new Map();
856
- if (!mode) {
857
- logger.verbose("Global commits mode disabled");
858
- return result;
859
- }
860
- logger.verbose(`Computing global commits per-package (mode: ${farver.cyan(mode)})`);
861
- const commitRange = findCommitRange(packageCommits);
862
- if (!commitRange) {
863
- logger.verbose("No commits found across packages");
864
- return result;
865
- }
866
- logger.verbose("Fetching files for commits range", `${farver.cyan(commitRange.oldest)}..${farver.cyan(commitRange.newest)}`);
867
- const commitFilesMap = await getGroupedFilesByCommitSha(workspaceRoot, commitRange.oldest, commitRange.newest);
868
- if (!commitFilesMap) {
869
- logger.warn("Failed to get commit file list, returning empty global commits");
870
- return result;
871
- }
872
- logger.verbose("Got file lists for commits", `${farver.cyan(commitFilesMap.size)} commits in ONE git call`);
873
- const packagePaths = new Set(allPackages.map((p) => p.path));
874
- for (const [pkgName, commits] of packageCommits) {
875
- const globalCommitsAffectingPackage = [];
876
- logger.verbose("Filtering global commits for package", `${farver.bold(pkgName)} from ${farver.cyan(commits.length)} commits`);
877
- for (const commit of commits) {
878
- const files = commitFilesMap.get(commit.shortHash);
879
- if (!files) continue;
880
- if (isGlobalCommit(workspaceRoot, files, packagePaths)) globalCommitsAffectingPackage.push(commit);
881
- }
882
- logger.verbose("Package global commits found", `${farver.bold(pkgName)}: ${farver.cyan(globalCommitsAffectingPackage.length)} global commits`);
883
- if (mode === "all") {
884
- result.set(pkgName, globalCommitsAffectingPackage);
885
- continue;
886
- }
887
- const dependencyCommits = [];
888
- for (const commit of globalCommitsAffectingPackage) {
889
- const files = commitFilesMap.get(commit.shortHash);
890
- if (!files) continue;
891
- if (files.some((file) => DEPENDENCY_FILES.includes(file.startsWith("./") ? file.slice(2) : file))) {
892
- logger.verbose("Global commit affects dependencies", `${farver.bold(pkgName)}: commit ${farver.cyan(commit.shortHash)} affects dependencies`);
893
- dependencyCommits.push(commit);
894
- }
895
- }
896
- logger.verbose("Global commits affect dependencies", `${farver.bold(pkgName)}: ${farver.cyan(dependencyCommits.length)} global commits affect dependencies`);
897
- result.set(pkgName, dependencyCommits);
898
- }
899
- return result;
813
+ function isDependencyFile(file) {
814
+ const normalized = file.startsWith("./") ? file.slice(2) : file;
815
+ if (DEPENDENCY_FILES.has(normalized)) return true;
816
+ return Array.from(DEPENDENCY_FILES).some((dep) => normalized.endsWith(`/${dep}`));
900
817
  }
901
- function determineBumpType(commit) {
902
- if (commit.isBreaking) return "major";
903
- if (!commit.isConventional || !commit.type) return "none";
904
- switch (commit.type) {
905
- case "feat": return "minor";
906
- case "fix":
907
- case "perf": return "patch";
908
- case "docs":
909
- case "style":
910
- case "refactor":
911
- case "test":
912
- case "build":
913
- case "ci":
914
- case "chore":
915
- case "revert": return "none";
916
- default: return "none";
917
- }
918
- }
919
-
920
- //#endregion
921
- //#region src/versioning/package.ts
922
818
  /**
923
- * Build a dependency graph from workspace packages
819
+ * Finds the oldest and newest commits across all packages.
924
820
  *
925
- * Creates a bidirectional graph that maps:
926
- * - packages: Map of package name → WorkspacePackage
927
- * - dependents: Map of package name → Set of packages that depend on it
821
+ * This establishes the overall time range we need to analyze for global commits.
928
822
  *
929
- * @param packages - All workspace packages
930
- * @returns Dependency graph with packages and dependents maps
823
+ * @param packages - Array of packages with their commits
824
+ * @returns Tuple of [oldestCommitSha, newestCommitSha], or [null, null] if no commits found
931
825
  */
932
- function buildPackageDependencyGraph(packages) {
933
- const packagesMap = /* @__PURE__ */ new Map();
934
- const dependents = /* @__PURE__ */ new Map();
935
- for (const pkg of packages) {
936
- packagesMap.set(pkg.name, pkg);
937
- dependents.set(pkg.name, /* @__PURE__ */ new Set());
938
- }
826
+ function findCommitRange(packages) {
827
+ let oldestCommit = null;
828
+ let newestCommit = null;
939
829
  for (const pkg of packages) {
940
- const allDeps = [...pkg.workspaceDependencies, ...pkg.workspaceDevDependencies];
941
- for (const dep of allDeps) {
942
- const depSet = dependents.get(dep);
943
- if (depSet) depSet.add(pkg.name);
944
- }
830
+ if (pkg.commits.length === 0) continue;
831
+ const firstCommit = pkg.commits[0];
832
+ if (!firstCommit) throw new Error(`No commits found for package ${pkg.name}`);
833
+ const lastCommit = pkg.commits[pkg.commits.length - 1];
834
+ if (!lastCommit) throw new Error(`No commits found for package ${pkg.name}`);
835
+ if (newestCommit == null || new Date(lastCommit.date) > new Date(newestCommit.date)) newestCommit = lastCommit;
836
+ if (oldestCommit == null || new Date(firstCommit.date) < new Date(oldestCommit.date)) oldestCommit = firstCommit;
945
837
  }
946
- return {
947
- packages: packagesMap,
948
- dependents
949
- };
950
- }
951
- /**
952
- * Get all packages affected by changes (including transitive dependents)
953
- *
954
- * Uses graph traversal to find all packages that need updates:
955
- * - Packages with direct changes
956
- * - All packages that depend on changed packages (transitively)
957
- *
958
- * @param graph - Dependency graph
959
- * @param changedPackages - Set of package names with direct changes
960
- * @returns Set of all package names that need updates
961
- */
962
- function getAllAffectedPackages(graph, changedPackages) {
963
- const affected = /* @__PURE__ */ new Set();
964
- function visitDependents(pkgName) {
965
- if (affected.has(pkgName)) return;
966
- affected.add(pkgName);
967
- const dependents = graph.dependents.get(pkgName);
968
- if (dependents) for (const dependent of dependents) visitDependents(dependent);
969
- }
970
- for (const pkg of changedPackages) visitDependents(pkg);
971
- return affected;
972
- }
973
- /**
974
- * Create version updates for all packages affected by dependency changes
975
- *
976
- * When a package is updated, all packages that depend on it should also be updated.
977
- * This function calculates which additional packages need patch bumps due to dependency changes.
978
- *
979
- * @param graph - Dependency graph
980
- * @param workspacePackages - All workspace packages
981
- * @param directUpdates - Packages with direct code changes
982
- * @returns All updates including dependent packages that need patch bumps
983
- */
984
- function createDependentUpdates(graph, workspacePackages, directUpdates) {
985
- const allUpdates = [...directUpdates];
986
- const directUpdateMap = new Map(directUpdates.map((u) => [u.package.name, u]));
987
- const affectedPackages = getAllAffectedPackages(graph, new Set(directUpdates.map((u) => u.package.name)));
988
- for (const pkgName of affectedPackages) {
989
- logger.verbose(`Processing affected package: ${pkgName}`);
990
- if (directUpdateMap.has(pkgName)) {
991
- logger.verbose(`Skipping ${pkgName}, already has a direct update`);
992
- continue;
993
- }
994
- const pkg = workspacePackages.find((p) => p.name === pkgName);
995
- if (!pkg) continue;
996
- allUpdates.push(createVersionUpdate(pkg, "patch", false));
997
- }
998
- return allUpdates;
838
+ if (oldestCommit == null || newestCommit == null) return [null, null];
839
+ return [oldestCommit.hash, newestCommit.hash];
999
840
  }
1000
841
 
1001
842
  //#endregion
1002
- //#region src/versioning/version.ts
1003
- function isValidSemver(version) {
1004
- return /^\d+\.\d+\.\d+(?:[-+].+)?$/.test(version);
1005
- }
1006
- function getNextVersion(currentVersion, bump) {
1007
- if (bump === "none") {
1008
- logger.verbose(`No version bump needed, keeping version ${currentVersion}`);
1009
- return currentVersion;
1010
- }
1011
- if (!isValidSemver(currentVersion)) throw new Error(`Cannot bump version for invalid semver: ${currentVersion}`);
1012
- const match = currentVersion.match(/^(\d+)\.(\d+)\.(\d+)(.*)$/);
1013
- if (!match) throw new Error(`Invalid semver version: ${currentVersion}`);
1014
- const [, major, minor, patch] = match;
1015
- let newMajor = Number.parseInt(major, 10);
1016
- let newMinor = Number.parseInt(minor, 10);
1017
- let newPatch = Number.parseInt(patch, 10);
1018
- switch (bump) {
1019
- case "major":
1020
- newMajor += 1;
1021
- newMinor = 0;
1022
- newPatch = 0;
1023
- break;
1024
- case "minor":
1025
- newMinor += 1;
1026
- newPatch = 0;
1027
- break;
1028
- case "patch":
1029
- newPatch += 1;
1030
- break;
1031
- }
1032
- return `${newMajor}.${newMinor}.${newPatch}`;
1033
- }
1034
- function createVersionUpdate(pkg, bump, hasDirectChanges) {
1035
- const newVersion = getNextVersion(pkg.version, bump);
1036
- return {
1037
- package: pkg,
1038
- currentVersion: pkg.version,
1039
- newVersion,
1040
- bumpType: bump,
1041
- hasDirectChanges
1042
- };
1043
- }
1044
- function _calculateBumpType(oldVersion, newVersion) {
1045
- if (!isValidSemver(oldVersion) || !isValidSemver(newVersion)) throw new Error(`Cannot calculate bump type for invalid semver: ${oldVersion} or ${newVersion}`);
1046
- const oldParts = oldVersion.split(".").map(Number);
1047
- const newParts = newVersion.split(".").map(Number);
1048
- if (newParts[0] > oldParts[0]) return "major";
1049
- if (newParts[1] > oldParts[1]) return "minor";
1050
- if (newParts[2] > oldParts[2]) return "patch";
1051
- return "none";
1052
- }
1053
- const messageColorMap = {
1054
- feat: farver.green,
1055
- feature: farver.green,
1056
- refactor: farver.cyan,
1057
- style: farver.cyan,
1058
- docs: farver.blue,
1059
- doc: farver.blue,
1060
- types: farver.blue,
1061
- type: farver.blue,
1062
- chore: farver.gray,
1063
- ci: farver.gray,
1064
- build: farver.gray,
1065
- deps: farver.gray,
1066
- dev: farver.gray,
1067
- fix: farver.yellow,
1068
- test: farver.yellow,
1069
- perf: farver.magenta,
1070
- revert: farver.red,
1071
- breaking: farver.red
1072
- };
1073
- function formatCommitsForDisplay(commits) {
1074
- if (commits.length === 0) return farver.dim("No commits found");
1075
- const maxCommitsToShow = 10;
1076
- const commitsToShow = commits.slice(0, maxCommitsToShow);
1077
- const hasMore = commits.length > maxCommitsToShow;
1078
- const typeLength = commits.map(({ type }) => type.length).reduce((a, b) => Math.max(a, b), 0);
1079
- const scopeLength = commits.map(({ scope }) => scope?.length).reduce((a, b) => Math.max(a || 0, b || 0), 0) || 0;
1080
- const formattedCommits = commitsToShow.map((commit) => {
1081
- let color = messageColorMap[commit.type] || ((c) => c);
1082
- if (commit.isBreaking) color = (s) => farver.inverse.red(s);
1083
- const paddedType = commit.type.padStart(typeLength + 1, " ");
1084
- const paddedScope = !commit.scope ? " ".repeat(scopeLength ? scopeLength + 2 : 0) : farver.dim("(") + commit.scope + farver.dim(")") + " ".repeat(scopeLength - commit.scope.length);
1085
- return [
1086
- farver.dim(commit.shortHash),
1087
- " ",
1088
- color === farver.gray ? color(paddedType) : farver.bold(color(paddedType)),
1089
- " ",
1090
- paddedScope,
1091
- farver.dim(":"),
1092
- " ",
1093
- color === farver.gray ? color(commit.description) : commit.description
1094
- ].join("");
1095
- }).join("\n");
1096
- if (hasMore) return `${formattedCommits}\n ${farver.dim(`... and ${commits.length - maxCommitsToShow} more commits`)}`;
1097
- return formattedCommits;
1098
- }
1099
- async function calculateVersionUpdates({ workspacePackages, packageCommits, workspaceRoot, showPrompt, globalCommitsPerPackage, overrides: initialOverrides = {} }) {
1100
- const versionUpdates = [];
1101
- const processedPackages = /* @__PURE__ */ new Set();
1102
- const newOverrides = { ...initialOverrides };
1103
- const bumpRanks = {
1104
- major: 3,
1105
- minor: 2,
1106
- patch: 1,
1107
- none: 0
1108
- };
1109
- logger.verbose(`Starting version inference for ${packageCommits.size} packages with commits`);
1110
- for (const [pkgName, pkgCommits] of packageCommits) {
1111
- const pkg = workspacePackages.find((p) => p.name === pkgName);
1112
- if (!pkg) {
1113
- logger.error(`Package ${pkgName} not found in workspace packages, skipping`);
1114
- continue;
1115
- }
1116
- processedPackages.add(pkgName);
1117
- const globalCommits = globalCommitsPerPackage.get(pkgName) || [];
1118
- const allCommitsForPackage = [...pkgCommits, ...globalCommits];
1119
- const determinedBump = determineHighestBump(allCommitsForPackage);
1120
- const override = newOverrides[pkgName];
1121
- const effectiveBump = override?.type || determinedBump;
1122
- if (effectiveBump === "none") continue;
1123
- let newVersion = override?.version || getNextVersion(pkg.version, effectiveBump);
1124
- let finalBumpType = effectiveBump;
1125
- if (!isCI && showPrompt) {
1126
- logger.clearScreen();
1127
- logger.section(`📝 Commits for ${farver.cyan(pkg.name)}`);
1128
- formatCommitsForDisplay(allCommitsForPackage).split("\n").forEach((line) => logger.item(line));
1129
- logger.emptyLine();
1130
- const selectedVersion = await selectVersionPrompt(workspaceRoot, pkg, pkg.version, newVersion);
1131
- if (selectedVersion === null) continue;
1132
- const userBump = _calculateBumpType(pkg.version, selectedVersion);
1133
- finalBumpType = userBump;
1134
- if (bumpRanks[userBump] < bumpRanks[determinedBump]) {
1135
- newOverrides[pkgName] = {
1136
- type: userBump,
1137
- version: selectedVersion
1138
- };
1139
- logger.info(`Version override recorded for ${pkgName}: ${determinedBump} → ${userBump}`);
1140
- } else if (newOverrides[pkgName] && bumpRanks[userBump] >= bumpRanks[determinedBump]) {
1141
- delete newOverrides[pkgName];
1142
- logger.info(`Version override removed for ${pkgName}.`);
1143
- }
1144
- newVersion = selectedVersion;
843
+ //#region src/verify.ts
844
+ function constructVerifyProgram(config) {
845
+ return Effect.gen(function* () {
846
+ const git = yield* GitService;
847
+ const github = yield* GitHubService;
848
+ const dependencyGraph = yield* DependencyGraphService;
849
+ const versionCalculator = yield* VersionCalculatorService;
850
+ const workspace = yield* WorkspaceService;
851
+ yield* git.workspace.assertWorkspaceReady;
852
+ const releasePullRequest = yield* github.getPullRequestByBranch(config.branch.release);
853
+ if (!releasePullRequest || !releasePullRequest.head) return yield* Effect.fail(/* @__PURE__ */ new Error(`Release pull request for branch "${config.branch.release}" does not exist.`));
854
+ yield* Console.log(`✅ Release pull request #${releasePullRequest.number} exists.`);
855
+ if ((yield* git.branches.get) !== config.branch.default) {
856
+ yield* git.branches.checkout(config.branch.default);
857
+ yield* Console.log(`✅ Checked out to default branch "${config.branch.default}".`);
1145
858
  }
1146
- versionUpdates.push({
1147
- package: pkg,
1148
- currentVersion: pkg.version,
1149
- newVersion,
1150
- bumpType: finalBumpType,
1151
- hasDirectChanges: allCommitsForPackage.length > 0
859
+ const overrides = yield* loadOverrides({
860
+ sha: releasePullRequest.head.sha,
861
+ overridesPath: ".github/ucdjs-release.overrides.json"
1152
862
  });
1153
- }
1154
- if (!isCI && showPrompt) for (const pkg of workspacePackages) {
1155
- if (processedPackages.has(pkg.name)) continue;
1156
- logger.clearScreen();
1157
- logger.section(`📦 Package: ${pkg.name}`);
1158
- logger.item("No direct commits found");
1159
- const newVersion = await selectVersionPrompt(workspaceRoot, pkg, pkg.version, pkg.version);
1160
- if (newVersion === null) break;
1161
- if (newVersion !== pkg.version) {
1162
- const bumpType = _calculateBumpType(pkg.version, newVersion);
1163
- versionUpdates.push({
1164
- package: pkg,
1165
- currentVersion: pkg.version,
1166
- newVersion,
1167
- bumpType,
1168
- hasDirectChanges: false
1169
- });
1170
- }
1171
- }
1172
- return {
1173
- updates: versionUpdates,
1174
- overrides: newOverrides
1175
- };
1176
- }
1177
- /**
1178
- * Calculate version updates and prepare dependent updates
1179
- * Returns both the updates and a function to apply them
1180
- */
1181
- async function calculateAndPrepareVersionUpdates({ workspacePackages, packageCommits, workspaceRoot, showPrompt, globalCommitsPerPackage, overrides }) {
1182
- const { updates: directUpdates, overrides: newOverrides } = await calculateVersionUpdates({
1183
- workspacePackages,
1184
- packageCommits,
1185
- workspaceRoot,
1186
- showPrompt,
1187
- globalCommitsPerPackage,
1188
- overrides
863
+ yield* Console.log("Loaded overrides:", overrides);
864
+ const packages = yield* workspace.discoverWorkspacePackages.pipe(Effect.flatMap(mergePackageCommitsIntoPackages), Effect.flatMap((pkgs) => mergeCommitsAffectingGloballyIntoPackage(pkgs, config.globalCommitMode)));
865
+ yield* Console.log("Discovered packages with commits and global commits:", packages);
866
+ const releases = yield* versionCalculator.calculateBumps(packages, overrides);
867
+ const ordered = yield* dependencyGraph.topologicalOrder(packages);
868
+ yield* Console.log("Calculated releases:", releases);
869
+ yield* Console.log("Release order:", ordered);
1189
870
  });
1190
- const allUpdates = createDependentUpdates(buildPackageDependencyGraph(workspacePackages), workspacePackages, directUpdates);
1191
- const applyUpdates = async () => {
1192
- await Promise.all(allUpdates.map(async (update) => {
1193
- const depUpdates = getDependencyUpdates(update.package, allUpdates);
1194
- await updatePackageJson(update.package, update.newVersion, depUpdates);
1195
- }));
1196
- };
1197
- return {
1198
- allUpdates,
1199
- applyUpdates,
1200
- overrides: newOverrides
1201
- };
1202
- }
1203
- async function updatePackageJson(pkg, newVersion, dependencyUpdates) {
1204
- const packageJsonPath = join(pkg.path, "package.json");
1205
- const content = await readFile(packageJsonPath, "utf-8");
1206
- const packageJson = JSON.parse(content);
1207
- packageJson.version = newVersion;
1208
- function updateDependency(deps, depName, depVersion, isPeerDependency = false) {
1209
- if (!deps) return;
1210
- const oldVersion = deps[depName];
1211
- if (!oldVersion) return;
1212
- if (oldVersion === "workspace:*") {
1213
- logger.verbose(` - Skipping workspace:* dependency: ${depName}`);
1214
- return;
1215
- }
1216
- if (isPeerDependency) {
1217
- const majorVersion = depVersion.split(".")[0];
1218
- deps[depName] = `>=${depVersion} <${Number(majorVersion) + 1}.0.0`;
1219
- } else deps[depName] = `^${depVersion}`;
1220
- logger.verbose(` - Updated dependency ${depName}: ${oldVersion} → ${deps[depName]}`);
1221
- }
1222
- for (const [depName, depVersion] of dependencyUpdates) {
1223
- updateDependency(packageJson.dependencies, depName, depVersion);
1224
- updateDependency(packageJson.devDependencies, depName, depVersion);
1225
- updateDependency(packageJson.peerDependencies, depName, depVersion, true);
1226
- }
1227
- await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf-8");
1228
- logger.verbose(` - Successfully wrote updated package.json`);
1229
- }
1230
- /**
1231
- * Get all dependency updates needed for a package
1232
- */
1233
- function getDependencyUpdates(pkg, allUpdates) {
1234
- const updates = /* @__PURE__ */ new Map();
1235
- const allDeps = [...pkg.workspaceDependencies, ...pkg.workspaceDevDependencies];
1236
- for (const dep of allDeps) {
1237
- const update = allUpdates.find((u) => u.package.name === dep);
1238
- if (update) {
1239
- logger.verbose(` - Dependency ${dep} will be updated: ${update.currentVersion} → ${update.newVersion} (${update.bumpType})`);
1240
- updates.set(dep, update.newVersion);
1241
- }
1242
- }
1243
- if (updates.size === 0) logger.verbose(` - No dependency updates needed`);
1244
- return updates;
1245
871
  }
1246
872
 
1247
873
  //#endregion
1248
- //#region src/core/prompts.ts
1249
- async function selectPackagePrompt(packages) {
1250
- const response = await prompts({
1251
- type: "multiselect",
1252
- name: "selectedPackages",
1253
- message: "Select packages to release",
1254
- choices: packages.map((pkg) => ({
1255
- title: `${pkg.name} (${farver.bold(pkg.version)})`,
1256
- value: pkg.name,
1257
- selected: true
1258
- })),
1259
- min: 1,
1260
- hint: "Space to select/deselect. Return to submit.",
1261
- instructions: false
874
+ //#region src/index.ts
875
+ async function createReleaseScripts(options) {
876
+ const config = normalizeReleaseScriptsOptions(options);
877
+ const AppLayer = Layer.succeed(ReleaseScriptsOptions, config).pipe(Layer.provide(NodeCommandExecutor.layer), Layer.provide(NodeFileSystem.layer), Layer.provide(GitService.Default), Layer.provide(GitHubService.Default), Layer.provide(DependencyGraphService.Default), Layer.provide(PackageUpdaterService.Default), Layer.provide(VersionCalculatorService.Default), Layer.provide(WorkspaceService.Default));
878
+ const runProgram = (program) => {
879
+ const provided = program.pipe(Effect.provide(AppLayer));
880
+ return Effect.runPromise(provided);
881
+ };
882
+ const safeguardProgram = Effect.gen(function* () {
883
+ return yield* (yield* GitService).workspace.assertWorkspaceReady;
1262
884
  });
1263
- if (!response.selectedPackages || response.selectedPackages.length === 0) return [];
1264
- return response.selectedPackages;
1265
- }
1266
- async function selectVersionPrompt(workspaceRoot, pkg, currentVersion, suggestedVersion) {
1267
- const answers = await prompts([{
1268
- type: "autocomplete",
1269
- name: "version",
1270
- message: `${pkg.name}: ${farver.green(pkg.version)}`,
1271
- choices: [
1272
- {
1273
- value: "skip",
1274
- title: `skip ${farver.dim("(no change)")}`
1275
- },
1276
- {
1277
- value: "major",
1278
- title: `major ${farver.bold(getNextVersion(pkg.version, "major"))}`
1279
- },
1280
- {
1281
- value: "minor",
1282
- title: `minor ${farver.bold(getNextVersion(pkg.version, "minor"))}`
1283
- },
1284
- {
1285
- value: "patch",
1286
- title: `patch ${farver.bold(getNextVersion(pkg.version, "patch"))}`
1287
- },
1288
- {
1289
- value: "suggested",
1290
- title: `suggested ${farver.bold(suggestedVersion)}`
1291
- },
1292
- {
1293
- value: "as-is",
1294
- title: `as-is ${farver.dim("(keep current version)")}`
1295
- },
1296
- {
1297
- value: "custom",
1298
- title: "custom"
1299
- }
1300
- ],
1301
- initial: suggestedVersion === currentVersion ? 0 : 4
1302
- }, {
1303
- type: (prev) => prev === "custom" ? "text" : null,
1304
- name: "custom",
1305
- message: "Enter the new version number:",
1306
- initial: suggestedVersion,
1307
- validate: (custom) => {
1308
- if (isValidSemver(custom)) return true;
1309
- return "That's not a valid version number";
1310
- }
1311
- }]);
1312
- if (!answers.version) return null;
1313
- if (answers.version === "skip") return null;
1314
- else if (answers.version === "suggested") return suggestedVersion;
1315
- else if (answers.version === "custom") {
1316
- if (!answers.custom) return null;
1317
- return answers.custom;
1318
- } else if (answers.version === "as-is") return currentVersion;
1319
- else return getNextVersion(pkg.version, answers.version);
1320
- }
1321
-
1322
- //#endregion
1323
- //#region src/core/workspace.ts
1324
- async function discoverWorkspacePackages(workspaceRoot, options) {
1325
- let workspaceOptions;
1326
- let explicitPackages;
1327
- if (options.packages == null || options.packages === true) workspaceOptions = { excludePrivate: false };
1328
- else if (Array.isArray(options.packages)) {
1329
- workspaceOptions = {
1330
- excludePrivate: false,
1331
- include: options.packages
1332
- };
1333
- explicitPackages = options.packages;
1334
- } else {
1335
- workspaceOptions = options.packages;
1336
- if (options.packages.include) explicitPackages = options.packages.include;
1337
- }
1338
- let workspacePackages = await findWorkspacePackages(workspaceRoot, workspaceOptions);
1339
- if (explicitPackages) {
1340
- const foundNames = new Set(workspacePackages.map((p) => p.name));
1341
- const missing = explicitPackages.filter((p) => !foundNames.has(p));
1342
- if (missing.length > 0) exitWithError(`Package${missing.length > 1 ? "s" : ""} not found in workspace: ${missing.join(", ")}`, "Check your package names or run 'pnpm ls' to see available packages");
1343
- }
1344
- const isPackagePromptEnabled = options.prompts?.packages !== false;
1345
- if (!isCI && isPackagePromptEnabled && !explicitPackages) {
1346
- const selectedNames = await selectPackagePrompt(workspacePackages);
1347
- workspacePackages = workspacePackages.filter((pkg) => selectedNames.includes(pkg.name));
1348
- }
1349
- return workspacePackages;
1350
- }
1351
- async function findWorkspacePackages(workspaceRoot, options) {
1352
885
  try {
1353
- const result = await run("pnpm", [
1354
- "-r",
1355
- "ls",
1356
- "--json"
1357
- ], { nodeOptions: {
1358
- cwd: workspaceRoot,
1359
- stdio: "pipe"
1360
- } });
1361
- const rawProjects = JSON.parse(result.stdout);
1362
- const allPackageNames = new Set(rawProjects.map((p) => p.name));
1363
- const excludedPackages = /* @__PURE__ */ new Set();
1364
- const promises = rawProjects.map(async (rawProject) => {
1365
- const content = await readFile(join(rawProject.path, "package.json"), "utf-8");
1366
- const packageJson = JSON.parse(content);
1367
- if (!shouldIncludePackage(packageJson, options)) {
1368
- excludedPackages.add(rawProject.name);
1369
- return null;
1370
- }
1371
- return {
1372
- name: rawProject.name,
1373
- version: rawProject.version,
1374
- path: rawProject.path,
1375
- packageJson,
1376
- workspaceDependencies: Object.keys(rawProject.dependencies || []).filter((dep) => {
1377
- return allPackageNames.has(dep);
1378
- }),
1379
- workspaceDevDependencies: Object.keys(rawProject.devDependencies || []).filter((dep) => {
1380
- return allPackageNames.has(dep);
1381
- })
1382
- };
1383
- });
1384
- const packages = await Promise.all(promises);
1385
- if (excludedPackages.size > 0) logger.info(`Excluded packages: ${farver.green(Array.from(excludedPackages).join(", "))}`);
1386
- return packages.filter((pkg) => pkg !== null);
886
+ await runProgram(safeguardProgram);
1387
887
  } catch (err) {
1388
- logger.error("Error discovering workspace packages:", err);
888
+ const message = err instanceof Error ? err.message : String(err);
889
+ await Effect.runPromise(Console.error(`❌ Initialization failed: ${message}`));
1389
890
  throw err;
1390
891
  }
1391
- }
1392
- function shouldIncludePackage(pkg, options) {
1393
- if (!options) return true;
1394
- if (options.excludePrivate && pkg.private) return false;
1395
- if (options.include && options.include.length > 0) {
1396
- if (!options.include.includes(pkg.name)) return false;
1397
- }
1398
- if (options.exclude?.includes(pkg.name)) return false;
1399
- return true;
1400
- }
1401
-
1402
- //#endregion
1403
- //#region src/shared/options.ts
1404
- const DEFAULT_COMMIT_GROUPS = [
1405
- {
1406
- name: "features",
1407
- title: "Features",
1408
- types: ["feat"]
1409
- },
1410
- {
1411
- name: "fixes",
1412
- title: "Bug Fixes",
1413
- types: ["fix", "perf"]
1414
- },
1415
- {
1416
- name: "refactor",
1417
- title: "Refactoring",
1418
- types: ["refactor"]
1419
- },
1420
- {
1421
- name: "docs",
1422
- title: "Documentation",
1423
- types: ["docs"]
1424
- }
1425
- ];
1426
- function normalizeSharedOptions(options) {
1427
- const { workspaceRoot = process.cwd(), githubToken = "", repo: fullRepo, packages = true, prompts: prompts$1 = {
1428
- packages: true,
1429
- versions: true
1430
- }, groups = DEFAULT_COMMIT_GROUPS } = options;
1431
- if (!githubToken.trim()) exitWithError("GitHub token is required", "Set GITHUB_TOKEN environment variable or pass it in options");
1432
- if (!fullRepo || !fullRepo.trim() || !fullRepo.includes("/")) exitWithError("Repository (repo) is required", "Specify the repository in 'owner/repo' format (e.g., 'octocat/hello-world')");
1433
- const [owner, repo] = fullRepo.split("/");
1434
- if (!owner || !repo) exitWithError(`Invalid repo format: "${fullRepo}"`, "Expected format: \"owner/repo\" (e.g., \"octocat/hello-world\")");
1435
- return {
1436
- packages: typeof packages === "object" && !Array.isArray(packages) ? {
1437
- exclude: packages.exclude ?? [],
1438
- include: packages.include ?? [],
1439
- excludePrivate: packages.excludePrivate ?? false
1440
- } : packages,
1441
- prompts: {
1442
- packages: prompts$1?.packages ?? true,
1443
- versions: prompts$1?.versions ?? true
1444
- },
1445
- workspaceRoot,
1446
- githubToken,
1447
- owner,
1448
- repo,
1449
- groups
1450
- };
1451
- }
1452
- async function normalizeReleaseOptions(options) {
1453
- const normalized = normalizeSharedOptions(options);
1454
- let defaultBranch = options.branch?.default?.trim();
1455
- const releaseBranch = options.branch?.release?.trim() ?? "release/next";
1456
- if (defaultBranch == null || defaultBranch === "") {
1457
- defaultBranch = await getDefaultBranch(normalized.workspaceRoot);
1458
- if (!defaultBranch) exitWithError("Could not determine default branch", "Please specify the default branch in options");
1459
- }
1460
- if (defaultBranch === releaseBranch) exitWithError(`Default branch and release branch cannot be the same: "${defaultBranch}"`, "Specify different branches for default and release");
1461
- const availableBranches = await getAvailableBranches(normalized.workspaceRoot);
1462
- if (!availableBranches.includes(defaultBranch)) exitWithError(`Default branch "${defaultBranch}" does not exist in the repository`, `Available branches: ${availableBranches.join(", ")}`);
1463
- logger.verbose(`Using default branch: ${farver.green(defaultBranch)}`);
1464
892
  return {
1465
- ...normalized,
1466
- branch: {
1467
- release: releaseBranch,
1468
- default: defaultBranch
1469
- },
1470
- safeguards: options.safeguards ?? true,
1471
- globalCommitMode: options.globalCommitMode ?? "dependencies",
1472
- pullRequest: {
1473
- title: options.pullRequest?.title ?? "chore: release new version",
1474
- body: options.pullRequest?.body ?? DEFAULT_PR_BODY_TEMPLATE
893
+ async verify() {
894
+ return runProgram(constructVerifyProgram(config));
1475
895
  },
1476
- changelog: {
1477
- enabled: options.changelog?.enabled ?? true,
1478
- template: options.changelog?.template ?? DEFAULT_CHANGELOG_TEMPLATE
1479
- }
1480
- };
1481
- }
1482
-
1483
- //#endregion
1484
- //#region src/release.ts
1485
- async function release(options) {
1486
- const { workspaceRoot,...normalizedOptions } = await normalizeReleaseOptions(options);
1487
- if (normalizedOptions.safeguards && !await isWorkingDirectoryClean(workspaceRoot)) exitWithError("Working directory is not clean. Please commit or stash your changes before proceeding.");
1488
- const workspacePackages = await discoverWorkspacePackages(workspaceRoot, options);
1489
- if (workspacePackages.length === 0) {
1490
- logger.warn("No packages found to release");
1491
- return null;
1492
- }
1493
- logger.section("📦 Workspace Packages");
1494
- logger.item(`Found ${workspacePackages.length} packages`);
1495
- for (const pkg of workspacePackages) {
1496
- logger.item(`${farver.cyan(pkg.name)} (${farver.bold(pkg.version)})`);
1497
- logger.item(` ${farver.gray("→")} ${farver.gray(pkg.path)}`);
1498
- }
1499
- logger.emptyLine();
1500
- const groupedPackageCommits = await getWorkspacePackageGroupedCommits(workspaceRoot, workspacePackages);
1501
- const globalCommitsPerPackage = await getGlobalCommitsPerPackage(workspaceRoot, groupedPackageCommits, workspacePackages, normalizedOptions.globalCommitMode);
1502
- const githubClient = createGitHubClient({
1503
- owner: normalizedOptions.owner,
1504
- repo: normalizedOptions.repo,
1505
- githubToken: normalizedOptions.githubToken
1506
- });
1507
- const prOps = await orchestrateReleasePullRequest({
1508
- workspaceRoot,
1509
- githubClient,
1510
- releaseBranch: normalizedOptions.branch.release,
1511
- defaultBranch: normalizedOptions.branch.default,
1512
- pullRequestTitle: options.pullRequest?.title,
1513
- pullRequestBody: options.pullRequest?.body
1514
- });
1515
- await prOps.prepareBranch();
1516
- const overridesPath = join(workspaceRoot, ucdjsReleaseOverridesPath);
1517
- let existingOverrides = {};
1518
- try {
1519
- const overridesContent = await readFile(overridesPath, "utf-8");
1520
- existingOverrides = JSON.parse(overridesContent);
1521
- logger.info("Found existing version overrides file.");
1522
- } catch {
1523
- logger.info("No existing version overrides file found. Continuing...");
1524
- }
1525
- const { allUpdates, applyUpdates, overrides: newOverrides } = await calculateAndPrepareVersionUpdates({
1526
- workspacePackages,
1527
- packageCommits: groupedPackageCommits,
1528
- workspaceRoot,
1529
- showPrompt: options.prompts?.versions !== false,
1530
- globalCommitsPerPackage,
1531
- overrides: existingOverrides
1532
- });
1533
- if (Object.keys(newOverrides).length > 0) {
1534
- logger.info("Writing version overrides file...");
1535
- try {
1536
- await mkdir(join(workspaceRoot, ".github"), { recursive: true });
1537
- await writeFile(overridesPath, JSON.stringify(newOverrides, null, 2), "utf-8");
1538
- logger.success("Successfully wrote version overrides file.");
1539
- } catch (e) {
1540
- logger.error("Failed to write version overrides file:", e);
1541
- }
1542
- }
1543
- if (Object.keys(newOverrides).length === 0 && Object.keys(existingOverrides).length > 0) {
1544
- let shouldRemoveOverrides = false;
1545
- for (const update of allUpdates) {
1546
- const overriddenVersion = existingOverrides[update.package.name];
1547
- if (overriddenVersion) {
1548
- if (compare(update.newVersion, overriddenVersion.version) > 0) {
1549
- shouldRemoveOverrides = true;
1550
- break;
896
+ async prepare() {
897
+ return runProgram(Effect.gen(function* () {
898
+ const git = yield* GitService;
899
+ const github = yield* GitHubService;
900
+ const dependencyGraph = yield* DependencyGraphService;
901
+ const packageUpdater = yield* PackageUpdaterService;
902
+ const versionCalculator = yield* VersionCalculatorService;
903
+ const workspace = yield* WorkspaceService;
904
+ yield* safeguardProgram;
905
+ const releasePullRequest = yield* github.getPullRequestByBranch(config.branch.release);
906
+ if (!releasePullRequest || !releasePullRequest.head) return yield* Effect.fail(/* @__PURE__ */ new Error(`Release pull request for branch "${config.branch.release}" does not exist.`));
907
+ yield* Console.log(`✅ Release pull request #${releasePullRequest.number} exists.`);
908
+ if ((yield* git.branches.get) !== config.branch.default) {
909
+ yield* git.branches.checkout(config.branch.default);
910
+ yield* Console.log(`✅ Checked out to default branch "${config.branch.default}".`);
1551
911
  }
1552
- }
1553
- }
1554
- if (shouldRemoveOverrides) {
1555
- logger.info("Removing obsolete version overrides file...");
1556
- try {
1557
- await rm(overridesPath);
1558
- logger.success("Successfully removed obsolete version overrides file.");
1559
- } catch (e) {
1560
- logger.error("Failed to remove obsolete version overrides file:", e);
1561
- }
1562
- }
1563
- }
1564
- if (allUpdates.filter((u) => u.hasDirectChanges).length === 0) logger.warn("No packages have changes requiring a release");
1565
- logger.section("🔄 Version Updates");
1566
- logger.item(`Updating ${allUpdates.length} packages (including dependents)`);
1567
- for (const update of allUpdates) logger.item(`${update.package.name}: ${update.currentVersion} → ${update.newVersion}`);
1568
- await applyUpdates();
1569
- if (normalizedOptions.changelog.enabled) {
1570
- logger.step("Updating changelogs");
1571
- const changelogPromises = allUpdates.map((update) => {
1572
- const pkgCommits = groupedPackageCommits.get(update.package.name) || [];
1573
- const globalCommits = globalCommitsPerPackage.get(update.package.name) || [];
1574
- const allCommits = [...pkgCommits, ...globalCommits];
1575
- if (allCommits.length === 0) {
1576
- logger.verbose(`No commits for ${update.package.name}, skipping changelog`);
1577
- return Promise.resolve();
1578
- }
1579
- logger.verbose(`Updating changelog for ${farver.cyan(update.package.name)}`);
1580
- return updateChangelog({
1581
- normalizedOptions: {
1582
- ...normalizedOptions,
1583
- workspaceRoot
1584
- },
1585
- githubClient,
1586
- workspacePackage: update.package,
1587
- version: update.newVersion,
1588
- previousVersion: update.currentVersion !== "0.0.0" ? update.currentVersion : void 0,
1589
- commits: allCommits,
1590
- date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
1591
- });
1592
- }).filter((p) => p != null);
1593
- const updates = await Promise.all(changelogPromises);
1594
- logger.success(`Updated ${updates.length} changelog(s)`);
1595
- }
1596
- if (!await prOps.syncChanges(true)) if (prOps.doesReleasePRExist && prOps.existingPullRequest) {
1597
- logger.item("No updates needed, PR is already up to date");
1598
- const { pullRequest: pullRequest$1, created: created$1 } = await prOps.syncPullRequest(allUpdates);
1599
- await prOps.cleanup();
1600
- return {
1601
- updates: allUpdates,
1602
- prUrl: pullRequest$1?.html_url,
1603
- created: created$1
1604
- };
1605
- } else {
1606
- logger.error("No changes to commit, and no existing PR. Nothing to do.");
1607
- return null;
1608
- }
1609
- const { pullRequest, created } = await prOps.syncPullRequest(allUpdates);
1610
- await prOps.cleanup();
1611
- if (pullRequest?.html_url) {
1612
- logger.section("🚀 Pull Request");
1613
- logger.success(`Pull request ${created ? "created" : "updated"}: ${pullRequest.html_url}`);
1614
- }
1615
- return {
1616
- updates: allUpdates,
1617
- prUrl: pullRequest?.html_url,
1618
- created
1619
- };
1620
- }
1621
- async function orchestrateReleasePullRequest({ workspaceRoot, githubClient, releaseBranch, defaultBranch, pullRequestTitle, pullRequestBody }) {
1622
- const currentBranch = await getCurrentBranch(workspaceRoot);
1623
- if (currentBranch !== defaultBranch) exitWithError(`Current branch is '${currentBranch}'. Please switch to the default branch '${defaultBranch}' before proceeding.`, `git checkout ${defaultBranch}`);
1624
- const existingPullRequest = await githubClient.getExistingPullRequest(releaseBranch);
1625
- const doesReleasePRExist = !!existingPullRequest;
1626
- if (doesReleasePRExist) logger.item("Found existing release pull request");
1627
- else logger.item("Will create new pull request");
1628
- const branchExists = await doesBranchExist(releaseBranch, workspaceRoot);
1629
- return {
1630
- existingPullRequest,
1631
- doesReleasePRExist,
1632
- async prepareBranch() {
1633
- if (!branchExists) await createBranch(releaseBranch, defaultBranch, workspaceRoot);
1634
- logger.step(`Checking out release branch: ${releaseBranch}`);
1635
- if (!await checkoutBranch(releaseBranch, workspaceRoot)) throw new Error(`Failed to checkout branch: ${releaseBranch}`);
1636
- if (branchExists) {
1637
- logger.step("Pulling latest changes from remote");
1638
- if (!await pullLatestChanges(releaseBranch, workspaceRoot)) logger.warn("Failed to pull latest changes, continuing anyway");
1639
- }
1640
- logger.step(`Rebasing onto ${defaultBranch}`);
1641
- if (!await rebaseBranch(defaultBranch, workspaceRoot)) throw new Error(`Failed to rebase onto ${defaultBranch}. Please resolve conflicts manually.`);
1642
- },
1643
- async syncChanges(hasChanges) {
1644
- const hasCommitted = hasChanges ? await commitChanges("chore: update release versions", workspaceRoot) : false;
1645
- const isBranchAhead = await isBranchAheadOfRemote(releaseBranch, workspaceRoot);
1646
- if (!hasCommitted && !isBranchAhead) {
1647
- logger.item("No changes to commit and branch is in sync with remote");
1648
- return false;
1649
- }
1650
- logger.step("Pushing changes to remote");
1651
- if (!await pushBranch(releaseBranch, workspaceRoot, { forceWithLease: true })) throw new Error(`Failed to push changes to ${releaseBranch}. Remote may have been updated.`);
1652
- return true;
912
+ const overrides = yield* loadOverrides({
913
+ sha: releasePullRequest.head.sha,
914
+ overridesPath: ".github/ucdjs-release.overrides.json"
915
+ });
916
+ yield* Console.log("Loaded overrides:", overrides);
917
+ const packages = yield* workspace.discoverWorkspacePackages.pipe(Effect.flatMap(mergePackageCommitsIntoPackages), Effect.flatMap((pkgs) => mergeCommitsAffectingGloballyIntoPackage(pkgs, config.globalCommitMode)));
918
+ yield* Console.log("Discovered packages with commits and global commits:", packages);
919
+ const releases = yield* versionCalculator.calculateBumps(packages, overrides);
920
+ const ordered = yield* dependencyGraph.topologicalOrder(packages);
921
+ yield* Console.log("Calculated releases:", releases);
922
+ yield* Console.log("Release order:", ordered);
923
+ yield* packageUpdater.applyReleases(packages, releases);
924
+ }));
1653
925
  },
1654
- async syncPullRequest(updates) {
1655
- const prTitle = existingPullRequest?.title || pullRequestTitle || "chore: update package versions";
1656
- const prBody = generatePullRequestBody(updates, pullRequestBody);
1657
- const pullRequest = await githubClient.upsertPullRequest({
1658
- pullNumber: existingPullRequest?.number,
1659
- title: prTitle,
1660
- body: prBody,
1661
- head: releaseBranch,
1662
- base: defaultBranch
1663
- });
1664
- logger.success(`${doesReleasePRExist ? "Updated" : "Created"} pull request: ${pullRequest?.html_url}`);
1665
- return {
1666
- pullRequest,
1667
- created: !doesReleasePRExist
1668
- };
926
+ async publish() {
927
+ return runProgram(Effect.gen(function* () {
928
+ return yield* Effect.fail(/* @__PURE__ */ new Error("Not implemented yet."));
929
+ }));
1669
930
  },
1670
- async cleanup() {
1671
- await checkoutBranch(defaultBranch, workspaceRoot);
931
+ packages: {
932
+ async list() {
933
+ return runProgram(Effect.gen(function* () {
934
+ return yield* (yield* WorkspaceService).discoverWorkspacePackages;
935
+ }));
936
+ },
937
+ async get(packageName) {
938
+ return runProgram(Effect.gen(function* () {
939
+ return (yield* (yield* WorkspaceService).findPackageByName(packageName)) || null;
940
+ }));
941
+ }
1672
942
  }
1673
943
  };
1674
944
  }
1675
945
 
1676
946
  //#endregion
1677
- //#region src/verify.ts
1678
- async function verify(options) {
1679
- const { workspaceRoot,...normalizedOptions } = await normalizeReleaseOptions(options);
1680
- if (normalizedOptions.safeguards && !await isWorkingDirectoryClean(workspaceRoot)) exitWithError("Working directory is not clean. Please commit or stash your changes before proceeding.");
1681
- const githubClient = createGitHubClient({
1682
- owner: normalizedOptions.owner,
1683
- repo: normalizedOptions.repo,
1684
- githubToken: normalizedOptions.githubToken
1685
- });
1686
- const releaseBranch = normalizedOptions.branch.release;
1687
- const defaultBranch = normalizedOptions.branch.default;
1688
- const releasePr = await githubClient.getExistingPullRequest(releaseBranch);
1689
- if (!releasePr || !releasePr.head) {
1690
- logger.warn(`No open release pull request found for branch "${releaseBranch}". Nothing to verify.`);
1691
- return;
1692
- }
1693
- logger.info(`Found release PR #${releasePr.number}. Verifying against default branch "${defaultBranch}"...`);
1694
- const originalBranch = await getCurrentBranch(workspaceRoot);
1695
- if (originalBranch !== defaultBranch) await checkoutBranch(defaultBranch, workspaceRoot);
1696
- const overridesPath = ucdjsReleaseOverridesPath;
1697
- let existingOverrides = {};
1698
- try {
1699
- const overridesContent = await readFileFromGit(workspaceRoot, releasePr.head.sha, overridesPath);
1700
- if (overridesContent) {
1701
- existingOverrides = JSON.parse(overridesContent);
1702
- logger.info("Found existing version overrides file on release branch.");
1703
- }
1704
- } catch {
1705
- logger.info("No version overrides file found on release branch. Continuing...");
1706
- }
1707
- const mainPackages = await discoverWorkspacePackages(workspaceRoot, options);
1708
- const mainCommits = await getWorkspacePackageGroupedCommits(workspaceRoot, mainPackages);
1709
- const { allUpdates: expectedUpdates } = await calculateAndPrepareVersionUpdates({
1710
- workspacePackages: mainPackages,
1711
- packageCommits: mainCommits,
1712
- workspaceRoot,
1713
- showPrompt: false,
1714
- globalCommitsPerPackage: await getGlobalCommitsPerPackage(workspaceRoot, mainCommits, mainPackages, normalizedOptions.globalCommitMode),
1715
- overrides: existingOverrides
1716
- });
1717
- const expectedVersionMap = new Map(expectedUpdates.map((u) => [u.package.name, u.newVersion]));
1718
- const prVersionMap = /* @__PURE__ */ new Map();
1719
- for (const pkg of mainPackages) {
1720
- const pkgJsonPath = relative(workspaceRoot, join(pkg.path, "package.json"));
1721
- const pkgJsonContent = await readFileFromGit(workspaceRoot, releasePr.head.sha, pkgJsonPath);
1722
- if (pkgJsonContent) {
1723
- const pkgJson = JSON.parse(pkgJsonContent);
1724
- prVersionMap.set(pkg.name, pkgJson.version);
1725
- }
1726
- }
1727
- if (originalBranch !== defaultBranch) await checkoutBranch(originalBranch, workspaceRoot);
1728
- let isOutOfSync = false;
1729
- for (const [pkgName, expectedVersion] of expectedVersionMap.entries()) {
1730
- const prVersion = prVersionMap.get(pkgName);
1731
- if (!prVersion) {
1732
- logger.warn(`Package "${pkgName}" found in default branch but not in release branch. Skipping.`);
1733
- continue;
1734
- }
1735
- if (gt(expectedVersion, prVersion)) {
1736
- logger.error(`Package "${pkgName}" is out of sync. Expected version >= ${expectedVersion}, but PR has ${prVersion}.`);
1737
- isOutOfSync = true;
1738
- } else logger.success(`Package "${pkgName}" is up to date (PR version: ${prVersion}, Expected: ${expectedVersion})`);
1739
- }
1740
- const statusContext = "ucdjs/release-verify";
1741
- if (isOutOfSync) {
1742
- await githubClient.setCommitStatus({
1743
- sha: releasePr.head.sha,
1744
- state: "failure",
1745
- context: statusContext,
1746
- description: "Release PR is out of sync with the default branch. Please re-run the release process."
1747
- });
1748
- logger.error("Verification failed. Commit status set to 'failure'.");
1749
- } else {
1750
- await githubClient.setCommitStatus({
1751
- sha: releasePr.head.sha,
1752
- state: "success",
1753
- context: statusContext,
1754
- description: "Release PR is up to date.",
1755
- targetUrl: `https://github.com/${normalizedOptions.owner}/${normalizedOptions.repo}/pull/${releasePr.number}`
1756
- });
1757
- logger.success("Verification successful. Commit status set to 'success'.");
1758
- }
1759
- }
1760
-
1761
- //#endregion
1762
- export { publish, release, verify };
947
+ export { createReleaseScripts };