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.
- package/README.md +12 -3
- package/commands/changelog.command.js +21 -6
- package/commands/publish.command.js +68 -9
- package/commands/version.command.js +4 -2
- package/constants.js +1 -1
- package/core/config.d.ts +1 -92
- package/core/package.d.ts +1 -1
- package/index.d.ts +1 -1
- package/index.js +11 -0
- package/interfaces/rman-config.interface.d.ts +130 -0
- package/interfaces/rman-config.interface.js +1 -0
- package/package.json +1 -1
- package/rmanrc.schema.json +129 -20
- package/services/changelog.service.d.ts +24 -12
- package/services/changelog.service.js +18 -17
- package/services/docker-publish.service.js +1 -1
- package/services/github-release.service.d.ts +68 -0
- package/services/github-release.service.js +265 -0
- package/services/list.service.d.ts +1 -1
- package/services/publish.service.js +9 -1
- package/services/version.service.d.ts +15 -4
- package/services/version.service.js +74 -32
- package/services.d.ts +1 -0
- package/services.js +1 -0
- package/utils/change-hash.d.ts +21 -8
- package/utils/change-hash.js +38 -20
- package/utils/conventional-commits.d.ts +13 -0
- package/utils/conventional-commits.js +47 -0
- package/utils/git.d.ts +8 -1
- package/utils/git.js +26 -2
- package/utils/release-version.d.ts +50 -0
- package/utils/release-version.js +66 -0
|
@@ -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.
|
|
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
|
|
65
|
-
*
|
|
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
|
|
79
|
-
*
|
|
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 {
|
|
4
|
-
import { hasBreakingChangeFooter, parseConventionalCommit, parseReleaseAs,
|
|
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
|
|
29
|
-
*
|
|
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
|
|
43
|
-
*
|
|
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
|
|
79
|
-
const commits =
|
|
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) && !
|
|
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:
|
|
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
|
-
/**
|
|
203
|
-
*
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
/**
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
|
|
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
|
|
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:
|
|
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';
|
package/utils/change-hash.d.ts
CHANGED
|
@@ -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
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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>;
|