@naturalcycles/dev-lib 20.51.3 → 20.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ import { fs2 } from '@naturalcycles/nodejs-lib/fs2';
4
4
  import { runScript } from '@naturalcycles/nodejs-lib/runScript';
5
5
  import { buildCopy, buildProd, eslintAll, lintStagedCommand, runCheck, runOxfmt, runOxlint, runTest, stylelintAll, typecheckWithTS, typecheckWithTSC, typecheckWithTSGO, } from '../check.util.js';
6
6
  import { runCommitlint } from '../commitlint.command.js';
7
+ import { releaseCommand } from '../release/release.command.js';
7
8
  const commands = [
8
9
  { name: 'check', fn: runCheck, desc: '"Run all possible checks": lint, typecheck, then test.' },
9
10
  {
@@ -105,6 +106,12 @@ const commands = [
105
106
  },
106
107
  { name: 'stylelint-no-fix', cliOnly: true, fn: () => stylelintAll(false) },
107
108
  { name: 'commitlint', fn: runCommitlint, desc: 'Run commitlint.', cliOnly: true },
109
+ {
110
+ name: 'release',
111
+ fn: releaseCommand,
112
+ desc: 'Release packages: analyze commits, publish to npm, tag, create GitHub Release. Supports --dry-run.',
113
+ cliOnly: true,
114
+ },
108
115
  {
109
116
  name: 'exit',
110
117
  fn: () => console.log('see you!'),
package/dist/config.d.ts CHANGED
@@ -4,6 +4,35 @@
4
4
  export declare function readDevLibConfigIfPresent(cwd?: string): Promise<DevLibConfig>;
5
5
  export interface DevLibConfig {
6
6
  commitlint?: DevLibCommitlintConfig;
7
+ release?: DevLibReleaseConfig;
8
+ }
9
+ /**
10
+ * Config for `dev-lib release`.
11
+ * The defaults are chosen so that a typical repo needs no config at all.
12
+ */
13
+ export interface DevLibReleaseConfig {
14
+ /**
15
+ * 'monorepo' releases every non-private workspace package, tagged `<pkgName>-v<version>`.
16
+ * 'single' releases the root package, tagged `v<version>`.
17
+ * Defaults to auto-detection: 'monorepo' if pnpm-workspace.yaml exists, 'single' otherwise.
18
+ */
19
+ mode?: 'monorepo' | 'single';
20
+ /**
21
+ * Branches that produce stable releases (npm dist-tag `latest`).
22
+ * Defaults to ['main', 'master'].
23
+ */
24
+ branches?: string[];
25
+ /**
26
+ * Branch globs that produce prereleases, versioned `X.Y.Z-<branch>.N`
27
+ * and published under npm dist-tag = branch name.
28
+ * Defaults to ['beta-*'].
29
+ */
30
+ prereleaseBranches?: string[];
31
+ /**
32
+ * Post a ":tada: This PR is included in <release>" comment on each released PR.
33
+ * Defaults to true.
34
+ */
35
+ successComments?: boolean;
7
36
  }
8
37
  export interface DevLibCommitlintConfig {
9
38
  /**
package/dist/config.js CHANGED
@@ -19,4 +19,12 @@ const devLibConfigSchema = j.object({
19
19
  allowedScopes: j.array(j.string()).optional(),
20
20
  })
21
21
  .optional(),
22
+ release: j.object
23
+ .infer({
24
+ mode: j.enum(['monorepo', 'single']).optional(),
25
+ branches: j.array(j.string()).optional(),
26
+ prereleaseBranches: j.array(j.string()).optional(),
27
+ successComments: j.boolean().optional(),
28
+ })
29
+ .optional(),
22
30
  });
@@ -0,0 +1,14 @@
1
+ import type { ParsedCommit, RawCommit, ReleaseType } from './release.model.js';
2
+ /**
3
+ * Determine the release type from the commits since the last release.
4
+ * Returns the highest release type among the commits, or null if none of them warrants a release.
5
+ *
6
+ * Rules follow the `@semantic-release/commit-analyzer` defaults (conventional commits only):
7
+ * breaking change => major, feat => minor, fix/perf/revert => patch, everything else => no release.
8
+ */
9
+ export declare function analyzeCommits(commits: ParsedCommit[]): ReleaseType | null;
10
+ /**
11
+ * Parse a raw git commit into a conventional commit.
12
+ * https://www.conventionalcommits.org/
13
+ */
14
+ export declare function parseCommit(raw: RawCommit): ParsedCommit;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Determine the release type from the commits since the last release.
3
+ * Returns the highest release type among the commits, or null if none of them warrants a release.
4
+ *
5
+ * Rules follow the `@semantic-release/commit-analyzer` defaults (conventional commits only):
6
+ * breaking change => major, feat => minor, fix/perf/revert => patch, everything else => no release.
7
+ */
8
+ export function analyzeCommits(commits) {
9
+ let result = null;
10
+ for (const commit of commits) {
11
+ const type = getCommitReleaseType(commit);
12
+ if (type === 'major')
13
+ return 'major';
14
+ if (type === 'minor')
15
+ result = 'minor';
16
+ else if (type === 'patch' && !result)
17
+ result = 'patch';
18
+ }
19
+ return result;
20
+ }
21
+ /**
22
+ * Parse a raw git commit into a conventional commit.
23
+ * https://www.conventionalcommits.org/
24
+ */
25
+ export function parseCommit(raw) {
26
+ const { hash, subject, body } = raw;
27
+ // Same pattern as commitlint.util.ts: type(scope)!: description
28
+ const match = subject.match(/^(\w+)(?:\(([^)]+)\))?(!)?\s*:\s*(.+)$/);
29
+ const type = match?.[1];
30
+ const scope = match?.[2];
31
+ const bang = !!match?.[3];
32
+ const description = match?.[4] || subject;
33
+ // `BREAKING CHANGE:` (or `BREAKING-CHANGE:`) footer, per the conventional commits spec
34
+ const breakingMatch = body.match(/(?:^|\n)BREAKING[ -]CHANGE:\s*([\s\S]*)/);
35
+ const breaking = bang || !!breakingMatch;
36
+ // Detect both `revert:` conventional commits and git-generated `Revert "..."` commits
37
+ const revert = type === 'revert' || subject.startsWith('Revert ');
38
+ return {
39
+ hash,
40
+ subject,
41
+ body,
42
+ type,
43
+ scope,
44
+ description,
45
+ breaking,
46
+ breakingNote: breaking ? breakingMatch?.[1]?.trim() || description : undefined,
47
+ revert,
48
+ };
49
+ }
50
+ function getCommitReleaseType(commit) {
51
+ if (commit.breaking)
52
+ return 'major';
53
+ if (commit.revert)
54
+ return 'patch';
55
+ if (commit.type === 'feat')
56
+ return 'minor';
57
+ if (commit.type === 'fix' || commit.type === 'perf')
58
+ return 'patch';
59
+ return null;
60
+ }
@@ -0,0 +1,35 @@
1
+ import type { RawCommit, RepoInfo } from './release.model.js';
2
+ /**
3
+ * Parse the `origin` remote into owner/repo.
4
+ * Supports both `git@github.com:Owner/repo.git` and `https://github.com/Owner/repo(.git)` remotes.
5
+ */
6
+ export declare function getRepoInfo(): RepoInfo;
7
+ export declare function getCurrentBranch(): string;
8
+ /**
9
+ * Make sure the full git history and all tags are available:
10
+ * CI checkouts are often shallow and without tags, while release calculation is based on tags.
11
+ * Tolerates fetch failures (e.g. offline local dry-run) with a warning.
12
+ */
13
+ export declare function ensureFullGitHistory(): void;
14
+ /**
15
+ * Abort if someone pushed to the branch since this checkout - releasing from a stale HEAD
16
+ * would compute wrong versions/notes. No-op if the branch has no remote counterpart.
17
+ */
18
+ export declare function checkHeadIsUpToDateWithRemote(branch: string): void;
19
+ /**
20
+ * All git tags reachable from HEAD.
21
+ * Reachability matters: on a prerelease branch this naturally excludes tags of other
22
+ * prerelease branches, matching semantic-release (which uses `git tag --merged`).
23
+ */
24
+ export declare function getReachableTags(): string[];
25
+ /**
26
+ * Commits since the given tag (or all history when no tag), newest first,
27
+ * optionally limited to the given directory (monorepo mode).
28
+ */
29
+ export declare function getCommitsSince(tag: string | undefined, relativeDir?: string): RawCommit[];
30
+ /**
31
+ * Create the release tag on HEAD and push it to origin.
32
+ * Uses a token-authenticated url when GITHUB_TOKEN is available,
33
+ * since CI checkouts don't persist git credentials.
34
+ */
35
+ export declare function createAndPushTag(tag: string, repo: RepoInfo): void;
@@ -0,0 +1,110 @@
1
+ import { _assert } from '@naturalcycles/js-lib/error/assert.js';
2
+ import { dimGrey } from '@naturalcycles/nodejs-lib/colors';
3
+ import { exec2 } from '@naturalcycles/nodejs-lib/exec2';
4
+ /**
5
+ * Parse the `origin` remote into owner/repo.
6
+ * Supports both `git@github.com:Owner/repo.git` and `https://github.com/Owner/repo(.git)` remotes.
7
+ */
8
+ export function getRepoInfo() {
9
+ const url = exec2.exec('git remote get-url origin').trim();
10
+ const match = url.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/);
11
+ _assert(match, `Cannot parse GitHub repo from origin url: ${url}`);
12
+ const [, owner, repo] = match;
13
+ return { owner: owner, repo: repo, url: `https://github.com/${owner}/${repo}` };
14
+ }
15
+ export function getCurrentBranch() {
16
+ const branch = exec2.exec('git rev-parse --abbrev-ref HEAD').trim();
17
+ if (branch !== 'HEAD')
18
+ return branch;
19
+ // Detached HEAD (some CI checkouts) - fall back to the GitHub Actions ref
20
+ const { GITHUB_REF_NAME } = process.env;
21
+ _assert(GITHUB_REF_NAME, 'Cannot determine current git branch (detached HEAD)');
22
+ return GITHUB_REF_NAME;
23
+ }
24
+ /**
25
+ * Make sure the full git history and all tags are available:
26
+ * CI checkouts are often shallow and without tags, while release calculation is based on tags.
27
+ * Tolerates fetch failures (e.g. offline local dry-run) with a warning.
28
+ */
29
+ export function ensureFullGitHistory() {
30
+ try {
31
+ const shallow = exec2.exec('git rev-parse --is-shallow-repository').trim() === 'true';
32
+ if (shallow) {
33
+ exec2.exec('git fetch --unshallow --tags --quiet', { log: true });
34
+ }
35
+ else {
36
+ exec2.exec('git fetch --tags --quiet', { log: true });
37
+ }
38
+ }
39
+ catch (err) {
40
+ console.log(dimGrey(`git fetch failed, continuing with local tags/history: ${err}`));
41
+ }
42
+ }
43
+ /**
44
+ * Abort if someone pushed to the branch since this checkout - releasing from a stale HEAD
45
+ * would compute wrong versions/notes. No-op if the branch has no remote counterpart.
46
+ */
47
+ export function checkHeadIsUpToDateWithRemote(branch) {
48
+ let remoteSha;
49
+ try {
50
+ remoteSha = exec2.exec(`git ls-remote origin "refs/heads/${branch}"`).split('\t')[0]?.trim();
51
+ }
52
+ catch (err) {
53
+ console.log(dimGrey(`git ls-remote failed, skipping up-to-date check: ${err}`));
54
+ return;
55
+ }
56
+ if (!remoteSha)
57
+ return;
58
+ const headSha = exec2.exec('git rev-parse HEAD').trim();
59
+ _assert(remoteSha === headSha, `Local HEAD (${headSha.slice(0, 7)}) is not up to date with remote ${branch} (${remoteSha.slice(0, 7)}) - aborting release`);
60
+ }
61
+ /**
62
+ * All git tags reachable from HEAD.
63
+ * Reachability matters: on a prerelease branch this naturally excludes tags of other
64
+ * prerelease branches, matching semantic-release (which uses `git tag --merged`).
65
+ */
66
+ export function getReachableTags() {
67
+ return exec2
68
+ .exec('git tag --merged HEAD')
69
+ .split('\n')
70
+ .map(t => t.trim())
71
+ .filter(Boolean);
72
+ }
73
+ /**
74
+ * Commits since the given tag (or all history when no tag), newest first,
75
+ * optionally limited to the given directory (monorepo mode).
76
+ */
77
+ export function getCommitsSince(tag, relativeDir) {
78
+ // %x1f (unit separator) between fields, %x1e (record separator) between commits
79
+ const range = tag ? `"${tag}..HEAD"` : 'HEAD';
80
+ const pathFilter = relativeDir ? ` -- "${relativeDir}"` : '';
81
+ const out = exec2.exec(`git log --format="%H%x1f%s%x1f%b%x1e" ${range}${pathFilter}`);
82
+ return out
83
+ .split('\u001E')
84
+ .map(record => record.trim())
85
+ .filter(Boolean)
86
+ .map(record => {
87
+ const [hash = '', subject = '', body = ''] = record.split('\u001F');
88
+ return { hash, subject, body: body.trim() };
89
+ })
90
+ .filter(c => c.hash);
91
+ }
92
+ /**
93
+ * Create the release tag on HEAD and push it to origin.
94
+ * Uses a token-authenticated url when GITHUB_TOKEN is available,
95
+ * since CI checkouts don't persist git credentials.
96
+ */
97
+ export function createAndPushTag(tag, repo) {
98
+ exec2.exec(`git tag "${tag}"`);
99
+ const token = process.env['GITHUB_TOKEN'] || process.env['GH_TOKEN'];
100
+ const pushUrl = token
101
+ ? `https://x-access-token:${token}@github.com/${repo.owner}/${repo.repo}.git`
102
+ : 'origin';
103
+ try {
104
+ exec2.exec(`git push "${pushUrl}" "refs/tags/${tag}"`);
105
+ }
106
+ catch {
107
+ // Sanitized rethrow: the original error message contains the failed command incl. the token
108
+ throw new Error(`Failed to push tag ${tag} to origin`);
109
+ }
110
+ }
@@ -0,0 +1,22 @@
1
+ import type { ParsedCommit, RepoInfo } from './release.model.js';
2
+ export declare class GithubApi {
3
+ private repo;
4
+ constructor(repo: RepoInfo, token: string);
5
+ private fetcher;
6
+ /**
7
+ * Create a GitHub Release for an existing tag. Returns the release html url.
8
+ */
9
+ createRelease(input: {
10
+ tag: string;
11
+ notes: string;
12
+ prerelease: boolean;
13
+ }): Promise<string>;
14
+ /**
15
+ * Comment on the merged PRs whose commits are part of this release.
16
+ * Failures are logged, not thrown - comments are not worth failing a published release over.
17
+ */
18
+ commentOnReleasedPrs(commits: ParsedCommit[], input: {
19
+ tag: string;
20
+ releaseUrl: string;
21
+ }): Promise<void>;
22
+ }
@@ -0,0 +1,65 @@
1
+ import { getFetcher } from '@naturalcycles/js-lib/http';
2
+ import { dimGrey } from '@naturalcycles/nodejs-lib/colors';
3
+ export class GithubApi {
4
+ repo;
5
+ constructor(repo, token) {
6
+ this.repo = repo;
7
+ this.fetcher = getFetcher({
8
+ baseUrl: 'https://api.github.com',
9
+ headers: {
10
+ authorization: `Bearer ${token}`,
11
+ accept: 'application/vnd.github+json',
12
+ 'user-agent': 'naturalcycles-dev-lib',
13
+ },
14
+ });
15
+ }
16
+ fetcher;
17
+ /**
18
+ * Create a GitHub Release for an existing tag. Returns the release html url.
19
+ */
20
+ async createRelease(input) {
21
+ const { repo } = this;
22
+ const { html_url: htmlUrl } = await this.fetcher.post(`/repos/${repo.owner}/${repo.repo}/releases`, {
23
+ json: {
24
+ tag_name: input.tag,
25
+ name: input.tag,
26
+ body: input.notes,
27
+ prerelease: input.prerelease,
28
+ },
29
+ });
30
+ return htmlUrl;
31
+ }
32
+ /**
33
+ * Comment on the merged PRs whose commits are part of this release.
34
+ * Failures are logged, not thrown - comments are not worth failing a published release over.
35
+ */
36
+ async commentOnReleasedPrs(commits, input) {
37
+ const { repo } = this;
38
+ const prNumbers = new Set();
39
+ for (const commit of commits) {
40
+ try {
41
+ const prs = await this.fetcher.get(`/repos/${repo.owner}/${repo.repo}/commits/${commit.hash}/pulls`);
42
+ for (const pr of prs) {
43
+ if (pr.merged_at)
44
+ prNumbers.add(pr.number);
45
+ }
46
+ }
47
+ catch (err) {
48
+ console.log(dimGrey(`Failed to look up PRs for commit ${commit.hash.slice(0, 7)}: ${err}`));
49
+ }
50
+ }
51
+ for (const prNumber of prNumbers) {
52
+ try {
53
+ await this.fetcher.post(`/repos/${repo.owner}/${repo.repo}/issues/${prNumber}/comments`, {
54
+ json: {
55
+ body: `:tada: This PR is included in [${input.tag}](${input.releaseUrl}) :tada:`,
56
+ },
57
+ });
58
+ console.log(dimGrey(`Commented on PR #${prNumber}`));
59
+ }
60
+ catch (err) {
61
+ console.log(dimGrey(`Failed to comment on PR #${prNumber}: ${err}`));
62
+ }
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,16 @@
1
+ import type { ParsedCommit, RepoInfo } from './release.model.js';
2
+ /**
3
+ * Generate release notes markdown for a GitHub Release,
4
+ * following the format of `@semantic-release/release-notes-generator` (angular preset):
5
+ * sections per commit type, entries linking to commits, compare link in the header.
6
+ */
7
+ export declare function generateReleaseNotes(input: {
8
+ repo: RepoInfo;
9
+ commits: ParsedCommit[];
10
+ lastTag?: string;
11
+ nextTag: string;
12
+ /**
13
+ * `YYYY-MM-DD`, defaults to today. Injectable for tests.
14
+ */
15
+ date?: string;
16
+ }): string;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Generate release notes markdown for a GitHub Release,
3
+ * following the format of `@semantic-release/release-notes-generator` (angular preset):
4
+ * sections per commit type, entries linking to commits, compare link in the header.
5
+ */
6
+ export function generateReleaseNotes(input) {
7
+ const { repo, commits, lastTag, nextTag } = input;
8
+ const date = input.date || new Date().toISOString().slice(0, 10);
9
+ const header = lastTag
10
+ ? `## [${nextTag}](${repo.url}/compare/${lastTag}...${nextTag}) (${date})`
11
+ : `## ${nextTag} (${date})`;
12
+ const sections = [header];
13
+ addSection(sections, '### Features', commits.filter(c => c.type === 'feat'), repo);
14
+ addSection(sections, '### Bug Fixes', commits.filter(c => c.type === 'fix'), repo);
15
+ addSection(sections, '### Performance Improvements', commits.filter(c => c.type === 'perf'), repo);
16
+ addSection(sections, '### Reverts', commits.filter(c => c.revert && c.type !== 'feat' && c.type !== 'fix' && c.type !== 'perf'), repo);
17
+ const breaking = commits.filter(c => c.breaking);
18
+ if (breaking.length) {
19
+ sections.push('### ⚠ BREAKING CHANGES', breaking.map(c => `* ${scopePrefix(c)}${c.breakingNote}`).join('\n'));
20
+ }
21
+ return sections.join('\n\n') + '\n';
22
+ }
23
+ function addSection(sections, title, commits, repo) {
24
+ if (!commits.length)
25
+ return;
26
+ sections.push(title, commits.map(c => formatCommitEntry(c, repo)).join('\n'));
27
+ }
28
+ function formatCommitEntry(c, repo) {
29
+ const shortHash = c.hash.slice(0, 7);
30
+ return `* ${scopePrefix(c)}${c.description} ([${shortHash}](${repo.url}/commit/${c.hash}))`;
31
+ }
32
+ function scopePrefix(c) {
33
+ return c.scope ? `**${c.scope}:** ` : '';
34
+ }
@@ -0,0 +1,17 @@
1
+ import type { ReleaseChannel } from './release.model.js';
2
+ /**
3
+ * Release workspace packages (or the root package in single-repo mode):
4
+ * analyze conventional commits since the last release tag, compute the next semver,
5
+ * publish to npm via `pnpm publish`, push the release tag and create a GitHub Release.
6
+ *
7
+ * A lean replacement for semantic-release. Git tags are the source of truth for versions -
8
+ * the package.json version bump happens in the working tree only and is never committed.
9
+ *
10
+ * Must be run from the repo root.
11
+ */
12
+ export declare function releaseCommand(): Promise<void>;
13
+ /**
14
+ * Resolve the release channel from the current branch, or undefined if the branch
15
+ * is not configured for release.
16
+ */
17
+ export declare function resolveChannel(branch: string, stableBranches: string[], prereleaseBranches: string[]): ReleaseChannel | undefined;
@@ -0,0 +1,159 @@
1
+ import { _assert } from '@naturalcycles/js-lib/error/assert.js';
2
+ import { _parseArgs } from '@naturalcycles/nodejs-lib/args';
3
+ import { dimGrey, white } from '@naturalcycles/nodejs-lib/colors';
4
+ import { appendToGithubOutput } from '@naturalcycles/nodejs-lib/env';
5
+ import { exec2 } from '@naturalcycles/nodejs-lib/exec2';
6
+ import { fs2 } from '@naturalcycles/nodejs-lib/fs2';
7
+ import picomatch from 'picomatch';
8
+ import { readDevLibConfigIfPresent } from '../config.js';
9
+ import { analyzeCommits, parseCommit } from './commit.util.js';
10
+ import { checkHeadIsUpToDateWithRemote, createAndPushTag, ensureFullGitHistory, getCommitsSince, getCurrentBranch, getReachableTags, getRepoInfo, } from './git.util.js';
11
+ import { GithubApi } from './github.util.js';
12
+ import { generateReleaseNotes } from './notes.util.js';
13
+ import { getLastRelease, getNextVersion, getVersionsFromTags } from './version.util.js';
14
+ import { discoverReleasePackages, resolveReleaseMode } from './workspace.util.js';
15
+ /**
16
+ * Release workspace packages (or the root package in single-repo mode):
17
+ * analyze conventional commits since the last release tag, compute the next semver,
18
+ * publish to npm via `pnpm publish`, push the release tag and create a GitHub Release.
19
+ *
20
+ * A lean replacement for semantic-release. Git tags are the source of truth for versions -
21
+ * the package.json version bump happens in the working tree only and is never committed.
22
+ *
23
+ * Must be run from the repo root.
24
+ */
25
+ export async function releaseCommand() {
26
+ const argv = _parseArgs({
27
+ 'dry-run': {
28
+ type: 'boolean',
29
+ default: false,
30
+ desc: 'Compute and print the release plan without publishing anything',
31
+ },
32
+ filter: {
33
+ type: 'string',
34
+ desc: 'Only release the given package (name, name without scope, or directory)',
35
+ },
36
+ });
37
+ const dryRun = argv['dry-run'];
38
+ const { release: cfg = {} } = await readDevLibConfigIfPresent();
39
+ const stableBranches = cfg.branches || ['main', 'master'];
40
+ const prereleaseBranches = cfg.prereleaseBranches || ['beta-*'];
41
+ const successComments = cfg.successComments !== false;
42
+ const repo = getRepoInfo();
43
+ const branch = getCurrentBranch();
44
+ const channel = resolveChannel(branch, stableBranches, prereleaseBranches);
45
+ if (!channel) {
46
+ console.log(`Branch "${branch}" is not configured for release - nothing to do`);
47
+ return;
48
+ }
49
+ const token = process.env['GITHUB_TOKEN'] || process.env['GH_TOKEN'];
50
+ if (!dryRun) {
51
+ _assert(token, 'GITHUB_TOKEN (or GH_TOKEN) is required to release, or use --dry-run');
52
+ }
53
+ ensureFullGitHistory();
54
+ if (!dryRun) {
55
+ checkHeadIsUpToDateWithRemote(branch);
56
+ }
57
+ const mode = resolveReleaseMode(cfg.mode);
58
+ const packages = discoverReleasePackages(mode, argv.filter);
59
+ const tags = getReachableTags();
60
+ console.log(`Releasing from branch ${white(branch)} (dist-tag ${white(channel.distTag)}), ${mode} mode, ${packages.length} package(s)${dryRun ? dimGrey(' [dry-run]') : ''}`);
61
+ const released = [];
62
+ for (const pkg of packages) {
63
+ const plan = planPackageRelease(pkg, tags, channel, repo);
64
+ if (!plan) {
65
+ console.log(dimGrey(`${pkg.name}: no release-worthy commits`));
66
+ continue;
67
+ }
68
+ console.log(white(`${pkg.name}: ${plan.lastRelease?.version || '(first release)'} -> ${plan.nextVersion} (${plan.releaseType}, ${plan.commits.length} commits)`));
69
+ if (dryRun) {
70
+ console.log(dimGrey(plan.notes));
71
+ }
72
+ else {
73
+ await executeRelease(plan, repo, { token: token, successComments });
74
+ }
75
+ released.push(plan);
76
+ }
77
+ console.log(released.length
78
+ ? `${dryRun ? 'Would release' : 'Released'}: ${released.map(p => `${p.pkg.name}@${p.nextVersion}`).join(', ')}`
79
+ : 'Nothing to release');
80
+ }
81
+ /**
82
+ * Resolve the release channel from the current branch, or undefined if the branch
83
+ * is not configured for release.
84
+ */
85
+ export function resolveChannel(branch, stableBranches, prereleaseBranches) {
86
+ if (stableBranches.includes(branch)) {
87
+ return { distTag: 'latest' };
88
+ }
89
+ if (prereleaseBranches.some(glob => picomatch.isMatch(branch, glob))) {
90
+ _assert(/^[0-9A-Za-z-]+$/.test(branch), `Branch name "${branch}" cannot be used as a prerelease identifier - only letters, numbers and dashes are allowed`);
91
+ return { distTag: branch, prereleaseId: branch };
92
+ }
93
+ return undefined;
94
+ }
95
+ function planPackageRelease(pkg, tags, channel, repo) {
96
+ const versions = getVersionsFromTags(tags, pkg.tagPrefix);
97
+ const lastRelease = getLastRelease(versions, pkg.tagPrefix, channel);
98
+ const commits = getCommitsSince(lastRelease?.tag, pkg.relativeDir || undefined).map(parseCommit);
99
+ const releaseType = analyzeCommits(commits);
100
+ if (!releaseType)
101
+ return null;
102
+ const pkgJson = fs2.readJson(`${pkg.dir}/package.json`);
103
+ const nextVersion = getNextVersion({
104
+ lastRelease,
105
+ allVersions: versions,
106
+ releaseType,
107
+ channel,
108
+ firstReleaseVersion: pkgJson.version,
109
+ });
110
+ const nextTag = `${pkg.tagPrefix}${nextVersion}`;
111
+ const notes = generateReleaseNotes({ repo, commits, lastTag: lastRelease?.tag, nextTag });
112
+ return { pkg, lastRelease, releaseType, nextVersion, nextTag, channel, commits, notes };
113
+ }
114
+ async function executeRelease(plan, repo, opt) {
115
+ const { pkg, nextVersion, nextTag, channel, notes, commits } = plan;
116
+ bumpPackageJsonVersion(pkg.dir, nextVersion);
117
+ // Publish before tagging: a failed publish must not leave a tag behind,
118
+ // as the tag would make the next run consider this version already released.
119
+ // pnpm rewrites workspace:* deps and handles npm OIDC trusted publishing + provenance.
120
+ exec2.spawn(`pnpm publish --tag ${channel.distTag} --no-git-checks`, { cwd: pkg.dir });
121
+ createAndPushTag(nextTag, repo);
122
+ const github = new GithubApi(repo, opt.token);
123
+ let releaseUrl;
124
+ try {
125
+ releaseUrl = await github.createRelease({
126
+ tag: nextTag,
127
+ notes,
128
+ prerelease: !!channel.prereleaseId,
129
+ });
130
+ }
131
+ catch (err) {
132
+ throw new Error(`${pkg.name}@${nextVersion} was published and tagged, but GitHub Release creation failed - create it manually for tag ${nextTag}. Cause: ${err}`, { cause: err });
133
+ }
134
+ console.log(`GitHub Release created: ${releaseUrl}`);
135
+ if (opt.successComments) {
136
+ await github.commentOnReleasedPrs(commits, { tag: nextTag, releaseUrl });
137
+ }
138
+ writeGithubOutputs(pkg.name, nextVersion);
139
+ }
140
+ function bumpPackageJsonVersion(dir, version) {
141
+ const packageJsonPath = `${dir}/package.json`;
142
+ const pkgJson = fs2.readJson(packageJsonPath);
143
+ pkgJson['version'] = version;
144
+ fs2.writeJson(packageJsonPath, pkgJson, { spaces: 2 });
145
+ }
146
+ /**
147
+ * Expose the release result to subsequent GitHub Actions steps,
148
+ * e.g. `shared_released=true`, `shared_version=7.660.0`.
149
+ */
150
+ function writeGithubOutputs(pkgName, version) {
151
+ const key = pkgName
152
+ .split('/')
153
+ .pop()
154
+ .replaceAll(/[^a-zA-Z0-9_]/g, '_');
155
+ appendToGithubOutput({
156
+ [`${key}_released`]: 'true',
157
+ [`${key}_version`]: version,
158
+ });
159
+ }
@@ -0,0 +1,81 @@
1
+ export type ReleaseType = 'major' | 'minor' | 'patch';
2
+ /**
3
+ * Release channel, resolved from the current git branch and the release config.
4
+ */
5
+ export interface ReleaseChannel {
6
+ /**
7
+ * npm dist-tag to publish under: `latest` on stable branches, the branch name on prerelease branches.
8
+ */
9
+ distTag: string;
10
+ /**
11
+ * Prerelease identifier (the branch name, e.g. `beta-my-feature`).
12
+ * Undefined on stable branches.
13
+ */
14
+ prereleaseId?: string;
15
+ }
16
+ export interface ReleasePackage {
17
+ name: string;
18
+ /**
19
+ * Absolute path to the package directory.
20
+ */
21
+ dir: string;
22
+ /**
23
+ * Package directory relative to the repo root, e.g. `packages/js-lib`.
24
+ * Empty string in single-repo mode (the root package) - means "no path filtering".
25
+ */
26
+ relativeDir: string;
27
+ /**
28
+ * Git tag prefix: `<name>-v` in monorepo mode, `v` in single-repo mode.
29
+ * Full tag is `<tagPrefix><version>`, e.g. `@naturalcycles/js-lib-v15.80.0` or `v7.660.0`.
30
+ */
31
+ tagPrefix: string;
32
+ }
33
+ export interface RepoInfo {
34
+ owner: string;
35
+ repo: string;
36
+ /**
37
+ * e.g. `https://github.com/NaturalCycles/js-libs`
38
+ */
39
+ url: string;
40
+ }
41
+ export interface RawCommit {
42
+ hash: string;
43
+ subject: string;
44
+ body: string;
45
+ }
46
+ export interface ParsedCommit {
47
+ hash: string;
48
+ subject: string;
49
+ body: string;
50
+ /**
51
+ * Conventional commit type, e.g. `feat`, `fix`.
52
+ * Undefined if the subject is not a valid conventional commit.
53
+ */
54
+ type?: string;
55
+ scope?: string;
56
+ /**
57
+ * Subject without the `type(scope):` prefix.
58
+ * Falls back to the full subject for non-conventional commits.
59
+ */
60
+ description: string;
61
+ breaking: boolean;
62
+ /**
63
+ * Text of the `BREAKING CHANGE:` footer, or the description when marked with `!` only.
64
+ */
65
+ breakingNote?: string;
66
+ revert: boolean;
67
+ }
68
+ export interface LastRelease {
69
+ version: string;
70
+ tag: string;
71
+ }
72
+ export interface ReleasePlan {
73
+ pkg: ReleasePackage;
74
+ lastRelease?: LastRelease;
75
+ releaseType: ReleaseType;
76
+ nextVersion: string;
77
+ nextTag: string;
78
+ channel: ReleaseChannel;
79
+ commits: ParsedCommit[];
80
+ notes: string;
81
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ import type { LastRelease, ReleaseChannel, ReleaseType } from './release.model.js';
2
+ /**
3
+ * Extract versions from git tags matching the package's tag prefix.
4
+ * Non-semver tags (and tags of other packages) are ignored.
5
+ */
6
+ export declare function getVersionsFromTags(tags: string[], tagPrefix: string): string[];
7
+ /**
8
+ * Determine the last release relevant to the given channel,
9
+ * replicating semantic-release `getLastRelease` semantics:
10
+ *
11
+ * - stable channel: the highest stable version
12
+ * - prerelease channel: the highest among stable versions and prereleases of this channel
13
+ * (a prerelease belongs to the channel when its prerelease components include the identifier,
14
+ * e.g. `7.660.0-beta-x.4` belongs to channel `beta-x`)
15
+ */
16
+ export declare function getLastRelease(versions: string[], tagPrefix: string, channel: ReleaseChannel): LastRelease | undefined;
17
+ /**
18
+ * Compute the next version, replicating semantic-release `getNextVersion` semantics.
19
+ *
20
+ * On a prerelease channel, when the last release is already a prerelease of this channel,
21
+ * the next version is the highest of:
22
+ * - the prerelease increment (`7.660.0-beta-x.4` => `7.660.0-beta-x.5`)
23
+ * - the increment of the latest overall version (incl. prereleases) with a reset counter
24
+ * (jumps the base when a bigger change type lands mid-prerelease, e.g. to `8.0.0-beta-x.1`)
25
+ */
26
+ export declare function getNextVersion(input: {
27
+ lastRelease?: LastRelease;
28
+ /**
29
+ * All versions of the package reachable from HEAD (any channel, any stability).
30
+ */
31
+ allVersions: string[];
32
+ releaseType: ReleaseType;
33
+ channel: ReleaseChannel;
34
+ /**
35
+ * Used when the package has no release tags yet. Defaults to `1.0.0`.
36
+ */
37
+ firstReleaseVersion?: string;
38
+ }): string;
@@ -0,0 +1,72 @@
1
+ import semver from 'semver';
2
+ /**
3
+ * Extract versions from git tags matching the package's tag prefix.
4
+ * Non-semver tags (and tags of other packages) are ignored.
5
+ */
6
+ export function getVersionsFromTags(tags, tagPrefix) {
7
+ const versions = [];
8
+ for (const tag of tags) {
9
+ if (!tag.startsWith(tagPrefix))
10
+ continue;
11
+ const version = tag.slice(tagPrefix.length);
12
+ if (semver.valid(version))
13
+ versions.push(version);
14
+ }
15
+ return versions;
16
+ }
17
+ /**
18
+ * Determine the last release relevant to the given channel,
19
+ * replicating semantic-release `getLastRelease` semantics:
20
+ *
21
+ * - stable channel: the highest stable version
22
+ * - prerelease channel: the highest among stable versions and prereleases of this channel
23
+ * (a prerelease belongs to the channel when its prerelease components include the identifier,
24
+ * e.g. `7.660.0-beta-x.4` belongs to channel `beta-x`)
25
+ */
26
+ export function getLastRelease(versions, tagPrefix, channel) {
27
+ const candidates = versions.filter(v => {
28
+ const prerelease = semver.prerelease(v);
29
+ if (!prerelease)
30
+ return true; // stable versions are always candidates
31
+ return !!channel.prereleaseId && prerelease.includes(channel.prereleaseId);
32
+ });
33
+ const [version] = candidates.sort(semver.rcompare);
34
+ if (!version)
35
+ return;
36
+ return { version, tag: `${tagPrefix}${version}` };
37
+ }
38
+ /**
39
+ * Compute the next version, replicating semantic-release `getNextVersion` semantics.
40
+ *
41
+ * On a prerelease channel, when the last release is already a prerelease of this channel,
42
+ * the next version is the highest of:
43
+ * - the prerelease increment (`7.660.0-beta-x.4` => `7.660.0-beta-x.5`)
44
+ * - the increment of the latest overall version (incl. prereleases) with a reset counter
45
+ * (jumps the base when a bigger change type lands mid-prerelease, e.g. to `8.0.0-beta-x.1`)
46
+ */
47
+ export function getNextVersion(input) {
48
+ const { lastRelease, allVersions, releaseType, channel } = input;
49
+ const { prereleaseId } = channel;
50
+ if (!lastRelease) {
51
+ const { firstReleaseVersion } = input;
52
+ const first = (firstReleaseVersion &&
53
+ !semver.prerelease(firstReleaseVersion) &&
54
+ semver.valid(firstReleaseVersion)) ||
55
+ '1.0.0';
56
+ return prereleaseId ? `${first}-${prereleaseId}.1` : first;
57
+ }
58
+ const lastVersion = lastRelease.version;
59
+ if (!prereleaseId) {
60
+ return semver.inc(lastVersion, releaseType);
61
+ }
62
+ if (semver.prerelease(lastVersion)) {
63
+ // The last release is a prerelease of this channel (guaranteed by getLastRelease)
64
+ const latestOverall = allVersions.toSorted(semver.rcompare)[0];
65
+ return highest(semver.inc(lastVersion, 'prerelease'), `${semver.inc(latestOverall, releaseType)}-${prereleaseId}.1`);
66
+ }
67
+ // The last release is stable - start a new prerelease sequence off it
68
+ return `${semver.inc(lastVersion, releaseType)}-${prereleaseId}.1`;
69
+ }
70
+ function highest(v1, v2) {
71
+ return semver.gt(v1, v2) ? v1 : v2;
72
+ }
@@ -0,0 +1,13 @@
1
+ import type { ReleasePackage } from './release.model.js';
2
+ export type ReleaseMode = 'monorepo' | 'single';
3
+ /**
4
+ * Detect the release mode: 'monorepo' when a pnpm workspace is present, 'single' otherwise.
5
+ */
6
+ export declare function resolveReleaseMode(configured?: ReleaseMode): ReleaseMode;
7
+ /**
8
+ * Discover the packages to release. Must be run from the repo root.
9
+ *
10
+ * - monorepo: every non-private workspace package, tagged as `<name>-v<version>`
11
+ * - single: the root package, tagged as `v<version>`
12
+ */
13
+ export declare function discoverReleasePackages(mode: ReleaseMode, filter?: string): ReleasePackage[];
@@ -0,0 +1,49 @@
1
+ import path from 'node:path';
2
+ import { _assert } from '@naturalcycles/js-lib/error/assert.js';
3
+ import { exec2 } from '@naturalcycles/nodejs-lib/exec2';
4
+ import { fs2 } from '@naturalcycles/nodejs-lib/fs2';
5
+ /**
6
+ * Detect the release mode: 'monorepo' when a pnpm workspace is present, 'single' otherwise.
7
+ */
8
+ export function resolveReleaseMode(configured) {
9
+ return configured || (fs2.pathExists('pnpm-workspace.yaml') ? 'monorepo' : 'single');
10
+ }
11
+ /**
12
+ * Discover the packages to release. Must be run from the repo root.
13
+ *
14
+ * - monorepo: every non-private workspace package, tagged as `<name>-v<version>`
15
+ * - single: the root package, tagged as `v<version>`
16
+ */
17
+ export function discoverReleasePackages(mode, filter) {
18
+ const packages = mode === 'monorepo' ? discoverWorkspacePackages() : [getRootPackage()];
19
+ if (!filter)
20
+ return packages;
21
+ const filtered = packages.filter(p => p.name === filter || p.name.split('/').pop() === filter || p.relativeDir === filter);
22
+ _assert(filtered.length, `--filter "${filter}" matched none of the release packages: ${packages.map(p => p.name).join(', ')}`);
23
+ return filtered;
24
+ }
25
+ function discoverWorkspacePackages() {
26
+ const root = process.cwd();
27
+ const out = exec2.exec('pnpm -r ls --depth -1 --json');
28
+ const list = JSON.parse(out);
29
+ return list
30
+ .filter(p => p.name && !p.private && p.path !== root)
31
+ .map(p => ({
32
+ name: p.name,
33
+ dir: p.path,
34
+ relativeDir: path.relative(root, p.path),
35
+ tagPrefix: `${p.name}-v`,
36
+ }));
37
+ }
38
+ function getRootPackage() {
39
+ const dir = process.cwd();
40
+ const pkg = fs2.readJson(`${dir}/package.json`);
41
+ _assert(pkg.name, 'package.json has no "name" - cannot release');
42
+ _assert(!pkg.private, `${pkg.name} is private - cannot release`);
43
+ return {
44
+ name: pkg.name,
45
+ dir,
46
+ relativeDir: '',
47
+ tagPrefix: 'v',
48
+ };
49
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/dev-lib",
3
3
  "type": "module",
4
- "version": "20.51.3",
4
+ "version": "20.52.0",
5
5
  "dependencies": {
6
6
  "@naturalcycles/js-lib": "^15",
7
7
  "@naturalcycles/nodejs-lib": "^15",
@@ -14,6 +14,7 @@
14
14
  "oxlint-tsgolint": "0.23.0",
15
15
  "picomatch": "^4",
16
16
  "prompts": "^2",
17
+ "semver": "^7",
17
18
  "std-env": "^4"
18
19
  },
19
20
  "peerDependencies": {
@@ -47,7 +48,9 @@
47
48
  },
48
49
  "devDependencies": {
49
50
  "@types/node": "^26",
51
+ "@types/picomatch": "^4",
50
52
  "@types/prompts": "^2",
53
+ "@types/semver": "^7",
51
54
  "typescript": "rc"
52
55
  },
53
56
  "exports": {