pantheon-opencode 1.5.0-beta.2 → 1.5.0-beta.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,151 +1,156 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * version-check.mjs — Single source of truth enforcement
4
- *
5
- * package.json is the SOURCE OF TRUTH for the Pantheon version.
6
- * Every other manifest (pyproject.toml, plugin.json, src/plugins/tui/package.json)
7
- * MUST carry the same version. This script:
8
- *
9
- * - compares every manifest against package.json and prints a divergence
10
- * table when they differ (exit code 1),
11
- * - with --fix, rewrites each divergent manifest to the package.json version.
12
- *
13
- * Mirrors the MANIFEST_FILES list in scripts/versioning.mjs so `apply`
14
- * and `check` can never drift apart.
15
- *
16
- * Usage:
17
- * node scripts/version-check.mjs # verify (exit 0 = in sync)
18
- * node scripts/version-check.mjs --fix # sync all manifests
19
- */
2
+ /** Fail-closed version and lockfile validation. */
20
3
 
21
- import { readFileSync, writeFileSync } from 'node:fs'
4
+ import { readFileSync } from 'node:fs'
22
5
  import { join } from 'node:path'
23
- import { fileURLToPath, pathToFileURL } from 'node:url'
6
+ import { pathToFileURL } from 'node:url'
7
+ import {
8
+ LOCK_INVENTORY,
9
+ VERSION_MANIFEST_INVENTORY,
10
+ validateInventory,
11
+ writeInventoryVersion,
12
+ } from './manifest-inventory.mjs'
24
13
 
25
- /** Same set of manifests as versioning.mjs (minus package.json itself). */
26
- export const MANIFESTS = [
27
- { name: 'pyproject.toml', file: 'pyproject.toml', kind: 'toml' },
28
- { name: 'plugin.json', file: 'plugin.json', kind: 'json' },
29
- {
30
- name: 'src/plugins/tui/package.json',
31
- file: join('src', 'plugins', 'tui', 'package.json'),
32
- kind: 'json',
33
- },
34
- ]
35
-
36
- function readTomlVersion(filePath) {
37
- const text = readFileSync(filePath, 'utf-8')
38
- const match = text.match(/^version\s*=\s*"([^"]+)"/m)
39
- if (!match) throw new Error(`no version field in ${filePath}`)
40
- return match[1]
41
- }
14
+ // Kept as a public compatibility API. New code must use MANIFEST_INVENTORY.
15
+ export const MANIFESTS = Object.freeze(
16
+ ['pyproject.toml', 'plugin.json', 'src/plugins/tui/package.json'].map((file) => {
17
+ const entry = VERSION_MANIFEST_INVENTORY.find((item) => item.file === file)
18
+ return { name: file, file, kind: entry.kind }
19
+ }),
20
+ )
42
21
 
43
- function readJsonVersion(filePath) {
44
- return JSON.parse(readFileSync(filePath, 'utf-8')).version
22
+ function readLegacyVersion(root, manifest) {
23
+ const path = join(root, manifest.file)
24
+ const text = readFileSync(path, 'utf8')
25
+ if (manifest.kind === 'toml') {
26
+ const match = text.match(/^version\s*=\s*"([^"]+)"/m)
27
+ if (!match) throw new Error(`${manifest.file} is missing version`)
28
+ return match[1]
29
+ }
30
+ const data = JSON.parse(text)
31
+ if (typeof data.version !== 'string') throw new Error(`${manifest.file} is missing version`)
32
+ return data.version
45
33
  }
46
34
 
47
- function readManifestVersion(filePath, kind) {
48
- try {
49
- return kind === 'toml' ? readTomlVersion(filePath) : readJsonVersion(filePath)
50
- } catch {
51
- return null
52
- }
35
+ function readSourceVersion(root) {
36
+ const data = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
37
+ return typeof data.version === 'string' ? data.version : null
53
38
  }
54
39
 
55
- function writeManifestVersion(filePath, kind, version) {
56
- if (kind === 'toml') {
57
- const text = readFileSync(filePath, 'utf-8')
58
- const updated = text.replace(/^(version\s*=\s*")[^"]+(")/m, `$1${version}$2`)
59
- writeFileSync(filePath, updated)
60
- } else {
61
- const content = JSON.parse(readFileSync(filePath, 'utf-8'))
62
- content.version = version
63
- writeFileSync(filePath, `${JSON.stringify(content, null, 2)}\n`)
64
- }
40
+ function readLockVersion(root, entry) {
41
+ const data = JSON.parse(readFileSync(join(root, entry.file), 'utf8'))
42
+ return typeof data.version === 'string' ? data.version : null
65
43
  }
66
44
 
67
45
  /**
68
- * Compare every manifest version against package.json (source of truth).
69
- *
70
- * @param {string} root - repository root containing package.json + manifests
71
- * @returns {{source: string, manifests: Array<{name: string, version: string|null, ok: boolean}>, ok: boolean}}
72
- * - source: version read from package.json
73
- * - manifests: per-manifest {name, version (null when unreadable), ok}
74
- * - ok: true when every manifest matches the source version
46
+ * Compare versions while retaining the historical MANIFESTS result shape.
47
+ * Invalid/missing inventory entries are represented as null so callers can
48
+ * display a useful table; all CLI mutation paths use validateInventory first.
75
49
  */
76
50
  export function compareVersions(root) {
77
- const source = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')).version
78
- const manifests = MANIFESTS.map(({ name, file, kind }) => {
79
- const version = readManifestVersion(join(root, file), kind)
80
- return { name, version, ok: version === source }
51
+ let source = null
52
+ const errors = []
53
+ try {
54
+ source = readSourceVersion(root)
55
+ } catch (error) {
56
+ errors.push(`package.json: ${error.message}`)
57
+ }
58
+ const manifests = MANIFESTS.map((manifest) => {
59
+ let version = null
60
+ try {
61
+ version = readLegacyVersion(root, manifest)
62
+ } catch (error) {
63
+ errors.push(`${manifest.file}: ${error.message}`)
64
+ }
65
+ return { name: manifest.name, version, ok: version !== null && version === source }
81
66
  })
82
- return { source, manifests, ok: manifests.every((m) => m.ok) }
67
+ const locks = LOCK_INVENTORY.map((entry) => {
68
+ let version = null
69
+ try {
70
+ version = readLockVersion(root, entry)
71
+ } catch (error) {
72
+ errors.push(`${entry.file}: ${error.message}`)
73
+ }
74
+ return { name: entry.file, version, ok: version !== null && version === source }
75
+ })
76
+ try {
77
+ validateInventory(root)
78
+ } catch (error) {
79
+ errors.push(error.message)
80
+ }
81
+ return {
82
+ source,
83
+ manifests,
84
+ locks,
85
+ errors: [...new Set(errors)],
86
+ ok:
87
+ source !== null &&
88
+ manifests.every((item) => item.ok) &&
89
+ locks.every((item) => item.ok) &&
90
+ errors.length === 0,
91
+ }
83
92
  }
84
93
 
85
94
  /**
86
- * Rewrite every divergent manifest to the package.json version.
87
- *
88
- * @param {string} root - repository root
89
- * @returns {number} count of manifests rewritten
95
+ * Rewrite only version metadata after every inventory entry has passed the
96
+ * structural preflight. A second validation is performed by the CLI.
90
97
  */
91
98
  export function syncToSource(root) {
92
- const { source, manifests } = compareVersions(root)
93
- let changed = 0
94
- for (const manifest of manifests) {
95
- if (manifest.ok) continue
96
- const def = MANIFESTS.find((m) => m.name === manifest.name)
97
- writeManifestVersion(join(root, def.file), def.kind, source)
98
- changed += 1
99
- }
100
- return changed
99
+ const inventory = validateInventory(root, { allowVersionDivergence: true })
100
+ const source = inventory.sourceVersion
101
+ const changed = inventory.versions.filter(({ version }) => version !== source).length
102
+ const lockChanged = inventory.documents.filter(
103
+ ({ entry, data }) => entry.role === 'lock' && data.version !== source,
104
+ ).length
105
+ if (changed || lockChanged) writeInventoryVersion(inventory, source)
106
+ return changed + lockChanged
101
107
  }
102
108
 
103
109
  function printDivergenceTable(result) {
104
110
  const rows = [
105
111
  ['manifest', 'version', 'status'],
106
- ...result.manifests.map((m) => [m.name, m.version ?? 'MISSING', m.ok ? 'ok' : 'DIVERGENT']),
112
+ ...result.manifests.map((item) => [
113
+ item.name,
114
+ item.version ?? 'MISSING',
115
+ item.ok ? 'ok' : 'DIVERGENT',
116
+ ]),
117
+ ...result.locks.map((item) => [
118
+ item.name,
119
+ item.version ?? 'MISSING',
120
+ item.ok ? 'ok' : 'DIVERGENT',
121
+ ]),
107
122
  ]
108
- const widths = rows[0].map((_, i) => Math.max(...rows.map((row) => row[i].length)))
109
- console.log(`Source of truth: package.json → v${result.source}\n`)
110
- for (const row of rows) {
111
- console.log(row.map((cell, i) => cell.padEnd(widths[i])).join(' '))
112
- }
123
+ const widths = rows[0].map((_, index) => Math.max(...rows.map((row) => row[index].length)))
124
+ console.log(`Source of truth: package.json → v${result.source ?? 'INVALID'}\n`)
125
+ for (const row of rows)
126
+ console.log(row.map((cell, index) => cell.padEnd(widths[index])).join(' '))
127
+ for (const error of result.errors) console.error(`version-check: ${error}`)
113
128
  console.log('')
114
129
  }
115
130
 
116
131
  const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
117
132
 
118
133
  if (isDirectRun) {
119
- // npm scripts run from the package root, so the working directory is the
120
- // repository root to check. Explicit cwd keeps the CLI testable.
121
134
  const root = process.cwd()
122
135
  const fix = process.argv.includes('--fix')
123
- let result
124
- try {
125
- result = compareVersions(root)
126
- } catch (error) {
127
- console.error(`version-check: cannot read package.json — ${error.message}`)
128
- process.exit(1)
129
- }
130
-
136
+ const result = compareVersions(root)
131
137
  if (result.ok) {
132
- console.log(`✓ All manifests match package.json v${result.source}`)
138
+ console.log(`✓ All manifests match package.json v${result.source}; locks are in sync`)
133
139
  process.exit(0)
134
140
  }
135
-
136
141
  printDivergenceTable(result)
137
- if (fix) {
142
+ if (!fix) {
143
+ console.error('version-check: inventory is invalid or out of sync')
144
+ process.exit(1)
145
+ }
146
+ try {
138
147
  const changed = syncToSource(root)
139
148
  const after = compareVersions(root)
140
- if (after.ok) {
141
- console.log(`✓ Synced ${changed} manifest(s) to v${after.source}`)
142
- process.exit(0)
143
- }
144
- console.error(
145
- `version-check: sync incomplete — ${after.manifests.filter((m) => !m.ok).length} manifest(s) still divergent`,
146
- )
149
+ if (!after.ok) throw new Error(after.errors.join('; ') || 'inventory remains divergent')
150
+ console.log(`✓ Synced ${changed} metadata entries to v${after.source}`)
151
+ process.exit(0)
152
+ } catch (error) {
153
+ console.error(`version-check: refusing to write: ${error.message}`)
147
154
  process.exit(1)
148
155
  }
149
- console.error('version-check: manifests out of sync (run with --fix to sync)')
150
- process.exit(1)
151
156
  }
@@ -6,45 +6,44 @@
6
6
  * recommend Analyze commits and suggest next version bump type
7
7
  * apply [type] Bump manifests + move [Unreleased] → [vX.Y.Z] in CHANGELOG
8
8
  * type: patch | minor | major | auto (default: auto)
9
+ * --beta: bump the committed beta line instead
10
+ * beta Alias for `apply --beta`
9
11
  * changelog [ver] (Internal) Insert a versioned section into CHANGELOG
10
12
  * Normally called by `apply`; can be run standalone.
11
13
  * status Show current version, latest tag, and pending bump type
12
14
  *
13
- * Design: the release signal is "package.json version > latest git tag".
14
- * Developers (or AI agents) call `apply` to bump + update CHANGELOG, then push.
15
- * The auto-release workflow detects the version bump and creates the release.
16
- * No version bumping ever happens inside GitHub Actions.
15
+ * Design: the release signal is "package.json version > latest git tag" for
16
+ * stable, and "package.json version is a committed X.Y.Z-beta.N" for beta.
17
+ * Developers (or AI agents) call `apply` (or `apply --beta` / `beta`) to bump
18
+ * the manifests and promote CHANGELOG.md, then push. The dispatch-only release
19
+ * workflow reads the committed version and creates the release. No version
20
+ * bumping ever happens inside GitHub Actions.
17
21
  */
18
22
 
19
- import { execSync } from 'node:child_process'
20
- import { readFileSync, writeFileSync } from 'node:fs'
23
+ import { execFileSync } from 'node:child_process'
24
+ import { readFileSync } from 'node:fs'
21
25
  import { dirname, join } from 'node:path'
22
26
  import { fileURLToPath } from 'node:url'
27
+ import {
28
+ commitPreparedUpdates,
29
+ prepareInventoryVersion,
30
+ SEMVER_PATTERN,
31
+ validateInventory,
32
+ } from './manifest-inventory.mjs'
33
+ import { nextBetaVersion } from './release-beta-version.mjs'
23
34
  import { collectEntries, groupCommits, parseCommitLine, renderChangelog } from './release-notes.mjs'
24
35
 
25
36
  const __dirname = dirname(fileURLToPath(import.meta.url))
26
37
  const ROOT = join(__dirname, '..')
27
38
 
28
- const MANIFEST_FILES = [
29
- // 'platform/forge.json' removed in v1.0 — directory no longer exists
30
- 'pyproject.toml',
31
- 'package.json',
32
- 'plugin.json',
33
- 'src/plugins/tui/package.json',
34
- ]
35
-
36
39
  const CHANGELOG_PATH = join(ROOT, 'CHANGELOG.md')
37
40
 
38
41
  // ---------------------------------------------------------------------------
39
42
  // Git helpers
40
43
  // ---------------------------------------------------------------------------
41
44
 
42
- function run(cmd) {
43
- try {
44
- return execSync(cmd, { cwd: ROOT, encoding: 'utf-8' }).trim()
45
- } catch {
46
- return ''
47
- }
45
+ function runGit(args) {
46
+ return execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' }).trim()
48
47
  }
49
48
 
50
49
  function getLatestTag() {
@@ -52,12 +51,15 @@ function getLatestTag() {
52
51
  // The loose glob `v[0-9]*.[0-9]*.[0-9]*` ALSO matches v1.2.0-beta.9.*
53
52
  // (the trailing `*` swallows the -beta suffix), which would poison the
54
53
  // version gate — so filter strictly before sorting.
55
- const tag = run("git tag -l 'v*' | grep -E '^v[0-9]+\\.[0-9]+\\.[0-9]+$' | sort -V | tail -1")
56
- return tag || 'v0.0.0'
54
+ const tags = runGit(['tag', '-l', 'v*'])
55
+ .split('\n')
56
+ .filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag))
57
+ tags.sort((left, right) => compareStable(left.slice(1), right.slice(1)))
58
+ return tags.at(-1) || 'v0.0.0'
57
59
  }
58
60
 
59
61
  function getCurrentVersion() {
60
- return JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8')).version
62
+ return validateInventory(ROOT).sourceVersion
61
63
  }
62
64
 
63
65
  // ---------------------------------------------------------------------------
@@ -65,7 +67,10 @@ function getCurrentVersion() {
65
67
  // ---------------------------------------------------------------------------
66
68
 
67
69
  function bumpVersion(version, type) {
68
- const [major, minor, patch] = version.split('.').map(Number)
70
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(version)
71
+ if (!match) throw new Error(`cannot bump invalid semver: ${version}`)
72
+ if (!['major', 'minor', 'patch'].includes(type)) throw new Error(`invalid bump type: ${type}`)
73
+ const [major, minor, patch] = match.slice(1).map(Number)
69
74
  switch (type) {
70
75
  case 'major':
71
76
  return `${major + 1}.0.0`
@@ -76,8 +81,17 @@ function bumpVersion(version, type) {
76
81
  }
77
82
  }
78
83
 
84
+ function compareStable(left, right) {
85
+ const a = left.split('.').map(Number)
86
+ const b = right.split('.').map(Number)
87
+ for (let index = 0; index < 3; index += 1) {
88
+ if (a[index] !== b[index]) return a[index] - b[index]
89
+ }
90
+ return 0
91
+ }
92
+
79
93
  function analyzeConventionalCommits(since) {
80
- const log = run(`git log ${since}..HEAD --format="%s"`)
94
+ const log = runGit(['log', `${since}..HEAD`, '--format=%s'])
81
95
  if (!log) return 'patch'
82
96
  let bump = 'patch'
83
97
  for (const msg of log.split('\n').filter(Boolean)) {
@@ -91,27 +105,10 @@ function analyzeConventionalCommits(since) {
91
105
  // Manifest updater
92
106
  // ---------------------------------------------------------------------------
93
107
 
94
- function updateManifests(newVersion) {
95
- for (const file of MANIFEST_FILES) {
96
- const path = join(ROOT, file)
97
- try {
98
- const raw = readFileSync(path, 'utf-8')
99
- if (file.endsWith('.toml')) {
100
- // TOML: replace version = "X.Y.Z"
101
- const updated = raw.replace(/^(version\s*=\s*")[^"]+(")/m, `$1${newVersion}$2`)
102
- writeFileSync(path, updated)
103
- console.log(` ✓ ${file} → ${newVersion}`)
104
- } else {
105
- // JSON
106
- const content = JSON.parse(raw)
107
- content.version = newVersion
108
- writeFileSync(path, `${JSON.stringify(content, null, 2)}\n`)
109
- console.log(` ✓ ${file} → ${newVersion}`)
110
- }
111
- } catch {
112
- console.log(` ⚠ ${file} not found — skipped`)
113
- }
114
- }
108
+ function prepareManifests(newVersion) {
109
+ if (!SEMVER_PATTERN.test(newVersion)) throw new Error(`invalid version: ${newVersion}`)
110
+ const inventory = validateInventory(ROOT, { allowVersionDivergence: true })
111
+ return { inventory, updates: prepareInventoryVersion(inventory, newVersion) }
115
112
  }
116
113
 
117
114
  // ---------------------------------------------------------------------------
@@ -181,15 +178,17 @@ function stripEmptySections(body) {
181
178
  return keep.join('\n').replace(/\n{3,}/g, '\n\n')
182
179
  }
183
180
 
184
- function promoteUnreleased(newVersion, dateStr, notesBody = null) {
181
+ function prepareUnreleased(newVersion, dateStr, notesBody = null) {
182
+ if (!SEMVER_PATTERN.test(newVersion)) throw new Error(`invalid changelog version: ${newVersion}`)
183
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) throw new Error(`invalid changelog date: ${dateStr}`)
184
+ if (notesBody !== null && typeof notesBody !== 'string') {
185
+ throw new Error('generated release notes must be text')
186
+ }
185
187
  const content = readFileSync(CHANGELOG_PATH, 'utf-8')
186
188
 
187
189
  const unreleasedHeader = '## [Unreleased]'
188
190
  const idx = content.indexOf(unreleasedHeader)
189
- if (idx === -1) {
190
- console.log(' ⚠ [Unreleased] section not found in CHANGELOG — skipping')
191
- return false
192
- }
191
+ if (idx === -1) throw new Error('CHANGELOG.md is missing the [Unreleased] section')
193
192
 
194
193
  // Find the end of [Unreleased]: next ## header or end of file
195
194
  const afterHeader = idx + unreleasedHeader.length
@@ -211,15 +210,10 @@ function promoteUnreleased(newVersion, dateStr, notesBody = null) {
211
210
  .map((l) => l.trim())
212
211
  .filter((l) => l && !l.startsWith('###') && !l.startsWith('## '))
213
212
 
214
- if (notesBody === null && realLines.length === 0) {
215
- console.log(' ℹ [Unreleased] is empty — CHANGELOG not modified')
216
- return false
217
- }
213
+ if (notesBody === null && realLines.length === 0) return { changed: false, content }
218
214
 
219
215
  if (notesBody !== null && realLines.length > 0) {
220
- console.log(
221
- ' ⚠ --notes replaces the manual [Unreleased] content — review the diff before committing',
222
- )
216
+ console.log(' --notes replaces the manual [Unreleased] content')
223
217
  }
224
218
 
225
219
  // Strip lines that are just empty subsections (### X or ## 🆕/🐞/⚠️/✅
@@ -239,13 +233,32 @@ function promoteUnreleased(newVersion, dateStr, notesBody = null) {
239
233
 
240
234
  const updated = `${before + unreleasedHeader + newTemplate}\n\n${newVersionHeader}${writtenBody}${after}`
241
235
 
242
- writeFileSync(CHANGELOG_PATH, updated)
243
- console.log(
244
- notesBody !== null
245
- ? ` ✓ CHANGELOG: [Unreleased] → [v${newVersion}] (notes generated from commits)`
246
- : ` ✓ CHANGELOG: [Unreleased] → [v${newVersion}]`,
247
- )
248
- return true
236
+ if (!updated.includes('## [Unreleased]') || !updated.includes(newVersionHeader)) {
237
+ throw new Error('prepared CHANGELOG.md failed structural validation')
238
+ }
239
+ return { changed: true, content: updated, notesGenerated: notesBody !== null }
240
+ }
241
+
242
+ function contentHasUnreleased() {
243
+ return readFileSync(CHANGELOG_PATH, 'utf8').includes('## [Unreleased]')
244
+ }
245
+
246
+ function commitPreparedRelease(manifestPlan, changelogPlan) {
247
+ const updates = [...manifestPlan.updates]
248
+ if (changelogPlan.changed) {
249
+ updates.push({ path: CHANGELOG_PATH, content: changelogPlan.content })
250
+ }
251
+ commitPreparedUpdates(updates)
252
+ for (const document of manifestPlan.inventory.documents) {
253
+ console.log(` ✓ ${document.entry.file} → ${manifestPlan.version}`)
254
+ }
255
+ if (changelogPlan.changed) {
256
+ console.log(
257
+ changelogPlan.notesGenerated
258
+ ? ` ✓ CHANGELOG: [Unreleased] → [v${manifestPlan.version}] (notes generated from commits)`
259
+ : ` ✓ CHANGELOG: [Unreleased] → [v${manifestPlan.version}]`,
260
+ )
261
+ }
249
262
  }
250
263
 
251
264
  // ---------------------------------------------------------------------------
@@ -282,9 +295,31 @@ switch (command) {
282
295
  break
283
296
  }
284
297
 
298
+ case 'beta':
285
299
  case 'apply': {
286
300
  const args = process.argv.slice(3)
287
301
  const useNotes = args.includes('--notes')
302
+ const useBeta = command === 'beta' || args.includes('--beta')
303
+
304
+ // Beta: advance the committed X.Y.Z-beta.N line and promote the
305
+ // [Unreleased] CHANGELOG section into [vX.Y.Z-beta.N], exactly like the
306
+ // stable path. The committed manifest is the single source of truth — no
307
+ // npm lookup, no git lookup, and no runtime version computation.
308
+ if (useBeta) {
309
+ if (!contentHasUnreleased())
310
+ throw new Error('CHANGELOG.md is missing the [Unreleased] section')
311
+ const current = getCurrentVersion()
312
+ const newVersion = nextBetaVersion(current)
313
+ const date = new Date().toISOString().slice(0, 10)
314
+ console.log(`Bumping ${current} → ${newVersion} (beta)`)
315
+ const changelogPlan = prepareUnreleased(newVersion, date)
316
+ const manifestPlan = prepareManifests(newVersion)
317
+ commitPreparedRelease({ ...manifestPlan, version: newVersion }, changelogPlan)
318
+ console.log(`\nDone. Commit the version inventory and CHANGELOG as v${newVersion}.`)
319
+ console.log(`Tag v${newVersion} will be created by the release workflow after merge to main.`)
320
+ break
321
+ }
322
+
288
323
  const type = args.find((a) => !a.startsWith('--')) || 'auto'
289
324
  const latestTag = getLatestTag()
290
325
  const current = getCurrentVersion()
@@ -298,23 +333,21 @@ switch (command) {
298
333
  const since = latestTag === 'v0.0.0' ? null : latestTag
299
334
  const raw = collectEntries({ since, draft: since === null })
300
335
  const body = renderChangelog(groupCommits(raw.map(parseCommitLine)))
301
- if (!body) {
302
- console.log(' ⚠ --notes: no groupable commits — manual [Unreleased] content used')
303
- return null
304
- }
305
- console.log(` ℹ --notes: release notes generated from ${raw.length} commits`)
336
+ if (!body) return null
337
+ console.log(` --notes generated from ${raw.length} commits`)
306
338
  return body
307
339
  }
308
340
 
309
- // If package.json is already ahead of the latest tag, someone bumped
310
- // without tagging — warn and use the current version as-is. Tags are
311
- // workflow-owned, so there is nothing to create here.
341
+ if (!contentHasUnreleased()) throw new Error('CHANGELOG.md is missing the [Unreleased] section')
312
342
  if (current !== latestVer) {
313
- console.log(`⚠ package.json (${current}) already ahead of latest tag (${latestTag}).`)
314
- console.log(` Syncing all manifests to ${current} and promoting CHANGELOG.`)
315
- updateManifests(current)
343
+ console.log(
344
+ `package.json (${current}) is ahead of latest tag (${latestTag}); synchronizing inventory.`,
345
+ )
316
346
  const date = new Date().toISOString().slice(0, 10)
317
- promoteUnreleased(current, date, generateNotes())
347
+ const notesBody = generateNotes()
348
+ const changelogPlan = prepareUnreleased(current, date, notesBody)
349
+ const manifestPlan = prepareManifests(current)
350
+ commitPreparedRelease({ ...manifestPlan, version: current }, changelogPlan)
318
351
  console.log(`Tag v${current} will be created by the release workflow after merge to main.`)
319
352
  break
320
353
  }
@@ -324,13 +357,11 @@ switch (command) {
324
357
  const date = new Date().toISOString().slice(0, 10)
325
358
 
326
359
  console.log(`Bumping ${current} → ${newVersion} (${bumpType})`)
327
- updateManifests(newVersion)
328
- promoteUnreleased(newVersion, date, generateNotes())
329
- console.log(`\nDone. Commit with: git add -A && git commit -m "chore(release): v${newVersion}"`)
330
-
331
- // Tags are owned by the release workflow — never create them locally.
332
- // A local tag on a pre-merge commit drifts from the merged main state
333
- // (the untagged-v1.2.1 root cause) and would desync the version gate.
360
+ const notesBody = generateNotes()
361
+ const changelogPlan = prepareUnreleased(newVersion, date, notesBody)
362
+ const manifestPlan = prepareManifests(newVersion)
363
+ commitPreparedRelease({ ...manifestPlan, version: newVersion }, changelogPlan)
364
+ console.log(`\nDone. Commit the version inventory and CHANGELOG as v${newVersion}.`)
334
365
  console.log(`Tag v${newVersion} will be created by the release workflow after merge to main.`)
335
366
  break
336
367
  }
@@ -339,7 +370,11 @@ switch (command) {
339
370
  case 'changelog': {
340
371
  const version = arg || getCurrentVersion()
341
372
  const date = new Date().toISOString().slice(0, 10)
342
- promoteUnreleased(version, date)
373
+ const changelogPlan = prepareUnreleased(version, date)
374
+ if (changelogPlan.changed) {
375
+ commitPreparedUpdates([{ path: CHANGELOG_PATH, content: changelogPlan.content }])
376
+ console.log(` ✓ CHANGELOG: [Unreleased] → [v${version}]`)
377
+ }
343
378
  break
344
379
  }
345
380
 
@@ -353,11 +388,19 @@ Commands:
353
388
  type: patch | minor | major | auto (default: auto)
354
389
  --notes: pre-fill the promoted entry with release
355
390
  notes generated from conventional commits
391
+ --beta: advance the committed X.Y.Z-beta.N line and
392
+ promote [Unreleased] → [vX.Y.Z-beta.N] in CHANGELOG
393
+ beta Alias for 'apply --beta'
356
394
  changelog [version] Promote [Unreleased] → [vX.Y.Z] without bumping
357
395
 
358
- Release flow:
396
+ Release flow (stable):
359
397
  1. node scripts/versioning.mjs apply [minor]
360
- 2. git add -A && git commit -m "chore(release): vX.Y.Z"
361
- 3. git push ← CI passes → auto-release fires because pkg > tag
398
+ 2. Commit the version inventory and CHANGELOG.
399
+ 3. Push after review; CI and the dispatch-only release workflow create the tag.
400
+
401
+ Release flow (beta):
402
+ 1. node scripts/versioning.mjs apply --beta (or: node scripts/versioning.mjs beta)
403
+ 2. Commit the version inventory and CHANGELOG.
404
+ 3. Dispatch the release workflow with release_channel=beta.
362
405
  `)
363
406
  }