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.
@@ -1,36 +1,69 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Release-prep version helper (plan dsh-keys-manager-ci-release, task 2 + 4):
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), aborts
7
- * when there are no commits since the previous release tag (nothing to
8
- * release), bumps package.json, inserts the `## [<version>]` section into
9
- * CHANGELOG.md (from the same git-log notes the workflows use; keeps
10
- * existing sections, no-op when already present), and prints
11
- * `VERSION=<version>` for the workflow to capture. Node built-ins only, no
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 # auto patch bump
16
- * node scripts/prepare-release.mjs 0.2.0 # explicit version
17
- * node scripts/prepare-release.mjs 0.1.1-alpha.1 # explicit prerelease
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 VERSION; 1 = rejected (unparseable
20
- * version, existing tag, no commits since the previous release tag,
21
- * unparseable current package.json version, or CHANGELOG.md write error).
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 { readFileSync, writeFileSync } from 'node:fs'
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. Generated from git log by the release-prep workflow.\n'
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
- const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf8'))
57
-
58
- let target
59
- if (process.argv[2] !== undefined) {
60
- target = parseVersion(process.argv[2])
61
- if (target === null) {
62
- console.error(`Error: invalid version "${process.argv[2]}". Expected X.Y.Z or X.Y.Z-prerelease (e.g. 0.2.0, 0.1.1-alpha.1).`)
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
- } else {
66
- // Auto patch bump (mirrors sibling dsh-llm-fallbacks autoBumpPatch): parse
67
- // the current version as X.Y.Z or X.Y.Z-pre, then:
68
- // - formal version X.Y.Z -> X.Y.(Z+1) (0.1.2 -> 0.1.3)
69
- // - prerelease with numeric tail -> bump only the tail, staying on the
70
- // prerelease line (0.1.3-alpha.3 -> 0.1.3-alpha.4)
71
- // - prerelease with non-numeric tail (e.g. 0.1.0-alpha) -> cannot auto
72
- // bump; require an explicit version argument.
73
- // The looser regex is deliberate: parseVersion (explicit args) requires a
74
- // digit in the suffix, so it cannot tell "alpha" from a malformed version;
75
- // this path needs the raw suffix to reject non-numeric tails with the
76
- // explicit-version hint.
77
- const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(pkg.version)
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
- const [, major, minor, patch, pre] = m
83
- if (pre === undefined) {
84
- target = { major: Number(major), minor: Number(minor), patch: Number(patch) + 1 }
85
- } else {
86
- const tail = pre.split('.').at(-1)
87
- if (tail === undefined || !/^\d+$/.test(tail)) {
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: cannot auto-bump "${pkg.version}": prerelease tail "${pre}" is not numeric. ` +
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
- const prefix = pre.slice(0, pre.length - tail.length) // "alpha." for "alpha.1"
95
- target = {
96
- major: Number(major),
97
- minor: Number(minor),
98
- patch: Number(patch),
99
- prerelease: `${prefix}${Number(tail) + 1}`,
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
- const version =
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
- // Validate the npm dist-tag derived by the release workflow. A prerelease
110
- // identifier like "latest" would move the stable tag, and a numeric one like
111
- // "1" is not a legal npm tag. Reject both before the release PR is opened.
112
- if (target.prerelease !== undefined) {
113
- const distTag = target.prerelease.split('.')[0]
114
- if (distTag === 'latest' || !/^[a-z][a-z0-9-]*$/.test(distTag)) {
115
- console.error(
116
- `Error: prerelease "${target.prerelease}" maps to invalid npm dist-tag "${distTag}". ` +
117
- 'Use a prerelease such as alpha.1, beta.1, or rc.1.',
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
- // Reject already-released versions: `git rev-parse v<version>` succeeds iff
124
- // the tag exists. Works unchanged for prerelease tags (v0.1.1-alpha.1 is a
125
- // plain tag name). Run from REPO_ROOT so the check is cwd-independent.
126
- try {
127
- execFileSync('git', ['rev-parse', `v${version}`], { cwd: REPO_ROOT, stdio: 'ignore' })
128
- console.error(`Error: tag v${version} already exists — this version is already released.`)
129
- process.exit(1)
130
- } catch {
131
- // Tag absent → OK to prepare.
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
- // Fail fast on a no-change release: an empty notes range means no commits
135
- // since the previous release tag — prepare-release must not bump the version
136
- // or touch CHANGELOG.md for an empty release (no empty release PR). First-
137
- // ever releases (no ancestor tag yet) span the full first-parent history,
138
- // which is non-empty by construction, so they still pass.
139
- const { prevTag, notes } = resolveReleaseNotes()
140
- if (notes === '') {
141
- console.error(
142
- `Error: no commits since previous release tag ${prevTag === '' ? '<first commit>' : prevTag} — nothing to release. Aborting.`,
143
- )
144
- process.exit(1)
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
- pkg.version = version
148
- // Preserve repo formatting: 2-space indent + trailing newline. The version
149
- // line must be the only diff (verified by `git diff package.json` in the
150
- // dry-run validation).
151
- writeFileSync(PKG_PATH, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8')
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
- * Resolve the previous release tag (nearest ancestor tag of HEAD; empty
155
- * when none exists yet) and the first-parent commit subjects since that
156
- * tag (full first-parent history when no tag exists). Same git-log snippet
157
- * the workflows use for the PR body — the CHANGELOG section and the PR body
158
- * stay consistent. Do NOT use "v$VERSION^": the version string is not a
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 resolveReleaseNotes() {
162
- let prevTag = ''
163
- try {
164
- prevTag = execFileSync('git', ['describe', '--tags', '--abbrev=0', 'HEAD'], {
165
- cwd: REPO_ROOT,
166
- encoding: 'utf8',
167
- stdio: ['ignore', 'pipe', 'ignore'],
168
- }).trim()
169
- } catch {
170
- // No ancestor tag yet → full history.
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
- const range = prevTag === '' ? 'HEAD' : `${prevTag}..HEAD`
173
- const notes = execFileSync('git', ['log', '--oneline', '--first-parent', range], {
174
- cwd: REPO_ROOT,
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
- * Insert a `## [<version>] - <YYYY-MM-DD>` section under the `# Changelog`
183
- * header, above the existing sections, from the pre-collected git-log notes
184
- * (guaranteed non-empty by the no-change guard, so no fallback text is ever
185
- * written). Preserves all existing sections; no-op when the section for this
186
- * version already exists (idempotent re-runs must not duplicate it); write
187
- * failures exit 1.
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 updateChangelog(version, notes) {
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
- updateChangelog(version, notes)
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
- console.log(`VERSION=${version}`)
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
+ }