dsh-keys-manager 0.2.1 → 0.2.3
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/CHANGELOG.md +97 -0
- package/README.md +108 -15
- package/README.zh-CN.md +80 -2
- package/bundle/cordis.patch.yml +5 -2
- package/lib/client/styles.d.ts +1 -1
- package/lib/client/types.d.ts +27 -2
- package/lib/client/ui.d.ts +14 -0
- package/lib/client.js +445 -18
- package/lib/domain.d.ts +63 -0
- package/lib/host-types.d.ts +29 -0
- package/lib/index.d.ts +973 -45
- package/lib/index.js +4257 -332
- package/lib/index.js.map +1 -1
- package/lib/providers.js +426 -0
- package/lib/providers.js.map +1 -1
- package/lib/store.js +4 -0
- package/lib/store.js.map +1 -1
- package/locale/en.json +27 -1
- package/locale/zh.json +27 -1
- package/package.json +14 -12
- package/scripts/prepare-release.mjs +431 -154
- package/scripts/verify-install.mjs +156 -0
|
@@ -1,36 +1,69 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Release-prep version helper (
|
|
3
|
+
* Release-prep version and changelog helper (plans dsh-keys-manager-ci-release,
|
|
4
|
+
* task 2 + 4; omp-transport-cursor-tools, task 5; release-changelog-fragments,
|
|
5
|
+
* task 1):
|
|
4
6
|
* resolves the target version — explicit `X.Y.Z` / `X.Y.Z-prerelease`
|
|
5
7
|
* argument (e.g. 0.1.1-alpha.1) or an auto patch bump from package.json —
|
|
6
|
-
* refuses already-released versions (existing `v<version>` git tag),
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* CHANGELOG.md (
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* new dependencies.
|
|
8
|
+
* refuses already-released versions (existing `v<version>` git tag), bumps
|
|
9
|
+
* package.json, assembles the release notes from the authored
|
|
10
|
+
* `.changes/unreleased/*.md` fragments, inserts the `## [<version>]` section
|
|
11
|
+
* into CHANGELOG.md (root only — the transports package has no generated
|
|
12
|
+
* changelog), archives the consumed fragments to `.changes/archive/<version>/`,
|
|
13
|
+
* and prints the root and transports versions and tags for the workflow to
|
|
14
|
+
* capture. Node built-ins only, no new dependencies.
|
|
15
|
+
*
|
|
16
|
+
* The fragments ARE the release notes: a release whose rendered body is empty
|
|
17
|
+
* (zero fragments, prose-only fragments, or fragments without `- ` bullet
|
|
18
|
+
* lines) is refused on both a dry run and a real run, so an empty
|
|
19
|
+
* `## [<version>]` section can never be published. Archiving never overwrites
|
|
20
|
+
* an existing `.changes/archive/<version>/` file, and a failed archive reports
|
|
21
|
+
* what moved and what did not. A run prints `ARCHIVED=1` only when it really
|
|
22
|
+
* consumed the fragments.
|
|
23
|
+
*
|
|
24
|
+
* The workspace package `packages/llm-omp` (`dsh-llm-omp-transports`) ships as
|
|
25
|
+
* part of the same release and always carries the same version: the resolved
|
|
26
|
+
* version is written to both package.json files, so one version input drives
|
|
27
|
+
* both tags (`v<version>` and `dsh-llm-omp-transports@<version>`). The
|
|
28
|
+
* transport version is checked against the tag the Release workflow creates
|
|
29
|
+
* after publishing, and the version written here is exactly the one
|
|
30
|
+
* `release.yml` publishes (that workflow skips an already-published version).
|
|
13
31
|
*
|
|
14
32
|
* Usage:
|
|
15
|
-
* node scripts/prepare-release.mjs
|
|
16
|
-
* node scripts/prepare-release.mjs 0.2.0
|
|
17
|
-
* node scripts/prepare-release.mjs
|
|
33
|
+
* node scripts/prepare-release.mjs # auto bump
|
|
34
|
+
* node scripts/prepare-release.mjs 0.2.0 # explicit version
|
|
35
|
+
* node scripts/prepare-release.mjs --dry-run # validate + print, write nothing
|
|
18
36
|
*
|
|
19
|
-
* Exit codes: 0 = bumped and printed
|
|
20
|
-
* version, existing tag, no
|
|
21
|
-
*
|
|
37
|
+
* Exit codes: 0 = bumped (or dry-run resolved) and printed versions; 1 =
|
|
38
|
+
* rejected (unparseable version, existing tag, no changelog bullets in
|
|
39
|
+
* `.changes/unreleased/`, an archive destination that already exists, an
|
|
40
|
+
* archive move that failed, unparseable current package.json version, invalid
|
|
41
|
+
* prerelease dist-tag, or CHANGELOG.md write error).
|
|
22
42
|
*/
|
|
23
43
|
|
|
24
44
|
import { execFileSync } from 'node:child_process'
|
|
25
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
existsSync,
|
|
47
|
+
mkdirSync,
|
|
48
|
+
readdirSync,
|
|
49
|
+
readFileSync,
|
|
50
|
+
realpathSync,
|
|
51
|
+
renameSync,
|
|
52
|
+
writeFileSync,
|
|
53
|
+
} from 'node:fs'
|
|
26
54
|
import { dirname, join } from 'node:path'
|
|
27
55
|
import { fileURLToPath } from 'node:url'
|
|
28
56
|
|
|
29
57
|
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
30
58
|
const PKG_PATH = join(REPO_ROOT, 'package.json')
|
|
59
|
+
const TRANSPORTS_PKG_PATH = join(REPO_ROOT, 'packages/llm-omp/package.json')
|
|
60
|
+
const TRANSPORT_PACKAGE = 'dsh-llm-omp-transports'
|
|
31
61
|
const CHANGELOG_PATH = join(REPO_ROOT, 'CHANGELOG.md')
|
|
32
62
|
const CHANGELOG_HEADER =
|
|
33
|
-
'# Changelog\n\nAll notable changes to dsh-keys-manager are documented here.
|
|
63
|
+
'# Changelog\n\nAll notable changes to dsh-keys-manager are documented here. Assembled from `.changes/unreleased/` fragments by the release-prep workflow.\n'
|
|
64
|
+
const UNRELEASED_DIR = join(REPO_ROOT, '.changes/unreleased')
|
|
65
|
+
const ARCHIVE_DIR = join(REPO_ROOT, '.changes/archive')
|
|
66
|
+
const DEFAULT_CATEGORY = 'Changed'
|
|
34
67
|
|
|
35
68
|
/**
|
|
36
69
|
* Parse X.Y.Z or X.Y.Z[-prerelease] (numeric parts; optional prerelease
|
|
@@ -53,191 +86,435 @@ function parseVersion(value) {
|
|
|
53
86
|
}
|
|
54
87
|
}
|
|
55
88
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Refuse a prerelease whose derived npm dist-tag the release workflow would
|
|
91
|
+
* reject, for either package. The tag is the text between the first '-' and
|
|
92
|
+
* the first '.' after it ("alpha.1" -> "alpha").
|
|
93
|
+
*/
|
|
94
|
+
function assertPublishablePrerelease(prerelease) {
|
|
95
|
+
if (prerelease === undefined) return
|
|
96
|
+
const distTag = prerelease.split('.')[0]
|
|
97
|
+
if (distTag === 'latest' || !/^[a-z][a-z0-9-]*$/.test(distTag)) {
|
|
98
|
+
console.error(
|
|
99
|
+
`Error: prerelease "${prerelease}" maps to invalid npm dist-tag "${distTag}". ` +
|
|
100
|
+
'Use a prerelease such as alpha.1, beta.1, or rc.1.',
|
|
101
|
+
)
|
|
63
102
|
process.exit(1)
|
|
64
103
|
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if (!m) {
|
|
79
|
-
console.error(`Error: cannot parse current package.json version "${pkg.version}".`)
|
|
80
|
-
process.exit(1)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse CLI flags. The first non-flag argument is the target version.
|
|
108
|
+
*/
|
|
109
|
+
function parseArgs(argv) {
|
|
110
|
+
const parsed = { versionArg: undefined, dryRun: false }
|
|
111
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
112
|
+
const arg = argv[i]
|
|
113
|
+
if (arg === '--dry-run') parsed.dryRun = true
|
|
114
|
+
else if (arg.startsWith('--')) usage(`unknown option "${arg}"`)
|
|
115
|
+
else if (parsed.versionArg === undefined) parsed.versionArg = arg
|
|
116
|
+
else usage(`unexpected extra argument "${arg}"`)
|
|
81
117
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
118
|
+
return parsed
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function usage(message) {
|
|
122
|
+
console.error(`Error: ${message}.`)
|
|
123
|
+
console.error(
|
|
124
|
+
'Usage: node scripts/prepare-release.mjs [X.Y.Z[-prerelease]] [--dry-run]',
|
|
125
|
+
)
|
|
126
|
+
process.exit(1)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve an explicit target version, or auto-bump the current one.
|
|
131
|
+
*
|
|
132
|
+
* Auto bump (mirrors sibling dsh-llm-fallbacks autoBumpPatch): parse the
|
|
133
|
+
* current version as X.Y.Z or X.Y.Z-pre, then:
|
|
134
|
+
* - formal version X.Y.Z -> X.Y.(Z+1) (0.1.2 -> 0.1.3)
|
|
135
|
+
* - prerelease with numeric tail -> bump only the tail, staying on the
|
|
136
|
+
* prerelease line (0.1.3-alpha.3 -> 0.1.3-alpha.4)
|
|
137
|
+
* - prerelease with non-numeric tail (e.g. 0.1.0-alpha) -> cannot auto
|
|
138
|
+
* bump; require an explicit version argument.
|
|
139
|
+
* The looser regex is deliberate: parseVersion (explicit args) requires a
|
|
140
|
+
* digit in the suffix, so it cannot tell "alpha" from a malformed version;
|
|
141
|
+
* this path needs the raw suffix to reject non-numeric tails with the
|
|
142
|
+
* explicit-version hint.
|
|
143
|
+
*/
|
|
144
|
+
function resolveTarget(explicit, current, label) {
|
|
145
|
+
if (explicit !== undefined) {
|
|
146
|
+
const target = parseVersion(explicit)
|
|
147
|
+
if (target === null) {
|
|
88
148
|
console.error(
|
|
89
|
-
`Error:
|
|
90
|
-
'Pass an explicit version instead (e.g. `node scripts/prepare-release.mjs 0.1.1-alpha.1`).',
|
|
149
|
+
`Error: invalid version "${explicit}". Expected X.Y.Z or X.Y.Z-prerelease (e.g. 0.2.0, 0.1.1-alpha.1).`,
|
|
91
150
|
)
|
|
92
151
|
process.exit(1)
|
|
93
152
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
153
|
+
return target
|
|
154
|
+
}
|
|
155
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(current)
|
|
156
|
+
if (!m) {
|
|
157
|
+
console.error(`Error: cannot parse current ${label} version "${current}".`)
|
|
158
|
+
process.exit(1)
|
|
159
|
+
}
|
|
160
|
+
const [, major, minor, patch, pre] = m
|
|
161
|
+
if (pre === undefined) {
|
|
162
|
+
return { major: Number(major), minor: Number(minor), patch: Number(patch) + 1 }
|
|
163
|
+
}
|
|
164
|
+
const tail = pre.split('.').at(-1)
|
|
165
|
+
if (tail === undefined || !/^\d+$/.test(tail)) {
|
|
166
|
+
console.error(
|
|
167
|
+
`Error: cannot auto-bump "${current}": prerelease tail "${pre}" is not numeric. ` +
|
|
168
|
+
'Pass an explicit version instead (e.g. `node scripts/prepare-release.mjs 0.1.1-alpha.1`).',
|
|
169
|
+
)
|
|
170
|
+
process.exit(1)
|
|
171
|
+
}
|
|
172
|
+
const prefix = pre.slice(0, pre.length - tail.length) // "alpha." for "alpha.1"
|
|
173
|
+
return {
|
|
174
|
+
major: Number(major),
|
|
175
|
+
minor: Number(minor),
|
|
176
|
+
patch: Number(patch),
|
|
177
|
+
prerelease: `${prefix}${Number(tail) + 1}`,
|
|
101
178
|
}
|
|
102
179
|
}
|
|
103
180
|
|
|
104
|
-
|
|
105
|
-
target.prerelease === undefined
|
|
181
|
+
function formatVersion(target) {
|
|
182
|
+
return target.prerelease === undefined
|
|
106
183
|
? `${target.major}.${target.minor}.${target.patch}`
|
|
107
184
|
: `${target.major}.${target.minor}.${target.patch}-${target.prerelease}`
|
|
185
|
+
}
|
|
108
186
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
process.exit(1)
|
|
187
|
+
/** Whether an exact tag ref exists. */
|
|
188
|
+
function tagExists(tag) {
|
|
189
|
+
try {
|
|
190
|
+
execFileSync('git', ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}`], {
|
|
191
|
+
cwd: REPO_ROOT,
|
|
192
|
+
stdio: 'ignore',
|
|
193
|
+
})
|
|
194
|
+
return true
|
|
195
|
+
} catch {
|
|
196
|
+
return false
|
|
120
197
|
}
|
|
121
198
|
}
|
|
122
199
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Split an optional leading `---` frontmatter block off a fragment. Only
|
|
202
|
+
* `key: value` lines are read; the rest of the file is the body.
|
|
203
|
+
*/
|
|
204
|
+
function parseFrontmatter(text) {
|
|
205
|
+
const frontmatter = {}
|
|
206
|
+
if (!text.startsWith('---')) return { frontmatter, body: text }
|
|
207
|
+
const end = text.indexOf('\n---', 3)
|
|
208
|
+
if (end === -1) return { frontmatter, body: text }
|
|
209
|
+
for (const line of text.slice(3, end).split('\n')) {
|
|
210
|
+
const match = /^([A-Za-z0-9 _-]+):\s*(.*)$/.exec(line)
|
|
211
|
+
if (match) frontmatter[match[1].trim()] = match[2].trim()
|
|
212
|
+
}
|
|
213
|
+
return { frontmatter, body: text.slice(end + 4).replace(/^\n/, '') }
|
|
132
214
|
}
|
|
133
215
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
)
|
|
144
|
-
|
|
216
|
+
/**
|
|
217
|
+
* One fragment file -> its grouping category (undefined = default), its bullet
|
|
218
|
+
* lines, and how many other non-blank body lines were ignored. Only lines that
|
|
219
|
+
* start with `- ` after trimming (and carry text after the dash) are release
|
|
220
|
+
* notes; prose cannot pass for a bullet, so a fragment of prose renders an
|
|
221
|
+
* empty body and the caller refuses the release instead of publishing a
|
|
222
|
+
* paragraph as a changelog entry.
|
|
223
|
+
*/
|
|
224
|
+
export function parseFragment(file, text) {
|
|
225
|
+
const { frontmatter, body } = parseFrontmatter(text)
|
|
226
|
+
const bullets = []
|
|
227
|
+
let dropped = 0
|
|
228
|
+
for (const line of body.split('\n')) {
|
|
229
|
+
const trimmed = line.trim()
|
|
230
|
+
if (trimmed.length === 0) continue
|
|
231
|
+
if (trimmed.startsWith('- ') && trimmed.slice(2).trim().length > 0) bullets.push(trimmed)
|
|
232
|
+
else dropped += 1
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
file,
|
|
236
|
+
category: frontmatter.category?.trim() || undefined,
|
|
237
|
+
bullets,
|
|
238
|
+
dropped,
|
|
239
|
+
}
|
|
145
240
|
}
|
|
146
241
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
242
|
+
/**
|
|
243
|
+
* Fragments in filename order. `README.md` (the format doc) and every
|
|
244
|
+
* non-`.md` entry (`.gitkeep`) are skipped; a missing directory is zero
|
|
245
|
+
* fragments, which the caller refuses for a real release.
|
|
246
|
+
*/
|
|
247
|
+
export function readFragments(dir = UNRELEASED_DIR) {
|
|
248
|
+
if (!existsSync(dir)) return []
|
|
249
|
+
return readdirSync(dir)
|
|
250
|
+
.filter((file) => file.endsWith('.md') && file !== 'README.md')
|
|
251
|
+
.sort()
|
|
252
|
+
.map((file) => parseFragment(file, readFileSync(join(dir, file), 'utf8')))
|
|
253
|
+
}
|
|
152
254
|
|
|
153
255
|
/**
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* git ref — the bump commit is not tagged yet.
|
|
256
|
+
* Render the section body: `### <category>` + its bullets, categories in
|
|
257
|
+
* first-seen order (a fragment without `category:` lands under `Changed`).
|
|
258
|
+
* A fragment with no bullets contributes nothing — not even a bullet-less
|
|
259
|
+
* heading — so "every fragment is empty" really renders an empty body and the
|
|
260
|
+
* caller can refuse it.
|
|
160
261
|
*/
|
|
161
|
-
function
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
262
|
+
export function buildSectionBody(fragments) {
|
|
263
|
+
const groups = []
|
|
264
|
+
for (const fragment of fragments) {
|
|
265
|
+
if (fragment.bullets.length === 0) continue
|
|
266
|
+
const category = fragment.category ?? DEFAULT_CATEGORY
|
|
267
|
+
let group = groups.find((candidate) => candidate.category === category)
|
|
268
|
+
if (group === undefined) {
|
|
269
|
+
group = { category, bullets: [] }
|
|
270
|
+
groups.push(group)
|
|
271
|
+
}
|
|
272
|
+
group.bullets.push(...fragment.bullets)
|
|
171
273
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
encoding: 'utf8',
|
|
176
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
177
|
-
}).trim()
|
|
178
|
-
return { prevTag, notes }
|
|
274
|
+
return groups
|
|
275
|
+
.map((group) => [`### ${group.category}`, '', ...group.bullets].join('\n'))
|
|
276
|
+
.join('\n\n')
|
|
179
277
|
}
|
|
180
278
|
|
|
181
279
|
/**
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
280
|
+
* Why this fragment set cannot be released, or null when it can. The rendered
|
|
281
|
+
* body IS the release notes, so an empty body — no fragments at all, fragments
|
|
282
|
+
* that are prose only, or fragments whose `- ` lines carry no text — would
|
|
283
|
+
* publish an empty section: refuse it. The message names the files that
|
|
284
|
+
* contributed nothing. Exported so a caller (CI, a test) can ask the same
|
|
285
|
+
* question without running the release flow.
|
|
188
286
|
*/
|
|
189
|
-
function
|
|
287
|
+
export function notesRefusal(fragments) {
|
|
288
|
+
if (buildSectionBody(fragments).trim() !== '') return null
|
|
289
|
+
const empties = fragments
|
|
290
|
+
.filter((fragment) => fragment.bullets.length === 0)
|
|
291
|
+
.map((fragment) => fragment.file)
|
|
292
|
+
return (
|
|
293
|
+
'no changelog bullets in .changes/unreleased/ — nothing to release. ' +
|
|
294
|
+
'Add one `- ` bullet per user-visible change (format: .changes/unreleased/README.md).' +
|
|
295
|
+
(empties.length === 0 ? '' : ` Fragment files without bullets: ${empties.join(', ')}.`)
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Insert a `## [<version>] - <date>` section above the first existing
|
|
301
|
+
* `## [` section (or after the header block when there is none yet).
|
|
302
|
+
* Everything at and below the insertion point keeps its bytes, including the
|
|
303
|
+
* file's trailing-newline state (a split/splice/join round-trip would
|
|
304
|
+
* re-normalize line breaks).
|
|
305
|
+
*/
|
|
306
|
+
export function insertSection(changelog, version, date, body) {
|
|
307
|
+
const header = `## [${version}] - ${date}`
|
|
308
|
+
const section = body.trim() === '' ? `${header}\n` : `${header}\n\n${body.trim()}\n`
|
|
309
|
+
const firstSectionMatch = /^## \[/m.exec(changelog)
|
|
310
|
+
if (firstSectionMatch === null) {
|
|
311
|
+
// No sections yet — append the new section after the header block.
|
|
312
|
+
return changelog.endsWith('\n')
|
|
313
|
+
? `${changelog}\n${section}`
|
|
314
|
+
: `${changelog}\n\n${section}`
|
|
315
|
+
}
|
|
316
|
+
return `${changelog.slice(0, firstSectionMatch.index)}${section}\n${changelog.slice(firstSectionMatch.index)}`
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Write the new section into CHANGELOG.md and report whether it was written.
|
|
321
|
+
* Preserves all existing sections; no-op (returns false) when the section for
|
|
322
|
+
* this version already exists, so a re-run must not duplicate it and must not
|
|
323
|
+
* consume fragments it never rendered. A 0-byte or whitespace-only file (e.g.
|
|
324
|
+
* manual truncation) is treated as missing, so it still boots with the
|
|
325
|
+
* standard header instead of a section above no header at all. Write failures
|
|
326
|
+
* exit 1.
|
|
327
|
+
*/
|
|
328
|
+
function updateChangelog(version, body) {
|
|
190
329
|
let content
|
|
191
330
|
try {
|
|
192
331
|
content = readFileSync(CHANGELOG_PATH, 'utf8')
|
|
193
332
|
} catch {
|
|
194
333
|
content = CHANGELOG_HEADER
|
|
195
334
|
}
|
|
196
|
-
// A 0-byte or whitespace-only CHANGELOG.md (e.g. manual truncation) is
|
|
197
|
-
// treated as missing: such a file must still bootstrap with the standard
|
|
198
|
-
// header, otherwise the new section would land at the top with leading
|
|
199
|
-
// blank lines and no `# Changelog` header.
|
|
200
335
|
if (content.trim() === '') {
|
|
201
336
|
content = CHANGELOG_HEADER
|
|
202
337
|
}
|
|
203
338
|
|
|
204
339
|
if (content.includes(`## [${version}]`)) {
|
|
205
|
-
return
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const sectionHeader = `## [${version}] - ${new Date().toISOString().slice(0, 10)}`
|
|
209
|
-
const sectionBody = notes
|
|
210
|
-
.split('\n')
|
|
211
|
-
.map((line) => `- ${line}`)
|
|
212
|
-
.join('\n')
|
|
213
|
-
// The section's own trailing `\n` plus the inserted `\n` below form the
|
|
214
|
-
// blank-line separator between the new section and the existing sections.
|
|
215
|
-
const section = `${sectionHeader}\n\n${sectionBody}\n`
|
|
216
|
-
|
|
217
|
-
// Insert on the first `## [` section header via string slice: everything
|
|
218
|
-
// at and below the insertion point — including the file's exact trailing
|
|
219
|
-
// newline — is preserved byte-for-byte. (A split('\n')/splice/join('\n')
|
|
220
|
-
// round-trip would re-normalize line breaks and could alter the tail, e.g.
|
|
221
|
-
// appending a trailing newline that was not in the source file.)
|
|
222
|
-
const firstSectionMatch = /^## \[/m.exec(content)
|
|
223
|
-
const firstSectionIdx = firstSectionMatch === null ? -1 : firstSectionMatch.index
|
|
224
|
-
let next
|
|
225
|
-
if (firstSectionIdx === -1) {
|
|
226
|
-
// No sections yet — append the new section after the header block,
|
|
227
|
-
// keeping the file's own trailing-newline state.
|
|
228
|
-
next = content.endsWith('\n') ? `${content}\n${section}` : `${content}\n\n${section}`
|
|
229
|
-
} else {
|
|
230
|
-
next = `${content.slice(0, firstSectionIdx)}${section}\n${content.slice(firstSectionIdx)}`
|
|
340
|
+
return false
|
|
231
341
|
}
|
|
232
342
|
|
|
343
|
+
const next = insertSection(content, version, new Date().toISOString().slice(0, 10), body)
|
|
344
|
+
|
|
233
345
|
try {
|
|
234
346
|
writeFileSync(CHANGELOG_PATH, next, 'utf8')
|
|
235
347
|
} catch (error) {
|
|
236
348
|
console.error(`Error: cannot write ${CHANGELOG_PATH}: ${error.message}`)
|
|
237
349
|
process.exit(1)
|
|
238
350
|
}
|
|
351
|
+
return true
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Move the consumed fragments to `.changes/archive/<version>/`.
|
|
356
|
+
*
|
|
357
|
+
* Authored release notes are never overwritten: a destination file that
|
|
358
|
+
* already exists refuses the whole move before anything is renamed, so a
|
|
359
|
+
* collision cannot leave the fragments half-archived. A move that fails
|
|
360
|
+
* mid-way reports exactly which files were archived and which remain, then
|
|
361
|
+
* exits 1 — a partially archived set must not be silently reported as done.
|
|
362
|
+
* `FS_*` is not needed for that; the paths are printed verbatim.
|
|
363
|
+
*/
|
|
364
|
+
function archiveFragments(version, fragments) {
|
|
365
|
+
if (fragments.length === 0) return true
|
|
366
|
+
const destination = join(ARCHIVE_DIR, version)
|
|
367
|
+
const moves = fragments.map((fragment) => ({
|
|
368
|
+
file: fragment.file,
|
|
369
|
+
from: join(UNRELEASED_DIR, fragment.file),
|
|
370
|
+
to: join(destination, fragment.file),
|
|
371
|
+
}))
|
|
372
|
+
|
|
373
|
+
const collisions = moves.filter((move) => existsSync(move.to)).map((move) => move.to)
|
|
374
|
+
if (collisions.length > 0) {
|
|
375
|
+
console.error(
|
|
376
|
+
`Error: refusing to overwrite archived release notes: ${collisions.join(', ')}. ` +
|
|
377
|
+
'Move the existing files away (or pick another version) and re-run.',
|
|
378
|
+
)
|
|
379
|
+
process.exit(1)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const fail = (error, archived) => {
|
|
383
|
+
const remaining = moves.slice(archived.length).map((candidate) => candidate.from)
|
|
384
|
+
console.error(`Error: cannot archive fragments: ${error.message}`)
|
|
385
|
+
console.error(
|
|
386
|
+
`Archived (${archived.length}): ${archived.length === 0 ? 'none' : archived.join(', ')}.`,
|
|
387
|
+
)
|
|
388
|
+
console.error(`Remaining (${remaining.length}): ${remaining.join(', ')}.`)
|
|
389
|
+
process.exit(1)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const archived = []
|
|
393
|
+
try {
|
|
394
|
+
mkdirSync(destination, { recursive: true })
|
|
395
|
+
} catch (error) {
|
|
396
|
+
fail(error, archived)
|
|
397
|
+
}
|
|
398
|
+
for (const move of moves) {
|
|
399
|
+
try {
|
|
400
|
+
renameSync(move.from, move.to)
|
|
401
|
+
} catch (error) {
|
|
402
|
+
fail(error, archived)
|
|
403
|
+
}
|
|
404
|
+
archived.push(move.file)
|
|
405
|
+
}
|
|
406
|
+
return true
|
|
239
407
|
}
|
|
240
408
|
|
|
241
|
-
|
|
409
|
+
function main() {
|
|
410
|
+
const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf8'))
|
|
411
|
+
const transportsPkg = JSON.parse(readFileSync(TRANSPORTS_PKG_PATH, 'utf8'))
|
|
412
|
+
|
|
413
|
+
const { versionArg, dryRun } = parseArgs(process.argv.slice(2))
|
|
242
414
|
|
|
243
|
-
|
|
415
|
+
const target = resolveTarget(versionArg, pkg.version, 'package.json')
|
|
416
|
+
const version = formatVersion(target)
|
|
417
|
+
|
|
418
|
+
// Validate the npm dist-tag derived by the release workflow. A prerelease
|
|
419
|
+
// identifier like "latest" would move the stable tag, and a numeric one like
|
|
420
|
+
// "1" is not a legal npm tag. Reject both before the release PR is opened.
|
|
421
|
+
assertPublishablePrerelease(target.prerelease)
|
|
422
|
+
|
|
423
|
+
// The transports package ships from the same version as the root: one input
|
|
424
|
+
// drives both package.json files, so the two can never drift.
|
|
425
|
+
const transportVersion = version
|
|
426
|
+
const transportTag = `${TRANSPORT_PACKAGE}@${transportVersion}`
|
|
427
|
+
|
|
428
|
+
// Reject already-released versions: the tag exists iff the version was
|
|
429
|
+
// released. Works unchanged for prerelease tags (v0.1.1-alpha.1 is a plain
|
|
430
|
+
// tag name). Run from REPO_ROOT so the check is cwd-independent.
|
|
431
|
+
if (tagExists(`v${version}`)) {
|
|
432
|
+
console.error(`Error: tag v${version} already exists — this version is already released.`)
|
|
433
|
+
process.exit(1)
|
|
434
|
+
}
|
|
435
|
+
if (tagExists(transportTag)) {
|
|
436
|
+
console.error(`Error: tag ${transportTag} already exists — this transport version is already released.`)
|
|
437
|
+
process.exit(1)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// The fragments are the only release-notes source, so a release whose
|
|
441
|
+
// rendered body is empty — no fragments at all, or only fragments with no
|
|
442
|
+
// bullet lines — would publish an empty `## [<version>]` section: refuse
|
|
443
|
+
// instead, on a dry run too (a dry run is release validation, not an
|
|
444
|
+
// exemption). Prose that looks like notes is not a bullet; it is reported
|
|
445
|
+
// below and never rendered.
|
|
446
|
+
const fragments = readFragments()
|
|
447
|
+
for (const fragment of fragments) {
|
|
448
|
+
if (fragment.dropped > 0) {
|
|
449
|
+
console.error(
|
|
450
|
+
`Notice: ${fragment.file}: ignored ${fragment.dropped} non-bullet line(s) — notes publish only "- " bullets.`,
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const refusal = notesRefusal(fragments)
|
|
455
|
+
if (refusal !== null) {
|
|
456
|
+
console.error(`Error: ${refusal}`)
|
|
457
|
+
process.exit(1)
|
|
458
|
+
}
|
|
459
|
+
const body = buildSectionBody(fragments)
|
|
460
|
+
|
|
461
|
+
// Whether this run actually moved the fragments into the archive, so a
|
|
462
|
+
// caller can tell "release prepared" from "nothing consumed" (dry run, or an
|
|
463
|
+
// existing section for this version) without guessing from the tree.
|
|
464
|
+
let archived = false
|
|
465
|
+
|
|
466
|
+
// Write only outside a dry run: a dry run validates and prints the resolved
|
|
467
|
+
// versions exactly like a real run, then leaves the working tree untouched.
|
|
468
|
+
if (!dryRun) {
|
|
469
|
+
pkg.version = version
|
|
470
|
+
// Preserve repo formatting: 2-space indent + trailing newline. Bumping only
|
|
471
|
+
// the version keeps `git diff package.json` to that one line.
|
|
472
|
+
writeFileSync(PKG_PATH, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8')
|
|
473
|
+
|
|
474
|
+
transportsPkg.version = transportVersion
|
|
475
|
+
writeFileSync(TRANSPORTS_PKG_PATH, `${JSON.stringify(transportsPkg, null, 2)}\n`, 'utf8')
|
|
476
|
+
|
|
477
|
+
// Root changelog only: the transports package has no generated changelog.
|
|
478
|
+
// Fragments are archived only when this run rendered them: an existing
|
|
479
|
+
// section (idempotent re-run) must not consume them unrendered.
|
|
480
|
+
if (updateChangelog(version, body)) {
|
|
481
|
+
archived = archiveFragments(version, fragments)
|
|
482
|
+
} else {
|
|
483
|
+
console.error(
|
|
484
|
+
`Notice: CHANGELOG.md already has a "## [${version}]" section — fragments left in .changes/unreleased/: ${fragments.map((fragment) => fragment.file).join(', ')}.`,
|
|
485
|
+
)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// The workflow captures VERSION; the remaining lines are informational and
|
|
490
|
+
// let a dry run show both derived tags without touching the tree.
|
|
491
|
+
if (dryRun) console.log('DRY_RUN=1')
|
|
492
|
+
console.log(`FRAGMENTS=${fragments.length}`)
|
|
493
|
+
console.log(`VERSION=${version}`)
|
|
494
|
+
console.log(`TAG=v${version}`)
|
|
495
|
+
console.log(`TRANSPORT_VERSION=${transportVersion}`)
|
|
496
|
+
console.log(`TRANSPORT_TAG=${transportTag}`)
|
|
497
|
+
console.log(`ARCHIVED=${archived ? 1 : 0}`)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Whether this module is the entry point of the running process. Compared by
|
|
502
|
+
* real path, not by string: a symlinked checkout (macOS `/tmp` ->
|
|
503
|
+
* `/private/tmp`, a linked worktree, an npm bin shim) runs `argv[1]` through a
|
|
504
|
+
* different path than `import.meta.url`, and a string comparison would silently
|
|
505
|
+
* no-op the whole release flow. Importing the module (tests) stays a no-op.
|
|
506
|
+
*/
|
|
507
|
+
function isDirectExecution() {
|
|
508
|
+
if (process.argv[1] === undefined) return false
|
|
509
|
+
try {
|
|
510
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
|
|
511
|
+
} catch {
|
|
512
|
+
return false
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Run only when executed directly (node scripts/prepare-release.mjs):
|
|
517
|
+
// importing the module (tests) must not start the release flow.
|
|
518
|
+
if (isDirectExecution()) {
|
|
519
|
+
main()
|
|
520
|
+
}
|