rman 1.0.7 → 1.0.9

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.
@@ -0,0 +1,265 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import fastGlob from 'fast-glob';
4
+ import semver from 'semver';
5
+ import { expandTag, tagPattern } from '../utils/change-hash.js';
6
+ import { GitHelper } from '../utils/git.js';
7
+ import { expandReleaseTag, isCalendarVersion, releaseTagPattern } from '../utils/release-version.js';
8
+ import { ChangelogService } from './changelog.service.js';
9
+ export var GithubReleaseService;
10
+ (function (GithubReleaseService) {
11
+ /**
12
+ * Computes what `publish --target github` *would* do - **one** release per run, or none.
13
+ *
14
+ * A GitHub Release is a property of the repository, not of a package: the tag covers the whole
15
+ * source tree, so everything that shipped under it belongs in it. That makes the `"github"`
16
+ * target a repository-level opt-in (typically in the root `.rmanrc`, alongside `"npm"`); it's
17
+ * honored as soon as *any* package resolves it, since a per-package release would have to invent
18
+ * a tag no package owns.
19
+ *
20
+ * The release is identified by the repository's own version (the root's - see
21
+ * `VersionService`'s `buildRootEntry`): its release tag when that version is a calendar one, and
22
+ * otherwise the tag of the single shared version, which is the group's own tag - so a repo with
23
+ * one version line gets no second name for the release it already has. Whether a release exists
24
+ * for that tag decides `'up-to-date'` vs `'publish'`.
25
+ *
26
+ * Uncommitted changes anywhere make it `'error'` unless `options.ignoreDirty` downgrades it to
27
+ * `'skip'`. An unresolvable `owner/repo`, or a lookup that fails for any reason other than "no
28
+ * such release", is `'error'` too - a blocking misconfiguration rather than a silent "not
29
+ * released yet" that only fails later.
30
+ */
31
+ async function getPlan(repository, options = {}, deps = {}) {
32
+ const root = repository.rootPackage;
33
+ const wanted = [root, ...repository.getPackages()].some(pkg => targetsGithub(pkg) && !pkg.config.publish?.skip);
34
+ if (!wanted)
35
+ return [];
36
+ const git = new GitHelper({ cwd: repository.dirname });
37
+ const base = { package: root, version: root.version };
38
+ const repo = options.repository ?? root.config?.publish?.github?.repository ?? repoFromRemoteUrl(await git.remoteUrl());
39
+ if (!repo) {
40
+ return [
41
+ {
42
+ ...base,
43
+ status: 'error',
44
+ reason: 'cannot resolve "owner/repo" - set "publish.github.repository" or an "origin" remote',
45
+ },
46
+ ];
47
+ }
48
+ const tag = releaseTagFor(root);
49
+ if ((await git.listDirtyFiles()).length) {
50
+ return [
51
+ {
52
+ ...base,
53
+ tag,
54
+ repository: repo,
55
+ status: options.ignoreDirty ? 'skip' : 'error',
56
+ reason: 'uncommitted local changes',
57
+ },
58
+ ];
59
+ }
60
+ // The tag is `version`'s to create, so a missing one means either it never ran or this clone
61
+ // simply doesn't have the tags. Both are refused rather than released: the notes are bounded by
62
+ // the *previous* release tag, which can't be found without it either, so the release would
63
+ // silently come out covering the entire history instead of what actually shipped.
64
+ if (!(await git.tagExists(tag))) {
65
+ return [
66
+ {
67
+ ...base,
68
+ tag,
69
+ repository: repo,
70
+ status: 'error',
71
+ reason: `release tag "${tag}" does not exist here - run "version" first, or fetch tags into this clone`,
72
+ },
73
+ ];
74
+ }
75
+ const releaseExists = deps.releaseExists ?? defaultReleaseExists;
76
+ try {
77
+ const exists = await releaseExists(repo, tag);
78
+ return [
79
+ {
80
+ ...base,
81
+ tag,
82
+ repository: repo,
83
+ status: exists ? 'up-to-date' : 'publish',
84
+ reason: exists ? `${repo} already has a release for ${tag}` : 'never released',
85
+ },
86
+ ];
87
+ }
88
+ catch (e) {
89
+ return [{ ...base, tag, repository: repo, status: 'error', reason: e.message }];
90
+ }
91
+ }
92
+ GithubReleaseService.getPlan = getPlan;
93
+ /**
94
+ * Creates the repository's GitHub Release, then uploads whatever `publish.github.assets` globs
95
+ * match. The body covers **every** package that shipped under this release - not just the ones
96
+ * naming `"github"` as a target - since the tag covers all of their code either way.
97
+ *
98
+ * Each package's notes are bounded by the *previous repository release*, and headed with that
99
+ * package's own version, so a repo whose packages sit on different version lines still reads
100
+ * correctly. A package with nothing in that range simply contributes no section, which is also
101
+ * how a package that didn't ship in this release is left out - no ancestry arithmetic needed.
102
+ *
103
+ * An existing release for the tag is updated rather than treated as a failure, so a re-run after
104
+ * a partial failure converges.
105
+ */
106
+ async function applyPlan(repository, plan) {
107
+ const entry = plan.find(e => e.status === 'publish');
108
+ if (!entry)
109
+ return plan;
110
+ const git = new GitHelper({ cwd: repository.dirname });
111
+ try {
112
+ const body = await buildReleaseNotes(repository, git, entry.tag);
113
+ const release = await createOrUpdateRelease(entry.repository, entry.tag, {
114
+ name: entry.tag,
115
+ body,
116
+ draft: !!repository.rootPackage.config?.publish?.github?.draft,
117
+ prerelease: resolvePrerelease(repository.rootPackage, entry.version),
118
+ });
119
+ await uploadAssets(repository, entry.repository, release.id);
120
+ return plan;
121
+ }
122
+ catch (e) {
123
+ return plan.map(e2 => (e2 === entry ? { ...e2, status: 'error', reason: e.message } : e2));
124
+ }
125
+ }
126
+ GithubReleaseService.applyPlan = applyPlan;
127
+ })(GithubReleaseService || (GithubReleaseService = {}));
128
+ const GITHUB_API = 'https://api.github.com';
129
+ const GITHUB_UPLOADS = 'https://uploads.github.com';
130
+ function targetsGithub(pkg) {
131
+ const target = pkg.config.publish?.target;
132
+ const targets = Array.isArray(target) ? target : target ? [target] : ['npm'];
133
+ return targets.includes('github');
134
+ }
135
+ /** The tag naming this repository's release. A calendar root version means several version lines,
136
+ * so the release needs a name of its own (`release-*`); a plain one means every package shares it,
137
+ * and that shared version's tag already *is* the release. */
138
+ function releaseTagFor(root) {
139
+ return isCalendarVersion(root.version) ? expandReleaseTag(root, root.version) : expandTag(root, root.version);
140
+ }
141
+ /** The glob matching the tags `releaseTagFor` produces - for stepping back to the previous one. */
142
+ function releaseTagGlob(root) {
143
+ const pattern = isCalendarVersion(root.version) ? releaseTagPattern(root) : tagPattern(root);
144
+ return pattern.replace('{name}', root.name);
145
+ }
146
+ function resolvePrerelease(root, version) {
147
+ const configured = root.config?.publish?.github?.prerelease;
148
+ // A calendar version's time part is a semver prerelease identifier by construction - it says
149
+ // nothing about the release being a preview, so it must not be read as one.
150
+ return configured ?? (!isCalendarVersion(version) && !!semver.prerelease(version));
151
+ }
152
+ /** `owner/repo` out of either remote URL form git hands back - `git@github.com:owner/repo.git`
153
+ * (SSH) or `https://github.com/owner/repo.git` (HTTPS, credentials and all). `undefined` for
154
+ * anything that isn't recognizably a GitHub remote. */
155
+ function repoFromRemoteUrl(url) {
156
+ if (!url)
157
+ return undefined;
158
+ const match = /github\.com[:/]([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(url.trim());
159
+ return match ? `${match[1]}/${match[2]}` : undefined;
160
+ }
161
+ function githubToken() {
162
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
163
+ if (!token)
164
+ throw new Error('GITHUB_TOKEN (or GH_TOKEN) environment variable is required to publish to GitHub');
165
+ return token;
166
+ }
167
+ function githubHeaders() {
168
+ return {
169
+ Authorization: `Bearer ${githubToken()}`,
170
+ Accept: 'application/vnd.github+json',
171
+ 'X-GitHub-Api-Version': '2022-11-28',
172
+ };
173
+ }
174
+ /** `GET /repos/{owner}/{repo}/releases/tags/{tag}` - `false` only for a genuine 404 (no release
175
+ * for that tag yet). Anything else throws rather than reading as "not published": a bad token or
176
+ * a typo'd repository would otherwise silently plan a publish that only fails much later. */
177
+ async function defaultReleaseExists(repository, tag) {
178
+ const res = await fetch(`${GITHUB_API}/repos/${repository}/releases/tags/${encodeURIComponent(tag)}`, {
179
+ headers: githubHeaders(),
180
+ });
181
+ if (res.ok)
182
+ return true;
183
+ if (res.status === 404)
184
+ return false;
185
+ throw new Error(`GitHub release lookup for "${tag}" failed: ${res.status} ${res.statusText}`);
186
+ }
187
+ /**
188
+ * The release body: one changelog section per package that shipped since the previous repository
189
+ * release. Generated per package rather than in one call, because each carries its own version -
190
+ * under independent versioning they differ, and a single shared label would misname all but one.
191
+ *
192
+ * The boundary is deliberately the previous *release*, not `changelog`'s own auto-detection: the
193
+ * tag being released already exists by the time `publish` runs, so auto-detection would resolve to
194
+ * it and correctly report nothing at all.
195
+ */
196
+ async function buildReleaseNotes(repository, git, releaseTag) {
197
+ const root = repository.rootPackage;
198
+ const previous = (await git.describeTag(releaseTagGlob(root), `${releaseTag}^`)) ?? (await git.rootCommit());
199
+ if (!previous)
200
+ return '';
201
+ const sections = [];
202
+ for (const pkg of [...repository.getPackages(), root]) {
203
+ const entries = await ChangelogService.getEntries(repository, {
204
+ from: previous,
205
+ root: true,
206
+ includeSkipped: true,
207
+ scope: pkg.name,
208
+ version: pkg.version,
209
+ });
210
+ for (const entry of entries)
211
+ sections.push(entry.content.trim());
212
+ }
213
+ return sections.join('\n\n');
214
+ }
215
+ async function createOrUpdateRelease(repository, tag, fields) {
216
+ const res = await fetch(`${GITHUB_API}/repos/${repository}/releases`, {
217
+ method: 'POST',
218
+ headers: { ...githubHeaders(), 'Content-Type': 'application/json' },
219
+ body: JSON.stringify({ tag_name: tag, ...fields }),
220
+ });
221
+ if (res.ok)
222
+ return (await res.json());
223
+ // 422 is how the API reports "a release for this tag already exists" - converge onto it instead
224
+ // of failing, so re-running after a partially failed release finishes the job.
225
+ if (res.status !== 422) {
226
+ throw new Error(`Unable to create GitHub release "${tag}": ${res.status} ${res.statusText}`);
227
+ }
228
+ const existing = await fetch(`${GITHUB_API}/repos/${repository}/releases/tags/${encodeURIComponent(tag)}`, {
229
+ headers: githubHeaders(),
230
+ });
231
+ if (!existing.ok) {
232
+ throw new Error(`Unable to create GitHub release "${tag}": ${res.status} ${res.statusText}`);
233
+ }
234
+ const release = (await existing.json());
235
+ const updated = await fetch(`${GITHUB_API}/repos/${repository}/releases/${release.id}`, {
236
+ method: 'PATCH',
237
+ headers: { ...githubHeaders(), 'Content-Type': 'application/json' },
238
+ body: JSON.stringify(fields),
239
+ });
240
+ if (!updated.ok) {
241
+ throw new Error(`Unable to update GitHub release "${tag}": ${updated.status} ${updated.statusText}`);
242
+ }
243
+ return release;
244
+ }
245
+ /** Every `publish.github.assets` glob across the repository, each resolved against its own
246
+ * package's directory - an app ships its artifacts from its own folder, but they all land on the
247
+ * one release the repository cut. */
248
+ async function uploadAssets(repository, repo, releaseId) {
249
+ for (const pkg of [repository.rootPackage, ...repository.getPackages()]) {
250
+ const patterns = pkg.config.publish?.github?.assets;
251
+ if (!patterns?.length)
252
+ continue;
253
+ const files = await fastGlob(patterns, { cwd: pkg.dirname, absolute: true, onlyFiles: true });
254
+ for (const file of files) {
255
+ const name = path.basename(file);
256
+ const res = await fetch(`${GITHUB_UPLOADS}/repos/${repo}/releases/${releaseId}/assets?name=${encodeURIComponent(name)}`, {
257
+ method: 'POST',
258
+ headers: { ...githubHeaders(), 'Content-Type': 'application/octet-stream' },
259
+ body: fs.readFileSync(file),
260
+ });
261
+ if (!res.ok)
262
+ throw new Error(`Unable to upload asset "${name}": ${res.status} ${res.statusText}`);
263
+ }
264
+ }
265
+ }
@@ -1,5 +1,5 @@
1
- import type { RmanConfig } from '../core/config.js';
2
1
  import type { Repository } from '../core/repository.js';
2
+ import type { RmanConfig } from '../interfaces/rman-config.interface.js';
3
3
  import { type PackageFilterOptions } from '../utils/package-filter.js';
4
4
  export declare namespace ListService {
5
5
  interface Options extends PackageFilterOptions {
@@ -41,7 +41,15 @@ export var PublishService;
41
41
  const entries = new Map();
42
42
  const toCheck = [];
43
43
  for (const pkg of packages) {
44
- if (pkg.isPrivate) {
44
+ if (pkg.config.publish?.skip) {
45
+ entries.set(pkg.name, {
46
+ package: pkg,
47
+ version: pkg.version,
48
+ status: 'skip',
49
+ reason: 'excluded via .rmanrc "publish.skip"',
50
+ });
51
+ }
52
+ else if (pkg.isPrivate) {
45
53
  entries.set(pkg.name, { package: pkg, version: pkg.version, status: 'skip', reason: 'private package' });
46
54
  }
47
55
  else if (isDirty(pkg)) {
@@ -20,6 +20,13 @@ export declare namespace VersionService {
20
20
  * `incVersion`. Has no effect when `bump` is an explicit semver version rather than a keyword
21
21
  * (there's no severity left to "pre-fix" at that point). */
22
22
  preid?: string;
23
+ /** Overrides the npm registry lookup `detectChangeHash` falls back to for a package that has
24
+ * no release tag yet - mainly for tests, so they don't depend on network access or a real
25
+ * published package. Same shape as `ChangelogService.Deps`/`PublishService.Deps`' own. */
26
+ npmViewVersion?: (name: string, cwd: string) => Promise<string | undefined>;
27
+ /** Clock behind a monorepo root's calendar release version - injectable so tests are
28
+ * deterministic. Default `() => new Date()`. */
29
+ now?: () => Date;
23
30
  }
24
31
  interface ApplyOptions {
25
32
  /** Push the resulting commit(s) and tag(s) to the remote once applied. Default false - same
@@ -61,8 +68,8 @@ export declare namespace VersionService {
61
68
  * version currently found among its own members (never persisted anywhere) - see
62
69
  * `resolveGroupKey`.
63
70
  *
64
- * Within a group, a member with real commits since its own last release tag (or an explicit
65
- * `bump`) sets the group's severity to the highest found among changed members; the new version
71
+ * Within a group, a member with real commits since its own last release (or an explicit `bump`)
72
+ * sets the group's severity to the highest found among changed members; the new version
66
73
  * is that current version bumped by that severity. Which members actually receive it depends on
67
74
  * the severity: **patch** only the changed member(s) (a caret dependency range already tolerates
68
75
  * a patch bump, no republish needed downstream); **minor** also every transitive in-group
@@ -75,8 +82,12 @@ export declare namespace VersionService {
75
82
  * on, until nothing new is affected - see `rippleCrossGroup`.
76
83
  *
77
84
  * A monorepo's root package is never a real member of any group (it's never published on its
78
- * own) - it gets one trailing informational entry instead, always `'bump'`ed to whatever single
79
- * version every group ended up sharing, or the overall highest version when groups diverged.
85
+ * own) - it gets one trailing entry instead, carrying the repository's own release identity: the
86
+ * single group's version when there is one, a calendar version once there are several - see
87
+ * `buildRootEntry`.
88
+ *
89
+ * "Since its own last release" is resolved by the shared `detectChangeHash` - the same boundary
90
+ * `changelog` measures from, so the two never disagree about which commits are unreleased.
80
91
  */
81
92
  function getPlan(repository: Repository, options?: Options): Promise<Entry[]>;
82
93
  /**
@@ -1,10 +1,11 @@
1
1
  import path from 'node:path';
2
2
  import semver from 'semver';
3
- import { findLatestTag, tagPattern } from '../utils/change-hash.js';
4
- import { hasBreakingChangeFooter, parseConventionalCommit, parseReleaseAs, VERSION_BUMP_PATTERN, } from '../utils/conventional-commits.js';
3
+ import { detectChangeHash, expandTag } from '../utils/change-hash.js';
4
+ import { hasBreakingChangeFooter, isReleaseCommit, parseConventionalCommit, parseReleaseAs, } from '../utils/conventional-commits.js';
5
5
  import { exec } from '../utils/exec.js';
6
6
  import { GitHelper } from '../utils/git.js';
7
7
  import { filterPackages } from '../utils/package-filter.js';
8
+ import { expandReleaseTag, findLastReleaseVersion, formatCalendarVersion, isCalendarVersion, usesCalendarVersion, } from '../utils/release-version.js';
8
9
  import { parseWorkspaceRange } from '../utils/workspace-range.js';
9
10
  import { ChangelogService } from './changelog.service.js';
10
11
  export var VersionService;
@@ -25,8 +26,8 @@ export var VersionService;
25
26
  * version currently found among its own members (never persisted anywhere) - see
26
27
  * `resolveGroupKey`.
27
28
  *
28
- * Within a group, a member with real commits since its own last release tag (or an explicit
29
- * `bump`) sets the group's severity to the highest found among changed members; the new version
29
+ * Within a group, a member with real commits since its own last release (or an explicit `bump`)
30
+ * sets the group's severity to the highest found among changed members; the new version
30
31
  * is that current version bumped by that severity. Which members actually receive it depends on
31
32
  * the severity: **patch** only the changed member(s) (a caret dependency range already tolerates
32
33
  * a patch bump, no republish needed downstream); **minor** also every transitive in-group
@@ -39,8 +40,12 @@ export var VersionService;
39
40
  * on, until nothing new is affected - see `rippleCrossGroup`.
40
41
  *
41
42
  * A monorepo's root package is never a real member of any group (it's never published on its
42
- * own) - it gets one trailing informational entry instead, always `'bump'`ed to whatever single
43
- * version every group ended up sharing, or the overall highest version when groups diverged.
43
+ * own) - it gets one trailing entry instead, carrying the repository's own release identity: the
44
+ * single group's version when there is one, a calendar version once there are several - see
45
+ * `buildRootEntry`.
46
+ *
47
+ * "Since its own last release" is resolved by the shared `detectChangeHash` - the same boundary
48
+ * `changelog` measures from, so the two never disagree about which commits are unreleased.
44
49
  */
45
50
  async function getPlan(repository, options = {}) {
46
51
  const bump = options.bump?.trim();
@@ -69,21 +74,22 @@ export var VersionService;
69
74
  }
70
75
  eligible.push(pkg);
71
76
  }
77
+ const commitMessage = repository.rootPackage.config?.version?.commitMessage;
72
78
  const changeByPackage = new Map();
73
79
  await Promise.all(eligible.map(async (pkg) => {
74
80
  if (explicitVersion) {
75
81
  changeByPackage.set(pkg.name, { severity: undefined, reason: `explicit version ${explicitVersion}` });
76
82
  return;
77
83
  }
78
- const tag = await findLatestTag(git, pkg);
79
- const commits = tag ? await git.listCommits({ hash: tag }) : await git.listAllCommits();
84
+ const since = await detectChangeHash(git, pkg, { npmViewVersion: options.npmViewVersion });
85
+ const commits = since ? await git.listCommits({ hash: since }) : await git.listAllCommits();
80
86
  const belongsToPkg = (c) => c.files.some(f => !path.relative(pkg.dirname, f).startsWith('..'));
81
- const real = commits.filter(c => belongsToPkg(c) && !VERSION_BUMP_PATTERN.test(c.subject));
87
+ const real = commits.filter(c => belongsToPkg(c) && !isReleaseCommit(c.subject, commitMessage));
82
88
  if (!real.length)
83
89
  return;
84
90
  changeByPackage.set(pkg.name, {
85
91
  severity: explicitSeverity ?? detectSeverity(real),
86
- reason: tag ? `changed since ${tag}` : 'unreleased commits',
92
+ reason: since ? `changed since ${since}` : 'unreleased commits',
87
93
  });
88
94
  }));
89
95
  const groups = new Map();
@@ -100,8 +106,13 @@ export var VersionService;
100
106
  }
101
107
  rippleCrossGroup(packages, entries, options.preid);
102
108
  const result = packages.map(pkg => entries.get(pkg.name));
103
- if (repository.monorepo)
104
- result.push(buildRootEntry(repository, result));
109
+ if (repository.monorepo) {
110
+ result.push(buildRootEntry(repository, result, {
111
+ groupCount: groups.size,
112
+ lastReleaseVersion: await findLastReleaseVersion(git, repository.rootPackage),
113
+ now: options.now ?? (() => new Date()),
114
+ }));
115
+ }
105
116
  return result;
106
117
  }
107
118
  VersionService.getPlan = getPlan;
@@ -172,12 +183,27 @@ export var VersionService;
172
183
  scope: entry.package.name,
173
184
  root: true,
174
185
  from,
186
+ // The tag for this release doesn't exist yet (it's created below), so changelog's own
187
+ // tag-derived version would resolve to the *previous* release and label the entry with it.
188
+ version: entry.to,
189
+ // version doesn't consult "publish.skip" at all (a package can still be meaningfully
190
+ // versioned/changelogged without ever being published) - this entry was already decided
191
+ // to bump, so its folded-in changelog shouldn't then be silently dropped by that flag.
192
+ includeSkipped: true,
175
193
  });
176
194
  for (const ce of changelogEntries) {
177
195
  changelogFileByPackage.set(ce.package.name, path.relative(repository.dirname, path.join(ce.package.dirname, ce.filePath)));
178
196
  }
179
197
  }
180
198
  }
199
+ /** The root's own informational version write isn't part of any group's release, but still
200
+ * needs to land in *some* commit rather than being left as an uncommitted local edit. Committed
201
+ * *before* the group commits, so the last commit this makes is always a tagged release commit -
202
+ * otherwise the tag sits one commit behind HEAD and every `git tag --points-at HEAD` consumer
203
+ * (CI capturing the tag it just released, say) comes up empty in a monorepo. */
204
+ if (rootEntry?.status === 'bump') {
205
+ await git.commit([path.relative(repository.dirname, repository.rootPackage.jsonFileName)], `chore: sync root version to ${rootEntry.to}`);
206
+ }
181
207
  const byGroup = new Map();
182
208
  for (const entry of bumped) {
183
209
  const list = byGroup.get(entry.groupKey);
@@ -199,10 +225,18 @@ export var VersionService;
199
225
  if (!(await git.tagExists(tag)))
200
226
  await git.createTag(tag);
201
227
  }
202
- /** The root's own informational version write isn't part of any group's release, but still
203
- * needs to land in *some* commit rather than being left as an uncommitted local edit. */
204
- if (rootEntry?.status === 'bump') {
205
- await git.commit([path.relative(repository.dirname, repository.rootPackage.jsonFileName)], `chore: sync root version to ${rootEntry.to}`);
228
+ /** A repository release tag, on top of the per-group ones - but only once the root is on a
229
+ * calendar version. With a single version line the group's own tag already *is* the release
230
+ * (same version, same commit), and a second name for it would only add noise to every existing
231
+ * repo's tag space. Created last, so it lands on HEAD rather than behind whichever group
232
+ * happened to be committed last. */
233
+ if (rootEntry?.status === 'bump' && isCalendarVersion(rootEntry.to)) {
234
+ const releaseTag = expandReleaseTag(repository.rootPackage, rootEntry.to);
235
+ if (await git.tagExists(releaseTag)) {
236
+ throw new Error(`Release tag "${releaseTag}" already exists - a second release within the same minute. ` +
237
+ 'Wait a moment and run again.');
238
+ }
239
+ await git.createTag(releaseTag);
206
240
  }
207
241
  if (options.push && bumped.length)
208
242
  await git.push();
@@ -377,19 +411,33 @@ function rippleCrossGroup(packages, entries, preid) {
377
411
  }
378
412
  }
379
413
  }
380
- /** The root's own `version` field is purely informational in a monorepo (it's never published on
381
- * its own) - it always reflects whatever single version every group ended up sharing, or the
382
- * overall highest version when groups diverged onto different numbers. Reports `'no-change'`
383
- * (not `'bump'`) when nothing in the repository changed at all. */
384
- function buildRootEntry(repository, memberEntries) {
414
+ /**
415
+ * A monorepo root is never published on its own, but its version is still the repository's release
416
+ * identity - what a GitHub Release is named after. How it's computed depends on how many version
417
+ * lines the repo has, derived rather than configured (see `usesCalendarVersion`):
418
+ *
419
+ * - **One group**: the root simply follows it, so the repo and its packages share one number.
420
+ * - **Several groups** (or a repo already on calendar): a calendar version (`2026.9.15-1430`).
421
+ * There is no meaningful shared number to report - the old "highest version among the groups"
422
+ * rule would leave the root standing still whenever a *lower* line released, so a release could
423
+ * happen with no identity of its own, and a semver-looking identity would anyway claim something
424
+ * untrue about packages sitting on entirely different lines.
425
+ *
426
+ * Reports `'no-change'` (not `'bump'`) when nothing in the repository changed at all.
427
+ */
428
+ function buildRootEntry(repository, memberEntries, context) {
385
429
  const root = repository.rootPackage;
386
430
  const anyBumped = memberEntries.some(e => e.status === 'bump');
387
431
  if (!anyBumped) {
388
432
  return { package: root, groupKey: '__root__', group: 'root', status: 'no-change', from: root.version };
389
433
  }
434
+ const calendar = usesCalendarVersion({
435
+ groupCount: context.groupCount,
436
+ rootVersion: root.version,
437
+ lastReleaseVersion: context.lastReleaseVersion,
438
+ });
390
439
  const finalVersions = memberEntries.map(e => e.to ?? e.from);
391
- const unique = new Set(finalVersions);
392
- const to = unique.size === 1 ? finalVersions[0] : maxVersion(finalVersions);
440
+ const to = calendar ? formatCalendarVersion(context.now()) : maxVersion(finalVersions);
393
441
  return {
394
442
  package: root,
395
443
  groupKey: '__root__',
@@ -397,7 +445,9 @@ function buildRootEntry(repository, memberEntries) {
397
445
  status: 'bump',
398
446
  from: root.version,
399
447
  to,
400
- reason: 'informational - monorepo root is never published on its own',
448
+ reason: calendar
449
+ ? 'repository release identity - several version lines, so no shared number to report'
450
+ : 'informational - monorepo root is never published on its own',
401
451
  };
402
452
  }
403
453
  /** `.rmanrc version.commitMessage` (root-level; `{version}` is replaced when every bumped package
@@ -417,14 +467,6 @@ function buildCommitMessage(repository, entries, messageOverride) {
417
467
  return messageOverride;
418
468
  return `chore(release): ${entries.map(e => `${e.package.name}@${e.to}`).join(', ')}`;
419
469
  }
420
- /** Expands `pkg`'s (cascaded) `.rmanrc changelog.tagPattern` into a concrete tag name for
421
- * `version` - the same pattern `changelog` reads tags back with (see `findLatestTag`), just run
422
- * forward: `{name}` becomes the package's own name, and `*` becomes `version`. */
423
- function expandTag(pkg, version) {
424
- const pattern = tagPattern(pkg).replace('{name}', pkg.name);
425
- const starIdx = pattern.indexOf('*');
426
- return starIdx === -1 ? pattern : pattern.slice(0, starIdx) + version + pattern.slice(starIdx + 1);
427
- }
428
470
  /** A `version.<key>` value: one command, or several to run in sequence - same shape as
429
471
  * `run.<script>.script`/`.preScript`/`.postScript`. */
430
472
  function normalizeScriptValue(value) {
package/services.d.ts CHANGED
@@ -3,6 +3,7 @@ export { CiService } from './services/ci.service.js';
3
3
  export { CleanService } from './services/clean.service.js';
4
4
  export { DockerPublishService } from './services/docker-publish.service.js';
5
5
  export { ExecService } from './services/exec.service.js';
6
+ export { GithubReleaseService } from './services/github-release.service.js';
6
7
  export { ImportService } from './services/import.service.js';
7
8
  export { ListService } from './services/list.service.js';
8
9
  export { PublishService } from './services/publish.service.js';
package/services.js CHANGED
@@ -3,6 +3,7 @@ export { CiService } from './services/ci.service.js';
3
3
  export { CleanService } from './services/clean.service.js';
4
4
  export { DockerPublishService } from './services/docker-publish.service.js';
5
5
  export { ExecService } from './services/exec.service.js';
6
+ export { GithubReleaseService } from './services/github-release.service.js';
6
7
  export { ImportService } from './services/import.service.js';
7
8
  export { ListService } from './services/list.service.js';
8
9
  export { PublishService } from './services/publish.service.js';
@@ -13,6 +13,16 @@ export declare function tagPattern(pkg: Package): string;
13
13
  * (reading the last-documented version) and `version` (finding the boundary a bump measures
14
14
  * "since"). */
15
15
  export declare function findLatestTag(git: GitHelper, pkg: Package): Promise<string | undefined>;
16
+ /** The forward direction of `findLatestTag`: expands `pkg`'s (cascaded) `.rmanrc
17
+ * changelog.tagPattern` into the concrete tag name `version` belongs under - `{name}` becomes the
18
+ * package's own name, `*` becomes `version`. Shared by `version` (creating the tag), `publish
19
+ * --target github` (finding the release that tag belongs to), and `detectChangeHash`'s own npm
20
+ * fallback (mapping a published version back onto a tag), so all three name tags identically. */
21
+ export declare function expandTag(pkg: Package, version: string): string;
22
+ /** The pattern expansion `expandTag` performs, on any pattern - `{name}` becomes `name`, `*` becomes
23
+ * `version`. Shared with the repository's own release tag, which uses a different pattern (see
24
+ * `releaseTagPattern`) but names tags the same way. */
25
+ export declare function applyTagPattern(pattern: string, name: string, version: string): string;
16
26
  /** Strips the pattern's literal prefix (everything before its first `*`) from `tag` to get just
17
27
  * the version part - e.g. tag `@sqb/builder@1.2.3` against pattern `@sqb/builder@*` -> `1.2.3`.
18
28
  * A pattern with no `*` is returned as its own "version" verbatim (an exact tag, nothing to strip). */
@@ -43,13 +53,16 @@ export interface DetectChangeHashOptions {
43
53
  * Resolves the commit/hash a package's changes should be measured "since" - the boundary
44
54
  * `changelog --from` uses, but reusable anywhere a command wants to answer "what changed for this
45
55
  * package". An explicit `options.from` (anything but `"npm"`) is returned as-is, applying the same
46
- * way to every package. Otherwise, it's auto-detected from the package's currently-published npm
47
- * version: looked up via `npmViewVersion`, then mapped to a git tag using `.rmanrc
48
- * changelog.tagPattern` (so independent and fixed monorepo versioning schemes both work - see
49
- * `tagPattern`) - and, if `catchUpFile` is given and exists, widened to also cover anything that
50
- * file hasn't caught up on yet (see its doc comment). Returns `undefined` when nothing can be
51
- * resolved at all (unpublished, no network, no matching tag, no catch-up file) - callers should
52
- * fall back to their own default in that case (e.g. `GitHelper.listCommits`'s "not yet pushed"
53
- * default when no hash is given).
56
+ * way to every package. Otherwise, it's auto-detected in order: (1) this package's own most recent
57
+ * release tag - the same network-free `findLatestTag` lookup `version`/`changed` themselves use,
58
+ * so all three commands agree on "since when" for any repo whose tags are the ones `rman version`
59
+ * actually created; (2) failing that (no tag at all yet - e.g. onboarding `rman` onto a repo with
60
+ * real npm history but no `rman`-created tags), the package's currently-published npm version -
61
+ * looked up via `npmViewVersion`, then mapped to a git tag using `.rmanrc changelog.tagPattern`
62
+ * (see `tagPattern`). Either way, if `catchUpFile` is given and exists, the result is widened to
63
+ * also cover anything that file hasn't caught up on yet (see its doc comment). Returns `undefined`
64
+ * when nothing can be resolved at all (never tagged *and* never published, no catch-up file - a
65
+ * genuinely first-ever release) - callers should fall back to their own default in that case (e.g.
66
+ * the whole history, since nothing has ever been released).
54
67
  */
55
68
  export declare function detectChangeHash(git: GitHelper, pkg: Package, options?: DetectChangeHashOptions): Promise<string | undefined>;