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.
- package/README.md +41 -15
- package/README.pt-BR.md +39 -12
- package/package.json +7 -3
- package/plugin.json +1 -1
- package/pyproject.toml +57 -0
- package/scripts/code_mode_server.py +101 -36
- package/scripts/install/opencode.mjs +6 -0
- package/scripts/install/plugin.mjs +3 -33
- package/scripts/manifest-inventory.mjs +410 -0
- package/scripts/mcp_persistence_server.py +516 -163
- package/scripts/package-evidence.mjs +242 -0
- package/scripts/release-beta-version.mjs +61 -60
- package/scripts/sync-tui.mjs +5 -27
- package/scripts/test-opencode-v1-v2-sandbox.sh +79 -161
- package/scripts/validation-policy.mjs +7 -7
- package/scripts/version-check.mjs +113 -108
- package/scripts/versioning.mjs +131 -88
- package/src/mcp/code_mode_server.py +101 -36
- package/src/mcp/mcp_persistence_server.py +516 -163
- package/src/pantheon/compaction-handoff.ts +304 -0
- package/src/pantheon/provider-capabilities.ts +221 -0
- package/src/pantheon/provider-stream-tracker.ts +182 -0
- package/src/plugins/tui/package-lock.json +2 -2
- package/src/plugins/tui/package.json +1 -1
|
@@ -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
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
22
5
|
import { join } from 'node:path'
|
|
23
|
-
import {
|
|
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
|
-
|
|
26
|
-
export const MANIFESTS =
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
44
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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((
|
|
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((_,
|
|
109
|
-
console.log(`Source of truth: package.json → v${result.source}\n`)
|
|
110
|
-
for (const row of rows)
|
|
111
|
-
console.log(row.map((cell,
|
|
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
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
}
|
package/scripts/versioning.mjs
CHANGED
|
@@ -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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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 {
|
|
20
|
-
import { readFileSync
|
|
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
|
|
43
|
-
|
|
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
|
|
56
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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.
|
|
361
|
-
3.
|
|
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
|
}
|