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

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,314 @@ 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
+
399
+ ### <%= group.title %>
400
+ <% group.commits.forEach((commit) => { %>
401
+
402
+ * <%= commit.line %>
403
+ <% }); %>
404
+
405
+ <% } %>
406
+ <% }); %>
407
+ `;
408
+ async function generateChangelogEntry(options) {
409
+ const { packageName, version, previousVersion, date, commits, owner, repo, groups, template, githubClient } = options;
410
+ const compareUrl = previousVersion ? `https://github.com/${owner}/${repo}/compare/${packageName}@${previousVersion}...${packageName}@${version}` : void 0;
411
+ const grouped = groupByType(commits, {
412
+ includeNonConventional: false,
413
+ mergeKeys: Object.fromEntries(groups.map((g) => [g.name, g.types]))
414
+ });
415
+ const commitAuthors = await resolveCommitAuthors(commits, githubClient);
416
+ const templateData = {
417
+ packageName,
418
+ version,
419
+ previousVersion,
420
+ date,
421
+ compareUrl,
422
+ owner,
423
+ repo,
424
+ groups: groups.map((group) => {
425
+ const commitsInGroup = grouped.get(group.name) ?? [];
426
+ if (commitsInGroup.length > 0) logger.verbose(`Found ${commitsInGroup.length} commits for group "${group.name}".`);
427
+ const formattedCommits = commitsInGroup.map((commit) => ({ line: formatCommitLine({
428
+ commit,
429
+ owner,
430
+ repo,
431
+ authors: commitAuthors.get(commit.hash) ?? []
432
+ }) }));
433
+ return {
434
+ name: group.name,
435
+ title: group.title,
436
+ commits: formattedCommits
437
+ };
438
+ })
439
+ };
440
+ const eta = new Eta();
441
+ const templateToUse = template || DEFAULT_CHANGELOG_TEMPLATE;
442
+ return eta.renderString(templateToUse, templateData).trim();
443
+ }
444
+ async function updateChangelog(options) {
445
+ const { version, previousVersion, commits, date, normalizedOptions, workspacePackage, githubClient } = options;
446
+ const changelogPath = join(workspacePackage.path, "CHANGELOG.md");
447
+ const changelogRelativePath = relative(normalizedOptions.workspaceRoot, join(workspacePackage.path, "CHANGELOG.md"));
448
+ const existingContent = await readFileFromGit(normalizedOptions.workspaceRoot, normalizedOptions.branch.default, changelogRelativePath);
449
+ logger.verbose("Existing content found: ", Boolean(existingContent));
450
+ const newEntry = await generateChangelogEntry({
451
+ packageName: workspacePackage.name,
452
+ version,
453
+ previousVersion,
454
+ date,
455
+ commits,
456
+ owner: normalizedOptions.owner,
457
+ repo: normalizedOptions.repo,
458
+ groups: normalizedOptions.groups,
459
+ template: normalizedOptions.changelog?.template,
460
+ githubClient
461
+ });
462
+ let updatedContent;
463
+ if (!existingContent) {
464
+ updatedContent = `# ${workspacePackage.name}\n\n${newEntry}\n`;
465
+ await writeFile(changelogPath, updatedContent, "utf-8");
466
+ return;
467
+ }
468
+ const parsed = parseChangelog(existingContent);
469
+ const lines = existingContent.split("\n");
470
+ const existingVersionIndex = parsed.versions.findIndex((v) => v.version === version);
471
+ if (existingVersionIndex !== -1) {
472
+ const existingVersion = parsed.versions[existingVersionIndex];
473
+ const before = lines.slice(0, existingVersion.lineStart);
474
+ const after = lines.slice(existingVersion.lineEnd + 1);
475
+ updatedContent = [
476
+ ...before,
477
+ newEntry,
478
+ ...after
479
+ ].join("\n");
480
+ } else {
481
+ const insertAt = parsed.headerLineEnd + 1;
482
+ const before = lines.slice(0, insertAt);
483
+ const after = lines.slice(insertAt);
484
+ if (before.length > 0 && before[before.length - 1] !== "") before.push("");
485
+ updatedContent = [
486
+ ...before,
487
+ newEntry,
488
+ "",
489
+ ...after
490
+ ].join("\n");
491
+ }
492
+ await writeFile(changelogPath, updatedContent, "utf-8");
493
+ }
494
+ async function resolveCommitAuthors(commits, githubClient) {
495
+ const authorsByEmail = /* @__PURE__ */ new Map();
496
+ const commitAuthors = /* @__PURE__ */ new Map();
497
+ for (const commit of commits) {
498
+ const authorsForCommit = [];
499
+ commit.authors.forEach((author, idx) => {
500
+ if (!author.email || !author.name) return;
501
+ if (!authorsByEmail.has(author.email)) authorsByEmail.set(author.email, {
502
+ commits: [],
503
+ name: author.name,
504
+ email: author.email
505
+ });
506
+ const info = authorsByEmail.get(author.email);
507
+ if (idx === 0) info.commits.push(commit.shortHash);
508
+ authorsForCommit.push(info);
509
+ });
510
+ commitAuthors.set(commit.hash, authorsForCommit);
511
+ }
512
+ await Promise.all(Array.from(authorsByEmail.values()).map((info) => githubClient.resolveAuthorInfo(info)));
513
+ return commitAuthors;
514
+ }
515
+ function formatCommitLine({ commit, owner, repo, authors }) {
516
+ const commitUrl = `https://github.com/${owner}/${repo}/commit/${commit.hash}`;
517
+ let line = `${commit.description}`;
518
+ const references = commit.references ?? [];
519
+ if (references.length > 0) logger.verbose("Located references in commit", references.length);
520
+ for (const ref of references) {
521
+ if (!ref.value) continue;
522
+ const number = Number.parseInt(ref.value.replace(/^#/, ""), 10);
523
+ if (Number.isNaN(number)) continue;
524
+ if (ref.type === "issue") {
525
+ line += ` ([Issue ${ref.value}](https://github.com/${owner}/${repo}/issues/${number}))`;
526
+ continue;
527
+ }
528
+ line += ` ([PR ${ref.value}](https://github.com/${owner}/${repo}/pull/${number}))`;
529
+ }
530
+ line += ` ([${commit.shortHash}](${commitUrl}))`;
531
+ if (authors.length > 0) {
532
+ const authorList = authors.map((author) => {
533
+ if (author.login) return `[@${author.login}](https://github.com/${author.login})`;
534
+ return author.name;
535
+ }).join(", ");
536
+ line += ` (by ${authorList})`;
537
+ }
538
+ return line;
539
+ }
540
+ function parseChangelog(content) {
541
+ const lines = content.split("\n");
542
+ let packageName = null;
543
+ let headerLineEnd = -1;
544
+ const versions = [];
545
+ for (let i = 0; i < lines.length; i++) {
546
+ const line = lines[i].trim();
547
+ if (line.startsWith("# ")) {
548
+ packageName = line.slice(2).trim();
549
+ headerLineEnd = i;
550
+ break;
551
+ }
552
+ }
553
+ for (let i = headerLineEnd + 1; i < lines.length; i++) {
554
+ const line = lines[i].trim();
555
+ if (line.startsWith("## ")) {
556
+ const versionMatch = line.match(/##\s+(?:<small>)?\[?([^\](\s<]+)/);
557
+ if (versionMatch) {
558
+ const version = versionMatch[1];
559
+ const lineStart = i;
560
+ let lineEnd = lines.length - 1;
561
+ for (let j = i + 1; j < lines.length; j++) if (lines[j].trim().startsWith("## ")) {
562
+ lineEnd = j - 1;
563
+ break;
564
+ }
565
+ const versionContent = lines.slice(lineStart, lineEnd + 1).join("\n");
566
+ versions.push({
567
+ version,
568
+ lineStart,
569
+ lineEnd,
570
+ content: versionContent
571
+ });
572
+ }
573
+ }
574
+ }
575
+ return {
576
+ packageName,
577
+ versions,
578
+ headerLineEnd
579
+ };
580
+ }
331
581
 
332
582
  //#endregion
333
583
  //#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;
584
+ var GitHubClient = class {
585
+ owner;
586
+ repo;
587
+ githubToken;
588
+ apiBase = "https://api.github.com";
589
+ constructor({ owner, repo, githubToken }) {
590
+ this.owner = owner;
591
+ this.repo = repo;
592
+ this.githubToken = githubToken;
593
+ }
594
+ async request(path, init = {}) {
595
+ const url = path.startsWith("http") ? path : `${this.apiBase}${path}`;
596
+ const res = await fetch(url, {
597
+ ...init,
598
+ headers: {
599
+ ...init.headers,
600
+ Accept: "application/vnd.github.v3+json",
601
+ Authorization: `token ${this.githubToken}`
602
+ }
603
+ });
604
+ if (!res.ok) {
605
+ const errorText = await res.text();
606
+ throw new Error(`GitHub API request failed with status ${res.status}: ${errorText || "No response body"}`);
607
+ }
608
+ if (res.status === 204) return;
609
+ return res.json();
610
+ }
611
+ async getExistingPullRequest(branch) {
612
+ const head = branch.includes(":") ? branch : `${this.owner}:${branch}`;
613
+ const endpoint = `/repos/${this.owner}/${this.repo}/pulls?state=open&head=${encodeURIComponent(head)}`;
614
+ logger.verbose(`Requesting pull request for branch: ${branch} (url: ${this.apiBase}${endpoint})`);
615
+ const pulls = await this.request(endpoint);
616
+ if (!Array.isArray(pulls) || pulls.length === 0) return null;
344
617
  const firstPullRequest = pulls[0];
345
618
  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
619
  const pullRequest = {
@@ -348,20 +621,15 @@ async function getExistingPullRequest({ owner, repo, branch, githubToken }) {
348
621
  title: firstPullRequest.title,
349
622
  body: firstPullRequest.body,
350
623
  draft: firstPullRequest.draft,
351
- html_url: firstPullRequest.html_url
624
+ html_url: firstPullRequest.html_url,
625
+ 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
626
  };
353
627
  logger.info(`Found existing pull request: ${farver.yellow(`#${pullRequest.number}`)}`);
354
628
  return pullRequest;
355
- } catch (err) {
356
- logger.error("Error fetching pull request:", err);
357
- return null;
358
629
  }
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";
630
+ async upsertPullRequest({ title, body, head, base, pullNumber }) {
631
+ const isUpdate = typeof pullNumber === "number";
632
+ const endpoint = isUpdate ? `/repos/${this.owner}/${this.repo}/pulls/${pullNumber}` : `/repos/${this.owner}/${this.repo}/pulls`;
365
633
  const requestBody = isUpdate ? {
366
634
  title,
367
635
  body
@@ -372,17 +640,11 @@ async function upsertPullRequest({ owner, repo, title, body, head, base, pullNum
372
640
  base,
373
641
  draft: true
374
642
  };
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
- },
643
+ logger.verbose(`${isUpdate ? "Updating" : "Creating"} pull request (url: ${this.apiBase}${endpoint})`);
644
+ const pr = await this.request(endpoint, {
645
+ method: isUpdate ? "PATCH" : "POST",
382
646
  body: JSON.stringify(requestBody)
383
647
  });
384
- if (!res.ok) throw new Error(`GitHub API request failed with status ${res.status}`);
385
- const pr = await res.json();
386
648
  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
649
  const action = isUpdate ? "Updated" : "Created";
388
650
  logger.info(`${action} pull request: ${farver.yellow(`#${pr.number}`)}`);
@@ -393,12 +655,45 @@ async function upsertPullRequest({ owner, repo, title, body, head, base, pullNum
393
655
  draft: pr.draft,
394
656
  html_url: pr.html_url
395
657
  };
396
- } catch (err) {
397
- logger.error(`Error upserting pull request:`, err);
398
- throw err;
399
658
  }
659
+ async setCommitStatus({ sha, state, targetUrl, description, context }) {
660
+ const endpoint = `/repos/${this.owner}/${this.repo}/statuses/${sha}`;
661
+ logger.verbose(`Setting commit status on ${sha} to ${state} (url: ${this.apiBase}${endpoint})`);
662
+ await this.request(endpoint, {
663
+ method: "POST",
664
+ body: JSON.stringify({
665
+ state,
666
+ target_url: targetUrl,
667
+ description: description || "",
668
+ context
669
+ })
670
+ });
671
+ logger.info(`Commit status set to ${farver.cyan(state)} for ${farver.gray(sha.substring(0, 7))}`);
672
+ }
673
+ async resolveAuthorInfo(info) {
674
+ if (info.login) return info;
675
+ try {
676
+ const q = encodeURIComponent(`${info.email} type:user in:email`);
677
+ const data = await this.request(`/search/users?q=${q}`);
678
+ if (!data.items || data.items.length === 0) return info;
679
+ info.login = data.items[0].login;
680
+ } catch (err) {
681
+ logger.warn(`Failed to resolve author info for email ${info.email}: ${err.message}`);
682
+ }
683
+ if (info.login) return info;
684
+ if (info.commits.length > 0) try {
685
+ const data = await this.request(`/repos/${this.owner}/${this.repo}/commits/${info.commits[0]}`);
686
+ if (data.author && data.author.login) info.login = data.author.login;
687
+ } catch (err) {
688
+ logger.warn(`Failed to resolve author info from commits for email ${info.email}: ${err.message}`);
689
+ }
690
+ return info;
691
+ }
692
+ };
693
+ function createGitHubClient(options) {
694
+ return new GitHubClient(options);
400
695
  }
401
- const defaultTemplate = dedent`
696
+ const DEFAULT_PR_BODY_TEMPLATE = dedent`
402
697
  This PR was automatically generated by the release script.
403
698
 
404
699
  The following packages have been prepared for release:
@@ -421,7 +716,7 @@ function dedentString(str) {
421
716
  }
422
717
  function generatePullRequestBody(updates, body) {
423
718
  const eta = new Eta();
424
- const bodyTemplate = body ? dedentString(body) : defaultTemplate;
719
+ const bodyTemplate = body ? dedentString(body) : DEFAULT_PR_BODY_TEMPLATE;
425
720
  return eta.renderString(bodyTemplate, { packages: updates.map((u) => ({
426
721
  name: u.package.name,
427
722
  currentVersion: u.currentVersion,
@@ -433,24 +728,6 @@ function generatePullRequestBody(updates, body) {
433
728
 
434
729
  //#endregion
435
730
  //#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
731
  function determineHighestBump(commits) {
455
732
  if (commits.length === 0) return "none";
456
733
  let highestBump = "none";
@@ -490,33 +767,6 @@ async function getWorkspacePackageGroupedCommits(workspaceRoot, packages) {
490
767
  for (const { pkgName, commits } of results) changedPackages.set(pkgName, commits);
491
768
  return changedPackages;
492
769
  }
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
770
  /**
521
771
  * Check if a file path touches any package folder.
522
772
  * @param file - The file path to check
@@ -599,7 +849,7 @@ async function getGlobalCommitsPerPackage(workspaceRoot, packageCommits, allPack
599
849
  return result;
600
850
  }
601
851
  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);
852
+ const commitFilesMap = await getGroupedFilesByCommitSha(workspaceRoot, commitRange.oldest, commitRange.newest);
603
853
  if (!commitFilesMap) {
604
854
  logger.warn("Failed to get commit file list, returning empty global commits");
605
855
  return result;
@@ -814,7 +1064,7 @@ function formatCommitsForDisplay(commits) {
814
1064
  const commitsToShow = commits.slice(0, maxCommitsToShow);
815
1065
  const hasMore = commits.length > maxCommitsToShow;
816
1066
  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);
1067
+ const scopeLength = commits.map(({ scope }) => scope?.length).reduce((a, b) => Math.max(a || 0, b || 0), 0) || 0;
818
1068
  const formattedCommits = commitsToShow.map((commit) => {
819
1069
  let color = messageColorMap[commit.type] || ((c) => c);
820
1070
  if (commit.isBreaking) color = (s) => farver.inverse.red(s);
@@ -1108,6 +1358,87 @@ function shouldIncludePackage(pkg, options) {
1108
1358
  return true;
1109
1359
  }
1110
1360
 
1361
+ //#endregion
1362
+ //#region src/shared/options.ts
1363
+ const DEFAULT_COMMIT_GROUPS = [
1364
+ {
1365
+ name: "features",
1366
+ title: "Features",
1367
+ types: ["feat"]
1368
+ },
1369
+ {
1370
+ name: "fixes",
1371
+ title: "Bug Fixes",
1372
+ types: ["fix", "perf"]
1373
+ },
1374
+ {
1375
+ name: "refactor",
1376
+ title: "Refactoring",
1377
+ types: ["refactor"]
1378
+ },
1379
+ {
1380
+ name: "docs",
1381
+ title: "Documentation",
1382
+ types: ["docs"]
1383
+ }
1384
+ ];
1385
+ function normalizeSharedOptions(options) {
1386
+ const { workspaceRoot = process.cwd(), githubToken = "", repo: fullRepo, packages = true, prompts: prompts$1 = {
1387
+ packages: true,
1388
+ versions: true
1389
+ }, groups = DEFAULT_COMMIT_GROUPS } = options;
1390
+ if (!githubToken.trim()) exitWithError("GitHub token is required", "Set GITHUB_TOKEN environment variable or pass it in options");
1391
+ if (!fullRepo || !fullRepo.trim() || !fullRepo.includes("/")) exitWithError("Repository (repo) is required", "Specify the repository in 'owner/repo' format (e.g., 'octocat/hello-world')");
1392
+ const [owner, repo] = fullRepo.split("/");
1393
+ if (!owner || !repo) exitWithError(`Invalid repo format: "${fullRepo}"`, "Expected format: \"owner/repo\" (e.g., \"octocat/hello-world\")");
1394
+ return {
1395
+ packages: typeof packages === "object" && !Array.isArray(packages) ? {
1396
+ exclude: packages.exclude ?? [],
1397
+ include: packages.include ?? [],
1398
+ excludePrivate: packages.excludePrivate ?? false
1399
+ } : packages,
1400
+ prompts: {
1401
+ packages: prompts$1?.packages ?? true,
1402
+ versions: prompts$1?.versions ?? true
1403
+ },
1404
+ workspaceRoot,
1405
+ githubToken,
1406
+ owner,
1407
+ repo,
1408
+ groups
1409
+ };
1410
+ }
1411
+ async function normalizeReleaseOptions(options) {
1412
+ const normalized = normalizeSharedOptions(options);
1413
+ let defaultBranch = options.branch?.default?.trim();
1414
+ const releaseBranch = options.branch?.release?.trim() ?? "release/next";
1415
+ if (defaultBranch == null || defaultBranch === "") {
1416
+ defaultBranch = await getDefaultBranch(normalized.workspaceRoot);
1417
+ if (!defaultBranch) exitWithError("Could not determine default branch", "Please specify the default branch in options");
1418
+ }
1419
+ if (defaultBranch === releaseBranch) exitWithError(`Default branch and release branch cannot be the same: "${defaultBranch}"`, "Specify different branches for default and release");
1420
+ const availableBranches = await getAvailableBranches(normalized.workspaceRoot);
1421
+ if (!availableBranches.includes(defaultBranch)) exitWithError(`Default branch "${defaultBranch}" does not exist in the repository`, `Available branches: ${availableBranches.join(", ")}`);
1422
+ logger.verbose(`Using default branch: ${farver.green(defaultBranch)}`);
1423
+ return {
1424
+ ...normalized,
1425
+ branch: {
1426
+ release: releaseBranch,
1427
+ default: defaultBranch
1428
+ },
1429
+ safeguards: options.safeguards ?? true,
1430
+ globalCommitMode: options.globalCommitMode ?? "dependencies",
1431
+ pullRequest: {
1432
+ title: options.pullRequest?.title ?? "chore: release new version",
1433
+ body: options.pullRequest?.body ?? DEFAULT_PR_BODY_TEMPLATE
1434
+ },
1435
+ changelog: {
1436
+ enabled: options.changelog?.enabled ?? true,
1437
+ template: options.changelog?.template ?? DEFAULT_CHANGELOG_TEMPLATE
1438
+ }
1439
+ };
1440
+ }
1441
+
1111
1442
  //#endregion
1112
1443
  //#region src/release.ts
1113
1444
  async function release(options) {
@@ -1138,11 +1469,14 @@ async function release(options) {
1138
1469
  logger.section("🔄 Version Updates");
1139
1470
  logger.item(`Updating ${allUpdates.length} packages (including dependents)`);
1140
1471
  for (const update of allUpdates) logger.item(`${update.package.name}: ${update.currentVersion} → ${update.newVersion}`);
1141
- const prOps = await orchestrateReleasePullRequest({
1142
- workspaceRoot,
1472
+ const githubClient = createGitHubClient({
1143
1473
  owner: normalizedOptions.owner,
1144
1474
  repo: normalizedOptions.repo,
1145
- githubToken: normalizedOptions.githubToken,
1475
+ githubToken: normalizedOptions.githubToken
1476
+ });
1477
+ const prOps = await orchestrateReleasePullRequest({
1478
+ workspaceRoot,
1479
+ githubClient,
1146
1480
  releaseBranch: normalizedOptions.branch.release,
1147
1481
  defaultBranch: normalizedOptions.branch.default,
1148
1482
  pullRequestTitle: options.pullRequest?.title,
@@ -1150,6 +1484,33 @@ async function release(options) {
1150
1484
  });
1151
1485
  await prOps.prepareBranch();
1152
1486
  await applyUpdates();
1487
+ if (normalizedOptions.changelog.enabled) {
1488
+ logger.step("Updating changelogs");
1489
+ const changelogPromises = allUpdates.map((update) => {
1490
+ const pkgCommits = groupedPackageCommits.get(update.package.name) || [];
1491
+ const globalCommits = globalCommitsPerPackage.get(update.package.name) || [];
1492
+ const allCommits = [...pkgCommits, ...globalCommits];
1493
+ if (allCommits.length === 0) {
1494
+ logger.verbose(`No commits for ${update.package.name}, skipping changelog`);
1495
+ return Promise.resolve();
1496
+ }
1497
+ logger.verbose(`Updating changelog for ${farver.cyan(update.package.name)}`);
1498
+ return updateChangelog({
1499
+ normalizedOptions: {
1500
+ ...normalizedOptions,
1501
+ workspaceRoot
1502
+ },
1503
+ githubClient,
1504
+ workspacePackage: update.package,
1505
+ version: update.newVersion,
1506
+ previousVersion: update.currentVersion !== "0.0.0" ? update.currentVersion : void 0,
1507
+ commits: allCommits,
1508
+ date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
1509
+ });
1510
+ }).filter((p) => p != null);
1511
+ const updates = await Promise.all(changelogPromises);
1512
+ logger.success(`Updated ${updates.length} changelog(s)`);
1513
+ }
1153
1514
  if (!await prOps.syncChanges(true)) if (prOps.doesReleasePRExist && prOps.existingPullRequest) {
1154
1515
  logger.item("No updates needed, PR is already up to date");
1155
1516
  const { pullRequest: pullRequest$1, created: created$1 } = await prOps.syncPullRequest(allUpdates);
@@ -1175,39 +1536,10 @@ async function release(options) {
1175
1536
  created
1176
1537
  };
1177
1538
  }
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 }) {
1539
+ async function orchestrateReleasePullRequest({ workspaceRoot, githubClient, releaseBranch, defaultBranch, pullRequestTitle, pullRequestBody }) {
1203
1540
  const currentBranch = await getCurrentBranch(workspaceRoot);
1204
1541
  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
- });
1542
+ const existingPullRequest = await githubClient.getExistingPullRequest(releaseBranch);
1211
1543
  const doesReleasePRExist = !!existingPullRequest;
1212
1544
  if (doesReleasePRExist) logger.item("Found existing release pull request");
1213
1545
  else logger.item("Will create new pull request");
@@ -1240,15 +1572,12 @@ async function orchestrateReleasePullRequest({ workspaceRoot, owner, repo, githu
1240
1572
  async syncPullRequest(updates) {
1241
1573
  const prTitle = existingPullRequest?.title || pullRequestTitle || "chore: update package versions";
1242
1574
  const prBody = generatePullRequestBody(updates, pullRequestBody);
1243
- const pullRequest = await upsertPullRequest({
1244
- owner,
1245
- repo,
1575
+ const pullRequest = await githubClient.upsertPullRequest({
1246
1576
  pullNumber: existingPullRequest?.number,
1247
1577
  title: prTitle,
1248
1578
  body: prBody,
1249
1579
  head: releaseBranch,
1250
- base: defaultBranch,
1251
- githubToken
1580
+ base: defaultBranch
1252
1581
  });
1253
1582
  logger.success(`${doesReleasePRExist ? "Updated" : "Created"} pull request: ${pullRequest?.html_url}`);
1254
1583
  return {
@@ -1263,4 +1592,8 @@ async function orchestrateReleasePullRequest({ workspaceRoot, owner, repo, githu
1263
1592
  }
1264
1593
 
1265
1594
  //#endregion
1266
- export { publish, release };
1595
+ //#region src/verify.ts
1596
+ function verify(_options) {}
1597
+
1598
+ //#endregion
1599
+ 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.17",
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",