@ucdjs/release-scripts 0.1.0-beta.15 → 0.1.0-beta.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
- import * as fs from "node:fs";
2
1
  import * as path from "node:path";
2
+ import * as fs from "node:fs";
3
3
 
4
4
  //#region node_modules/.pnpm/eta@4.0.1/node_modules/eta/dist/index.js
5
5
  var EtaError = class extends Error {
@@ -90,8 +90,8 @@ function resolvePath(templatePath, options) {
90
90
  } else throw new EtaFileResolutionError(`Template '${templatePath}' is not in the views directory`);
91
91
  }
92
92
  function dirIsChild(parent, dir) {
93
- const relative = path.relative(parent, dir);
94
- return relative && !relative.startsWith("..") && !path.isAbsolute(relative);
93
+ const relative$1 = path.relative(parent, dir);
94
+ return relative$1 && !relative$1.startsWith("..") && !path.isAbsolute(relative$1);
95
95
  }
96
96
  const absolutePathRegExp = /^\\|^\//;
97
97
  /* istanbul ignore next */
package/dist/index.d.mts CHANGED
@@ -11,11 +11,25 @@ interface WorkspacePackage {
11
11
  //#region src/shared/types.d.ts
12
12
  type BumpKind = "none" | "patch" | "minor" | "major";
13
13
  type GlobalCommitMode = false | "dependencies" | "all";
14
+ interface CommitGroup {
15
+ /**
16
+ * Unique identifier for the group
17
+ */
18
+ name: string;
19
+ /**
20
+ * Display title (e.g., "Features", "Bug Fixes")
21
+ */
22
+ title: string;
23
+ /**
24
+ * Conventional commit types to include in this group
25
+ */
26
+ types: string[];
27
+ }
14
28
  interface SharedOptions {
15
29
  /**
16
30
  * Repository identifier (e.g., "owner/repo")
17
31
  */
18
- repo: string;
32
+ repo: `${string}/${string}`;
19
33
  /**
20
34
  * Root directory of the workspace (defaults to process.cwd())
21
35
  */
@@ -44,6 +58,12 @@ interface SharedOptions {
44
58
  */
45
59
  versions?: boolean;
46
60
  };
61
+ /**
62
+ * Commit grouping configuration
63
+ * Used for changelog generation and commit display
64
+ * @default DEFAULT_COMMIT_GROUPS
65
+ */
66
+ groups?: CommitGroup[];
47
67
  }
48
68
  interface PackageJson {
49
69
  name: string;
@@ -136,6 +156,10 @@ interface ReleaseOptions extends SharedOptions {
136
156
  * @default true
137
157
  */
138
158
  enabled?: boolean;
159
+ /**
160
+ * Custom changelog entry template (ETA format)
161
+ */
162
+ template?: string;
139
163
  };
140
164
  globalCommitMode?: GlobalCommitMode;
141
165
  }
@@ -155,4 +179,8 @@ interface ReleaseResult {
155
179
  }
156
180
  declare function release(options: ReleaseOptions): Promise<ReleaseResult | null>;
157
181
  //#endregion
158
- export { type PublishOptions, type ReleaseOptions, type ReleaseResult, publish, release };
182
+ //#region src/verify.d.ts
183
+ interface VerifyOptions extends SharedOptions {}
184
+ declare function verify(_options: VerifyOptions): void;
185
+ //#endregion
186
+ export { type PublishOptions, type ReleaseOptions, type ReleaseResult, type VerifyOptions, publish, release, verify };
package/dist/index.mjs CHANGED
@@ -1,12 +1,12 @@
1
- import { t as Eta } from "./eta-Boh7yPZi.mjs";
1
+ import { t as Eta } from "./eta-j5TFRbI4.mjs";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { join, relative } from "node:path";
2
4
  import process from "node:process";
3
5
  import farver from "farver";
4
6
  import mri from "mri";
5
7
  import { exec } from "tinyexec";
6
8
  import { dedent } from "@luxass/utils";
7
- import { join } from "node:path";
8
- import { readFile, writeFile } from "node:fs/promises";
9
- import { getCommits } from "commit-parser";
9
+ import { getCommits, groupByType } from "commit-parser";
10
10
  import prompts from "prompts";
11
11
 
12
12
  //#region src/publish.ts
@@ -49,8 +49,8 @@ const logger = {
49
49
  emptyLine: () => {
50
50
  console.log();
51
51
  },
52
- item: (message) => {
53
- console.log(` ${message}`);
52
+ item: (message, ...args$1) => {
53
+ console.log(` ${message}`, ...args$1);
54
54
  },
55
55
  step: (message) => {
56
56
  console.log(` ${farver.blue("→")} ${message}`);
@@ -78,28 +78,6 @@ function exitWithError(message, hint) {
78
78
  if (hint) console.error(farver.gray(` ${hint}`));
79
79
  process.exit(1);
80
80
  }
81
- function normalizeSharedOptions(options) {
82
- const { workspaceRoot = process.cwd(), githubToken = "", repo: fullRepo, packages = true, prompts: prompts$1 = {
83
- packages: true,
84
- versions: true
85
- },...rest } = options;
86
- if (!githubToken.trim()) exitWithError("GitHub token is required", "Set GITHUB_TOKEN environment variable or pass it in options");
87
- if (!fullRepo || !fullRepo.trim() || !fullRepo.includes("/")) exitWithError("Repository (repo) is required", "Specify the repository in 'owner/repo' format (e.g., 'octocat/hello-world')");
88
- const [owner, repo] = fullRepo.split("/");
89
- if (!owner || !repo) exitWithError(`Invalid repo format: "${fullRepo}"`, "Expected format: \"owner/repo\" (e.g., \"octocat/hello-world\")");
90
- return {
91
- ...rest,
92
- packages,
93
- prompts: {
94
- packages: prompts$1?.packages ?? true,
95
- versions: prompts$1?.versions ?? true
96
- },
97
- workspaceRoot,
98
- githubToken,
99
- owner,
100
- repo
101
- };
102
- }
103
81
  if (isDryRun || isVerbose || isForce) {
104
82
  logger.verbose(farver.inverse(farver.yellow(" Running with special flags ")));
105
83
  logger.verbose({
@@ -328,19 +306,291 @@ async function pushBranch(branch, workspaceRoot, options) {
328
306
  exitWithError(`Failed to push branch: ${branch}`, `Make sure you have permission to push to the remote repository`);
329
307
  }
330
308
  }
309
+ async function readFileFromGit(workspaceRoot, ref, filePath) {
310
+ try {
311
+ return (await run("git", ["show", `${ref}:${filePath}`], { nodeOptions: {
312
+ cwd: workspaceRoot,
313
+ stdio: "pipe"
314
+ } })).stdout;
315
+ } catch {
316
+ return null;
317
+ }
318
+ }
319
+ async function getMostRecentPackageTag(workspaceRoot, packageName) {
320
+ try {
321
+ const { stdout } = await run("git", [
322
+ "tag",
323
+ "--list",
324
+ `${packageName}@*`
325
+ ], { nodeOptions: {
326
+ cwd: workspaceRoot,
327
+ stdio: "pipe"
328
+ } });
329
+ const tags = stdout.split("\n").map((tag) => tag.trim()).filter(Boolean);
330
+ if (tags.length === 0) return;
331
+ return tags.reverse()[0];
332
+ } catch (err) {
333
+ logger.warn(`Failed to get tags for package ${packageName}: ${err.message}`);
334
+ return;
335
+ }
336
+ }
337
+ /**
338
+ * Builds a mapping of commit SHAs to the list of files changed in each commit
339
+ * within a given inclusive range.
340
+ *
341
+ * Internally runs:
342
+ * git log --name-only --format=%H <from>^..<to>
343
+ *
344
+ * Notes
345
+ * - This includes the commit identified by `from` (via `from^..to`).
346
+ * - Order of commits in the resulting Map follows `git log` output
347
+ * (reverse chronological, newest first).
348
+ * - On failure (e.g., invalid refs), the function returns null.
349
+ *
350
+ * @param {string} workspaceRoot Absolute path to the git repository root used as cwd.
351
+ * @param {string} from Starting commit/ref (inclusive).
352
+ * @param {string} to Ending commit/ref (inclusive).
353
+ * @returns {Promise<Map<string, string[]> | null>} Promise resolving to a Map where keys are commit SHAs and values are
354
+ * arrays of file paths changed by that commit, or null on error.
355
+ */
356
+ async function getGroupedFilesByCommitSha(workspaceRoot, from, to) {
357
+ const commitsMap = /* @__PURE__ */ new Map();
358
+ try {
359
+ const { stdout } = await run("git", [
360
+ "log",
361
+ "--name-only",
362
+ "--format=%H",
363
+ `${from}^..${to}`
364
+ ], { nodeOptions: {
365
+ cwd: workspaceRoot,
366
+ stdio: "pipe"
367
+ } });
368
+ const lines = stdout.trim().split("\n").filter((line) => line.trim() !== "");
369
+ let currentSha = null;
370
+ const HASH_REGEX = /^[0-9a-f]{40}$/i;
371
+ for (const line of lines) {
372
+ const trimmedLine = line.trim();
373
+ if (HASH_REGEX.test(trimmedLine)) {
374
+ currentSha = trimmedLine;
375
+ commitsMap.set(currentSha, []);
376
+ continue;
377
+ }
378
+ if (currentSha === null) continue;
379
+ commitsMap.get(currentSha).push(trimmedLine);
380
+ }
381
+ return commitsMap;
382
+ } catch {
383
+ return null;
384
+ }
385
+ }
386
+
387
+ //#endregion
388
+ //#region src/core/changelog.ts
389
+ const DEFAULT_CHANGELOG_TEMPLATE = dedent`
390
+ <% if (it.previousVersion) { %>
391
+ ## [<%= it.version %>](<%= it.compareUrl %>) (<%= it.date %>)
392
+ <% } else { %>
393
+ ## <%= it.version %> (<%= it.date %>)
394
+ <% } %>
395
+
396
+ <% it.groups.forEach((group) => { %>
397
+ <% if (group.commits.length > 0) { %>
398
+ ### <%= group.title %>
399
+
400
+ <% group.commits.forEach((commit) => { %>
401
+ * <%= commit.line %>
402
+ <% }) %>
403
+
404
+ <% } %>
405
+ <% }) %>
406
+ `;
407
+ async function generateChangelogEntry(options) {
408
+ const { packageName, version, previousVersion, date, commits, owner, repo, groups, template, githubClient } = options;
409
+ const compareUrl = previousVersion ? `https://github.com/${owner}/${repo}/compare/${packageName}@${previousVersion}...${packageName}@${version}` : void 0;
410
+ const grouped = groupByType(commits, {
411
+ includeNonConventional: false,
412
+ mergeKeys: Object.fromEntries(groups.map((g) => [g.name, g.types]))
413
+ });
414
+ for (const commit of commits) {
415
+ if (commit.authors.length === 0) continue;
416
+ commit.resolvedAuthors = await Promise.all(commit.authors.map(async (author) => {
417
+ const username = await githubClient.resolveAuthorInfo(author.email);
418
+ return {
419
+ ...author,
420
+ username
421
+ };
422
+ }));
423
+ }
424
+ const templateData = {
425
+ packageName,
426
+ version,
427
+ previousVersion,
428
+ date,
429
+ compareUrl,
430
+ owner,
431
+ repo,
432
+ groups: groups.map((group) => {
433
+ const commitsInGroup = grouped.get(group.name) ?? [];
434
+ if (commitsInGroup.length > 0) logger.verbose(`Found ${commitsInGroup.length} commits for group "${group.name}".`);
435
+ const formattedCommits = commitsInGroup.map((commit) => {
436
+ const commitUrl = `https://github.com/${owner}/${repo}/commit/${commit.hash}`;
437
+ let line = `${commit.description}`;
438
+ if (commit.references.length > 0) logger.verbose("Located references in commit", commit.references.length);
439
+ for (const ref of commit.references) {
440
+ if (!ref.value) continue;
441
+ const number = Number.parseInt(ref.value.replace(/^#/, ""), 10);
442
+ if (Number.isNaN(number)) continue;
443
+ if (ref.type === "issue") {
444
+ line += ` ([Issue ${ref.value}](https://github.com/${owner}/${repo}/issues/${number}))`;
445
+ continue;
446
+ }
447
+ line += ` ([PR ${ref.value}](https://github.com/${owner}/${repo}/pull/${number}))`;
448
+ }
449
+ line += ` ([${commit.shortHash}](${commitUrl}))`;
450
+ if (commit.authors.length > 0) {
451
+ console.log(commit.resolvedAuthors);
452
+ line += ` (by ${commit.authors.map((a) => a.name).join(", ")})`;
453
+ }
454
+ return { line };
455
+ });
456
+ return {
457
+ name: group.name,
458
+ title: group.title,
459
+ commits: formattedCommits
460
+ };
461
+ })
462
+ };
463
+ const eta = new Eta();
464
+ const templateToUse = template || DEFAULT_CHANGELOG_TEMPLATE;
465
+ return eta.renderString(templateToUse, templateData).trim();
466
+ }
467
+ async function updateChangelog(options) {
468
+ const { version, previousVersion, commits, date, normalizedOptions, workspacePackage } = options;
469
+ const changelogPath = join(workspacePackage.path, "CHANGELOG.md");
470
+ const changelogRelativePath = relative(normalizedOptions.workspaceRoot, join(workspacePackage.path, "CHANGELOG.md"));
471
+ const existingContent = await readFileFromGit(normalizedOptions.workspaceRoot, normalizedOptions.branch.default, changelogRelativePath);
472
+ logger.verbose("Existing content found: ", Boolean(existingContent));
473
+ const newEntry = await generateChangelogEntry({
474
+ packageName: workspacePackage.name,
475
+ version,
476
+ previousVersion,
477
+ date,
478
+ commits,
479
+ owner: normalizedOptions.owner,
480
+ repo: normalizedOptions.repo,
481
+ groups: normalizedOptions.groups,
482
+ template: normalizedOptions.changelog?.template,
483
+ githubClient: options.githubClient
484
+ });
485
+ let updatedContent;
486
+ if (!existingContent) {
487
+ updatedContent = `# ${workspacePackage.name}\n\n${newEntry}\n`;
488
+ await writeFile(changelogPath, updatedContent, "utf-8");
489
+ return;
490
+ }
491
+ const parsed = parseChangelog(existingContent);
492
+ const lines = existingContent.split("\n");
493
+ const existingVersionIndex = parsed.versions.findIndex((v) => v.version === version);
494
+ if (existingVersionIndex !== -1) {
495
+ const existingVersion = parsed.versions[existingVersionIndex];
496
+ const before = lines.slice(0, existingVersion.lineStart);
497
+ const after = lines.slice(existingVersion.lineEnd + 1);
498
+ updatedContent = [
499
+ ...before,
500
+ newEntry,
501
+ ...after
502
+ ].join("\n");
503
+ } else {
504
+ const insertAt = parsed.headerLineEnd + 1;
505
+ const before = lines.slice(0, insertAt);
506
+ const after = lines.slice(insertAt);
507
+ if (before.length > 0 && before[before.length - 1] !== "") before.push("");
508
+ updatedContent = [
509
+ ...before,
510
+ newEntry,
511
+ "",
512
+ ...after
513
+ ].join("\n");
514
+ }
515
+ await writeFile(changelogPath, updatedContent, "utf-8");
516
+ }
517
+ function parseChangelog(content) {
518
+ const lines = content.split("\n");
519
+ let packageName = null;
520
+ let headerLineEnd = -1;
521
+ const versions = [];
522
+ for (let i = 0; i < lines.length; i++) {
523
+ const line = lines[i].trim();
524
+ if (line.startsWith("# ")) {
525
+ packageName = line.slice(2).trim();
526
+ headerLineEnd = i;
527
+ break;
528
+ }
529
+ }
530
+ for (let i = headerLineEnd + 1; i < lines.length; i++) {
531
+ const line = lines[i].trim();
532
+ if (line.startsWith("## ")) {
533
+ const versionMatch = line.match(/##\s+(?:<small>)?\[?([^\](\s<]+)/);
534
+ if (versionMatch) {
535
+ const version = versionMatch[1];
536
+ const lineStart = i;
537
+ let lineEnd = lines.length - 1;
538
+ for (let j = i + 1; j < lines.length; j++) if (lines[j].trim().startsWith("## ")) {
539
+ lineEnd = j - 1;
540
+ break;
541
+ }
542
+ const versionContent = lines.slice(lineStart, lineEnd + 1).join("\n");
543
+ versions.push({
544
+ version,
545
+ lineStart,
546
+ lineEnd,
547
+ content: versionContent
548
+ });
549
+ }
550
+ }
551
+ }
552
+ return {
553
+ packageName,
554
+ versions,
555
+ headerLineEnd
556
+ };
557
+ }
331
558
 
332
559
  //#endregion
333
560
  //#region src/core/github.ts
334
- async function getExistingPullRequest({ owner, repo, branch, githubToken }) {
335
- try {
336
- logger.verbose(`Requesting pull request for branch: ${branch} (url: https://api.github.com/repos/${owner}/${repo}/pulls?state=open&head=${branch})`);
337
- const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls?state=open&head=${branch}`, { headers: {
338
- Accept: "application/vnd.github.v3+json",
339
- Authorization: `token ${githubToken}`
340
- } });
341
- if (!res.ok) throw new Error(`GitHub API request failed with status ${res.status}`);
342
- const pulls = await res.json();
343
- if (pulls == null || !Array.isArray(pulls) || pulls.length === 0) return null;
561
+ var GitHubClient = class {
562
+ owner;
563
+ repo;
564
+ githubToken;
565
+ apiBase = "https://api.github.com";
566
+ constructor({ owner, repo, githubToken }) {
567
+ this.owner = owner;
568
+ this.repo = repo;
569
+ this.githubToken = githubToken;
570
+ }
571
+ async request(path, init = {}) {
572
+ const url = path.startsWith("http") ? path : `${this.apiBase}${path}`;
573
+ const res = await fetch(url, {
574
+ ...init,
575
+ headers: {
576
+ ...init.headers,
577
+ Accept: "application/vnd.github.v3+json",
578
+ Authorization: `token ${this.githubToken}`
579
+ }
580
+ });
581
+ if (!res.ok) {
582
+ const errorText = await res.text();
583
+ throw new Error(`GitHub API request failed with status ${res.status}: ${errorText || "No response body"}`);
584
+ }
585
+ if (res.status === 204) return;
586
+ return res.json();
587
+ }
588
+ async getExistingPullRequest(branch) {
589
+ const head = branch.includes(":") ? branch : `${this.owner}:${branch}`;
590
+ const endpoint = `/repos/${this.owner}/${this.repo}/pulls?state=open&head=${encodeURIComponent(head)}`;
591
+ logger.verbose(`Requesting pull request for branch: ${branch} (url: ${this.apiBase}${endpoint})`);
592
+ const pulls = await this.request(endpoint);
593
+ if (!Array.isArray(pulls) || pulls.length === 0) return null;
344
594
  const firstPullRequest = pulls[0];
345
595
  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");
346
596
  const pullRequest = {
@@ -348,20 +598,15 @@ async function getExistingPullRequest({ owner, repo, branch, githubToken }) {
348
598
  title: firstPullRequest.title,
349
599
  body: firstPullRequest.body,
350
600
  draft: firstPullRequest.draft,
351
- html_url: firstPullRequest.html_url
601
+ html_url: firstPullRequest.html_url,
602
+ 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
352
603
  };
353
604
  logger.info(`Found existing pull request: ${farver.yellow(`#${pullRequest.number}`)}`);
354
605
  return pullRequest;
355
- } catch (err) {
356
- logger.error("Error fetching pull request:", err);
357
- return null;
358
606
  }
359
- }
360
- async function upsertPullRequest({ owner, repo, title, body, head, base, pullNumber, githubToken }) {
361
- try {
362
- const isUpdate = pullNumber != null;
363
- const url = isUpdate ? `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}` : `https://api.github.com/repos/${owner}/${repo}/pulls`;
364
- const method = isUpdate ? "PATCH" : "POST";
607
+ async upsertPullRequest({ title, body, head, base, pullNumber }) {
608
+ const isUpdate = typeof pullNumber === "number";
609
+ const endpoint = isUpdate ? `/repos/${this.owner}/${this.repo}/pulls/${pullNumber}` : `/repos/${this.owner}/${this.repo}/pulls`;
365
610
  const requestBody = isUpdate ? {
366
611
  title,
367
612
  body
@@ -372,17 +617,11 @@ async function upsertPullRequest({ owner, repo, title, body, head, base, pullNum
372
617
  base,
373
618
  draft: true
374
619
  };
375
- logger.verbose(`${isUpdate ? "Updating" : "Creating"} pull request (url: ${url})`);
376
- const res = await fetch(url, {
377
- method,
378
- headers: {
379
- Accept: "application/vnd.github.v3+json",
380
- Authorization: `token ${githubToken}`
381
- },
620
+ logger.verbose(`${isUpdate ? "Updating" : "Creating"} pull request (url: ${this.apiBase}${endpoint})`);
621
+ const pr = await this.request(endpoint, {
622
+ method: isUpdate ? "PATCH" : "POST",
382
623
  body: JSON.stringify(requestBody)
383
624
  });
384
- if (!res.ok) throw new Error(`GitHub API request failed with status ${res.status}`);
385
- const pr = await res.json();
386
625
  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");
387
626
  const action = isUpdate ? "Updated" : "Created";
388
627
  logger.info(`${action} pull request: ${farver.yellow(`#${pr.number}`)}`);
@@ -393,12 +632,32 @@ async function upsertPullRequest({ owner, repo, title, body, head, base, pullNum
393
632
  draft: pr.draft,
394
633
  html_url: pr.html_url
395
634
  };
396
- } catch (err) {
397
- logger.error(`Error upserting pull request:`, err);
398
- throw err;
399
635
  }
636
+ async setCommitStatus({ sha, state, targetUrl, description, context }) {
637
+ const endpoint = `/repos/${this.owner}/${this.repo}/statuses/${sha}`;
638
+ logger.verbose(`Setting commit status on ${sha} to ${state} (url: ${this.apiBase}${endpoint})`);
639
+ await this.request(endpoint, {
640
+ method: "POST",
641
+ body: JSON.stringify({
642
+ state,
643
+ target_url: targetUrl,
644
+ description: description || "",
645
+ context
646
+ })
647
+ });
648
+ logger.info(`Commit status set to ${farver.cyan(state)} for ${farver.gray(sha.substring(0, 7))}`);
649
+ }
650
+ async resolveAuthorInfo(email) {
651
+ const q = encodeURIComponent(`${email} type:user in:email`);
652
+ const data = await this.request(`/search/users?q=${q}`);
653
+ if (!data.items || data.items.length === 0) return null;
654
+ return data.items[0].login;
655
+ }
656
+ };
657
+ function createGitHubClient(options) {
658
+ return new GitHubClient(options);
400
659
  }
401
- const defaultTemplate = dedent`
660
+ const DEFAULT_PR_BODY_TEMPLATE = dedent`
402
661
  This PR was automatically generated by the release script.
403
662
 
404
663
  The following packages have been prepared for release:
@@ -421,7 +680,7 @@ function dedentString(str) {
421
680
  }
422
681
  function generatePullRequestBody(updates, body) {
423
682
  const eta = new Eta();
424
- const bodyTemplate = body ? dedentString(body) : defaultTemplate;
683
+ const bodyTemplate = body ? dedentString(body) : DEFAULT_PR_BODY_TEMPLATE;
425
684
  return eta.renderString(bodyTemplate, { packages: updates.map((u) => ({
426
685
  name: u.package.name,
427
686
  currentVersion: u.currentVersion,
@@ -433,24 +692,6 @@ function generatePullRequestBody(updates, body) {
433
692
 
434
693
  //#endregion
435
694
  //#region src/versioning/commits.ts
436
- async function getMostRecentPackageTag(workspaceRoot, packageName) {
437
- try {
438
- const { stdout } = await run("git", [
439
- "tag",
440
- "--list",
441
- `${packageName}@*`
442
- ], { nodeOptions: {
443
- cwd: workspaceRoot,
444
- stdio: "pipe"
445
- } });
446
- const tags = stdout.split("\n").map((tag) => tag.trim()).filter(Boolean);
447
- if (tags.length === 0) return;
448
- return tags.reverse()[0];
449
- } catch (err) {
450
- logger.warn(`Failed to get tags for package ${packageName}: ${err.message}`);
451
- return;
452
- }
453
- }
454
695
  function determineHighestBump(commits) {
455
696
  if (commits.length === 0) return "none";
456
697
  let highestBump = "none";
@@ -490,33 +731,6 @@ async function getWorkspacePackageGroupedCommits(workspaceRoot, packages) {
490
731
  for (const { pkgName, commits } of results) changedPackages.set(pkgName, commits);
491
732
  return changedPackages;
492
733
  }
493
- async function getCommitFileList(workspaceRoot, from, to) {
494
- const commits = /* @__PURE__ */ new Map();
495
- try {
496
- const { stdout } = await run("git", [
497
- "log",
498
- "--name-only",
499
- "--format=%H",
500
- `${from}^..${to}`
501
- ], { nodeOptions: {
502
- cwd: workspaceRoot,
503
- stdio: "pipe"
504
- } });
505
- const lines = stdout.trim().split("\n").filter((line) => line.trim() !== "");
506
- let currentSha = null;
507
- const HASH_REGEX = /^[0-9a-f]{40}$/i;
508
- for (const line of lines) {
509
- const trimmedLine = line.trim();
510
- if (HASH_REGEX.test(trimmedLine)) {
511
- currentSha = trimmedLine;
512
- commits.set(currentSha, []);
513
- } else if (currentSha !== null) commits.get(currentSha)?.push(trimmedLine);
514
- }
515
- return commits;
516
- } catch {
517
- return null;
518
- }
519
- }
520
734
  /**
521
735
  * Check if a file path touches any package folder.
522
736
  * @param file - The file path to check
@@ -599,7 +813,7 @@ async function getGlobalCommitsPerPackage(workspaceRoot, packageCommits, allPack
599
813
  return result;
600
814
  }
601
815
  logger.verbose("Fetching files for commits range", `${farver.cyan(commitRange.oldest)}..${farver.cyan(commitRange.newest)}`);
602
- const commitFilesMap = await getCommitFileList(workspaceRoot, commitRange.oldest, commitRange.newest);
816
+ const commitFilesMap = await getGroupedFilesByCommitSha(workspaceRoot, commitRange.oldest, commitRange.newest);
603
817
  if (!commitFilesMap) {
604
818
  logger.warn("Failed to get commit file list, returning empty global commits");
605
819
  return result;
@@ -814,7 +1028,7 @@ function formatCommitsForDisplay(commits) {
814
1028
  const commitsToShow = commits.slice(0, maxCommitsToShow);
815
1029
  const hasMore = commits.length > maxCommitsToShow;
816
1030
  const typeLength = commits.map(({ type }) => type.length).reduce((a, b) => Math.max(a, b), 0);
817
- const scopeLength = commits.map(({ scope }) => scope.length).reduce((a, b) => Math.max(a, b), 0);
1031
+ const scopeLength = commits.map(({ scope }) => scope?.length).reduce((a, b) => Math.max(a || 0, b || 0), 0) || 0;
818
1032
  const formattedCommits = commitsToShow.map((commit) => {
819
1033
  let color = messageColorMap[commit.type] || ((c) => c);
820
1034
  if (commit.isBreaking) color = (s) => farver.inverse.red(s);
@@ -1108,6 +1322,84 @@ function shouldIncludePackage(pkg, options) {
1108
1322
  return true;
1109
1323
  }
1110
1324
 
1325
+ //#endregion
1326
+ //#region src/shared/options.ts
1327
+ const DEFAULT_COMMIT_GROUPS = [
1328
+ {
1329
+ name: "features",
1330
+ title: "Features",
1331
+ types: ["feat"]
1332
+ },
1333
+ {
1334
+ name: "fixes",
1335
+ title: "Bug Fixes",
1336
+ types: ["fix", "perf"]
1337
+ },
1338
+ {
1339
+ name: "refactor",
1340
+ title: "Refactoring",
1341
+ types: ["refactor"]
1342
+ },
1343
+ {
1344
+ name: "docs",
1345
+ title: "Documentation",
1346
+ types: ["docs"]
1347
+ }
1348
+ ];
1349
+ function normalizeSharedOptions(options) {
1350
+ const { workspaceRoot = process.cwd(), githubToken = "", repo: fullRepo, packages = true, prompts: prompts$1 = {
1351
+ packages: true,
1352
+ versions: true
1353
+ }, groups = DEFAULT_COMMIT_GROUPS } = options;
1354
+ if (!githubToken.trim()) exitWithError("GitHub token is required", "Set GITHUB_TOKEN environment variable or pass it in options");
1355
+ if (!fullRepo || !fullRepo.trim() || !fullRepo.includes("/")) exitWithError("Repository (repo) is required", "Specify the repository in 'owner/repo' format (e.g., 'octocat/hello-world')");
1356
+ const [owner, repo] = fullRepo.split("/");
1357
+ if (!owner || !repo) exitWithError(`Invalid repo format: "${fullRepo}"`, "Expected format: \"owner/repo\" (e.g., \"octocat/hello-world\")");
1358
+ return {
1359
+ packages: typeof packages === "object" && !Array.isArray(packages) ? {
1360
+ exclude: packages.exclude ?? [],
1361
+ include: packages.include ?? [],
1362
+ excludePrivate: packages.excludePrivate ?? false
1363
+ } : packages,
1364
+ prompts: {
1365
+ packages: prompts$1?.packages ?? true,
1366
+ versions: prompts$1?.versions ?? true
1367
+ },
1368
+ workspaceRoot,
1369
+ githubToken,
1370
+ owner,
1371
+ repo,
1372
+ groups
1373
+ };
1374
+ }
1375
+ async function normalizeReleaseOptions(options) {
1376
+ const normalized = normalizeSharedOptions(options);
1377
+ let defaultBranch = options.branch?.default?.trim();
1378
+ const releaseBranch = options.branch?.release?.trim() ?? "release/next";
1379
+ if (defaultBranch == null || defaultBranch === "") {
1380
+ defaultBranch = await getDefaultBranch(normalized.workspaceRoot);
1381
+ if (!defaultBranch) exitWithError("Could not determine default branch", "Please specify the default branch in options");
1382
+ }
1383
+ if (defaultBranch === releaseBranch) exitWithError(`Default branch and release branch cannot be the same: "${defaultBranch}"`, "Specify different branches for default and release");
1384
+ const availableBranches = await getAvailableBranches(normalized.workspaceRoot);
1385
+ if (!availableBranches.includes(defaultBranch)) exitWithError(`Default branch "${defaultBranch}" does not exist in the repository`, `Available branches: ${availableBranches.join(", ")}`);
1386
+ logger.verbose(`Using default branch: ${farver.green(defaultBranch)}`);
1387
+ return {
1388
+ ...normalized,
1389
+ branch: {
1390
+ release: releaseBranch,
1391
+ default: defaultBranch
1392
+ },
1393
+ safeguards: options.safeguards ?? true,
1394
+ globalCommitMode: options.globalCommitMode ?? "dependencies",
1395
+ pullRequest: {
1396
+ title: options.pullRequest?.title ?? "chore: release new version",
1397
+ body: options.pullRequest?.body ?? DEFAULT_PR_BODY_TEMPLATE
1398
+ },
1399
+ changelog: { enabled: options.changelog?.enabled ?? true }
1400
+ };
1401
+ }
1402
+
1111
1403
  //#endregion
1112
1404
  //#region src/release.ts
1113
1405
  async function release(options) {
@@ -1138,11 +1430,14 @@ async function release(options) {
1138
1430
  logger.section("🔄 Version Updates");
1139
1431
  logger.item(`Updating ${allUpdates.length} packages (including dependents)`);
1140
1432
  for (const update of allUpdates) logger.item(`${update.package.name}: ${update.currentVersion} → ${update.newVersion}`);
1141
- const prOps = await orchestrateReleasePullRequest({
1142
- workspaceRoot,
1433
+ const githubClient = createGitHubClient({
1143
1434
  owner: normalizedOptions.owner,
1144
1435
  repo: normalizedOptions.repo,
1145
- githubToken: normalizedOptions.githubToken,
1436
+ githubToken: normalizedOptions.githubToken
1437
+ });
1438
+ const prOps = await orchestrateReleasePullRequest({
1439
+ workspaceRoot,
1440
+ githubClient,
1146
1441
  releaseBranch: normalizedOptions.branch.release,
1147
1442
  defaultBranch: normalizedOptions.branch.default,
1148
1443
  pullRequestTitle: options.pullRequest?.title,
@@ -1150,6 +1445,33 @@ async function release(options) {
1150
1445
  });
1151
1446
  await prOps.prepareBranch();
1152
1447
  await applyUpdates();
1448
+ if (normalizedOptions.changelog.enabled) {
1449
+ logger.step("Updating changelogs");
1450
+ const changelogPromises = allUpdates.map((update) => {
1451
+ const pkgCommits = groupedPackageCommits.get(update.package.name) || [];
1452
+ const globalCommits = globalCommitsPerPackage.get(update.package.name) || [];
1453
+ const allCommits = [...pkgCommits, ...globalCommits];
1454
+ if (allCommits.length === 0) {
1455
+ logger.verbose(`No commits for ${update.package.name}, skipping changelog`);
1456
+ return Promise.resolve();
1457
+ }
1458
+ logger.verbose(`Updating changelog for ${farver.cyan(update.package.name)}`);
1459
+ return updateChangelog({
1460
+ normalizedOptions: {
1461
+ ...normalizedOptions,
1462
+ workspaceRoot
1463
+ },
1464
+ githubClient,
1465
+ workspacePackage: update.package,
1466
+ version: update.newVersion,
1467
+ previousVersion: update.currentVersion !== "0.0.0" ? update.currentVersion : void 0,
1468
+ commits: allCommits,
1469
+ date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
1470
+ });
1471
+ }).filter((p) => p != null);
1472
+ const updates = await Promise.all(changelogPromises);
1473
+ logger.success(`Updated ${updates.length} changelog(s)`);
1474
+ }
1153
1475
  if (!await prOps.syncChanges(true)) if (prOps.doesReleasePRExist && prOps.existingPullRequest) {
1154
1476
  logger.item("No updates needed, PR is already up to date");
1155
1477
  const { pullRequest: pullRequest$1, created: created$1 } = await prOps.syncPullRequest(allUpdates);
@@ -1175,39 +1497,10 @@ async function release(options) {
1175
1497
  created
1176
1498
  };
1177
1499
  }
1178
- async function normalizeReleaseOptions(options) {
1179
- const normalized = normalizeSharedOptions(options);
1180
- let defaultBranch = options.branch?.default?.trim();
1181
- const releaseBranch = options.branch?.release?.trim() ?? "release/next";
1182
- if (defaultBranch == null || defaultBranch === "") {
1183
- defaultBranch = await getDefaultBranch(normalized.workspaceRoot);
1184
- if (!defaultBranch) exitWithError("Could not determine default branch", "Please specify the default branch in options");
1185
- }
1186
- if (defaultBranch === releaseBranch) exitWithError(`Default branch and release branch cannot be the same: "${defaultBranch}"`, "Specify different branches for default and release");
1187
- const availableBranches = await getAvailableBranches(normalized.workspaceRoot);
1188
- if (!availableBranches.includes(defaultBranch)) exitWithError(`Default branch "${defaultBranch}" does not exist in the repository`, `Available branches: ${availableBranches.join(", ")}`);
1189
- logger.verbose(`Using default branch: ${farver.green(defaultBranch)}`);
1190
- return {
1191
- ...normalized,
1192
- branch: {
1193
- release: releaseBranch,
1194
- default: defaultBranch
1195
- },
1196
- safeguards: options.safeguards ?? true,
1197
- globalCommitMode: options.globalCommitMode ?? "dependencies",
1198
- pullRequest: options.pullRequest,
1199
- changelog: { enabled: options.changelog?.enabled ?? true }
1200
- };
1201
- }
1202
- async function orchestrateReleasePullRequest({ workspaceRoot, owner, repo, githubToken, releaseBranch, defaultBranch, pullRequestTitle, pullRequestBody }) {
1500
+ async function orchestrateReleasePullRequest({ workspaceRoot, githubClient, releaseBranch, defaultBranch, pullRequestTitle, pullRequestBody }) {
1203
1501
  const currentBranch = await getCurrentBranch(workspaceRoot);
1204
1502
  if (currentBranch !== defaultBranch) exitWithError(`Current branch is '${currentBranch}'. Please switch to the default branch '${defaultBranch}' before proceeding.`, `git checkout ${defaultBranch}`);
1205
- const existingPullRequest = await getExistingPullRequest({
1206
- owner,
1207
- repo,
1208
- branch: releaseBranch,
1209
- githubToken
1210
- });
1503
+ const existingPullRequest = await githubClient.getExistingPullRequest(releaseBranch);
1211
1504
  const doesReleasePRExist = !!existingPullRequest;
1212
1505
  if (doesReleasePRExist) logger.item("Found existing release pull request");
1213
1506
  else logger.item("Will create new pull request");
@@ -1240,15 +1533,12 @@ async function orchestrateReleasePullRequest({ workspaceRoot, owner, repo, githu
1240
1533
  async syncPullRequest(updates) {
1241
1534
  const prTitle = existingPullRequest?.title || pullRequestTitle || "chore: update package versions";
1242
1535
  const prBody = generatePullRequestBody(updates, pullRequestBody);
1243
- const pullRequest = await upsertPullRequest({
1244
- owner,
1245
- repo,
1536
+ const pullRequest = await githubClient.upsertPullRequest({
1246
1537
  pullNumber: existingPullRequest?.number,
1247
1538
  title: prTitle,
1248
1539
  body: prBody,
1249
1540
  head: releaseBranch,
1250
- base: defaultBranch,
1251
- githubToken
1541
+ base: defaultBranch
1252
1542
  });
1253
1543
  logger.success(`${doesReleasePRExist ? "Updated" : "Created"} pull request: ${pullRequest?.html_url}`);
1254
1544
  return {
@@ -1263,4 +1553,8 @@ async function orchestrateReleasePullRequest({ workspaceRoot, owner, repo, githu
1263
1553
  }
1264
1554
 
1265
1555
  //#endregion
1266
- export { publish, release };
1556
+ //#region src/verify.ts
1557
+ function verify(_options) {}
1558
+
1559
+ //#endregion
1560
+ export { publish, release, verify };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ucdjs/release-scripts",
3
- "version": "0.1.0-beta.15",
3
+ "version": "0.1.0-beta.16",
4
4
  "description": "@ucdjs release scripts",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -13,7 +13,8 @@
13
13
  "#versioning/*": "./src/versioning/*.ts",
14
14
  "#shared/*": "./src/shared/*.ts",
15
15
  "#release": "./src/release.ts",
16
- "#publish": "./src/publish.ts"
16
+ "#publish": "./src/publish.ts",
17
+ "#verify": "./src/verify.ts"
17
18
  },
18
19
  "exports": {
19
20
  ".": "./dist/index.mjs",
@@ -27,7 +28,7 @@
27
28
  ],
28
29
  "dependencies": {
29
30
  "@luxass/utils": "2.7.2",
30
- "commit-parser": "1.0.0",
31
+ "commit-parser": "1.3.0",
31
32
  "farver": "1.0.0-beta.1",
32
33
  "mri": "1.2.0",
33
34
  "prompts": "2.4.2",
@@ -41,7 +42,8 @@
41
42
  "eta": "4.0.1",
42
43
  "tsdown": "0.16.0",
43
44
  "typescript": "5.9.3",
44
- "vitest": "4.0.4"
45
+ "vitest": "4.0.4",
46
+ "vitest-testdirs": "4.3.0"
45
47
  },
46
48
  "scripts": {
47
49
  "build": "tsdown",