diff --git a/CHANGELOG.md b/CHANGELOG.md index 935f383..9e977fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ Details: [foreman changelog](plugins/foreman/CHANGELOG.md). - CI pins Claude Code 2.1.295, the minimum version the README requires (#151) - `/axis-new-branch` asks whether to start fixing the issue right after creating its branch (#174) +#### Fixed + +- `release.js prepare` restores its files and exits 1 when the release notes fail (#171) +- `context-budget.js --base` works on revisions from before the vcs reference files (#171) + ## [1.7.0] - 2026-10-17 ### Marketplace diff --git a/scripts/context-budget.js b/scripts/context-budget.js index 3cc43cd..4edc6bc 100644 --- a/scripts/context-budget.js +++ b/scripts/context-budget.js @@ -85,7 +85,8 @@ function gitReader(ref, repo = REPO) { function measure(r) { const size = (rel) => Buffer.byteLength(r.text(rel)); const vcs = ['vcs-git.md', 'vcs-tfvc.md', 'vcs-none.md'].map((f) => `reference/${f}`).filter(r.exists); - const largestVcs = vcs.reduce((a, b) => (size(a) >= size(b) ? a : b)); + // null on a base revision from before the vcs files (#171): nothing to add for them. + const largestVcs = vcs.reduce((a, b) => (a && size(a) >= size(b) ? a : b), null); const templates = r.list('templates'); // Every plugin file a text names: ${CLAUDE_PLUGIN_ROOT}/... paths, `vcs-.md` or @@ -101,10 +102,11 @@ function measure(r) { return out; } for (const m of body.matchAll(/\$\{CLAUDE_PLUGIN_ROOT\}\/([\w./<>-]+\.(?:md|json))/g)) { - if (m[1].includes('')) out.add(largestVcs); - else if (r.exists(m[1])) out.add(m[1]); + if (m[1].includes('')) { + if (largestVcs) out.add(largestVcs); + } else if (r.exists(m[1])) out.add(m[1]); } - if (/the vcs file/.test(body)) out.add(largestVcs); + if (largestVcs && /the vcs file/.test(body)) out.add(largestVcs); if (/template/i.test(body)) for (const t of templates) if (body.includes(`\`${t}\``)) out.add(`templates/${t}`); return out; } diff --git a/scripts/release.js b/scripts/release.js index 197ccc8..db7ee4a 100644 --- a/scripts/release.js +++ b/scripts/release.js @@ -12,7 +12,8 @@ // table, plugin README Version line), renames Unreleased to `[X.Y.Z] - date` in the // root and those plugins' changelogs (new empty Unreleased above), and sets the // root `### X.Y.Z` headings. Every plugin with Unreleased entries needs a -// version. All files or none; each keeps its line endings. +// version. All files or none (also when the release notes then fail); each keeps +// its line endings. // node scripts/release.js tag X.Y.Z // On a clean `main` equal to GitHub's, with no open milestone issues and valid // release notes: creates the annotated tag vX.Y.Z and pushes only that tag to the @@ -322,7 +323,9 @@ function status(arg) { return lines; } -function prepare(args) { +// Writes the release edits into `root`; on a write or release notes error, restores the files and throws. +function prepare(args, root = ROOT, notesOf = releaseNotes) { + const read = (rel) => fs.readFileSync(path.join(root, rel), 'utf8'); let date = today(); const versions = {}; const rest = []; @@ -335,20 +338,32 @@ function prepare(args) { } if (rest.length !== 1) throw new Error('usage: prepare X.Y.Z [=X.Y.Z ...] [--date YYYY-MM-DD]'); const version = rest[0]; - const result = prepareEdits(readRel, version, versions, date); + const result = prepareEdits(read, version, versions, date); if (result.error) throw new Error(result.error); const before = {}; + const restore = () => { + for (const [rel, text] of Object.entries(before)) fs.writeFileSync(path.join(root, rel), text); + }; try { for (const [rel, text] of Object.entries(result.files)) { - before[rel] = readRel(rel); - fs.writeFileSync(path.join(ROOT, rel), text); + before[rel] = read(rel); + fs.writeFileSync(path.join(root, rel), text); } } catch (e) { - for (const [rel, text] of Object.entries(before)) fs.writeFileSync(path.join(ROOT, rel), text); + restore(); throw e; } - const notes = releaseNotes(`v${version}`, ROOT); - return [...Object.keys(result.files).map((rel) => `changed: ${rel}`), notes.error ? `notes: ${notes.error}` : `notes: v${version} ok`]; + let notes; + try { + notes = notesOf(`v${version}`, root); + } catch (e) { + notes = { error: e.message }; + } + if (notes.error) { + restore(); + throw new Error(`notes: ${notes.error} (files restored)`); + } + return [...Object.keys(result.files).map((rel) => `changed: ${rel}`), `notes: v${version} ok`]; } function tag(version) { @@ -415,4 +430,4 @@ function main(argv) { if (require.main === module) process.exitCode = main(process.argv.slice(2)); -module.exports = { unreleasedLines, rootAreas, pluginTypes, bumpOf, bump, compare, milestoneVersion, kindOf, phaseOf, propose, releaseChangelog, setJsonVersion, setReadmeRow, setVersionLine, prepareEdits, pushRemote }; +module.exports = { unreleasedLines, rootAreas, pluginTypes, bumpOf, bump, compare, milestoneVersion, kindOf, phaseOf, propose, releaseChangelog, setJsonVersion, setReadmeRow, setVersionLine, prepareEdits, prepare, pushRemote }; diff --git a/tests/foreman/context-budget.test.js b/tests/foreman/context-budget.test.js index f2ffca0..917093b 100644 --- a/tests/foreman/context-budget.test.js +++ b/tests/foreman/context-budget.test.js @@ -52,6 +52,20 @@ test('report: only commands near budget or changed, with the changed files', () assert.deepStrictEqual(report(small, small), ['context budget: ok']); }); +test('measure works on a plugin without vcs files (a base revision from before them)', () => { + const files = { + 'commands/run.md': '---\ndescription: Run\n---\nRead `${CLAUDE_PLUGIN_ROOT}/reference/vcs-.md`, then the vcs file.\n', + 'templates/claude-md.md': 'block', + }; + const r = { + text: (rel) => files[rel], + exists: (rel) => rel in files, + list: (rel) => (rel === 'commands' ? ['run.md'] : []), + }; + const m = measure(r); + assert.deepStrictEqual(Object.keys(m.commands.run.files), ['commands/run.md']); +}); + test('the git reader on HEAD measures what the tree measures when nothing is changed', () => { // A throwaway clone of HEAD: the working tree there is HEAD, so both readers must agree. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'axis-context-budget-')); diff --git a/tests/release-script.test.js b/tests/release-script.test.js index a9c4088..33c847c 100644 --- a/tests/release-script.test.js +++ b/tests/release-script.test.js @@ -6,7 +6,7 @@ const test = require('node:test'); const assert = require('node:assert'); const fs = require('fs'); const path = require('path'); -const { unreleasedLines, rootAreas, pluginTypes, bumpOf, bump, compare, milestoneVersion, kindOf, phaseOf, propose, releaseChangelog, setJsonVersion, setReadmeRow, setVersionLine, prepareEdits, pushRemote } = require('../scripts/release.js'); +const { unreleasedLines, rootAreas, pluginTypes, bumpOf, bump, compare, milestoneVersion, kindOf, phaseOf, propose, releaseChangelog, setJsonVersion, setReadmeRow, setVersionLine, prepareEdits, prepare, pushRemote } = require('../scripts/release.js'); const ROOT_LOG = [ '# Changelog', @@ -164,6 +164,30 @@ test('prepareEdits: every file, or an error', () => { assert.deepStrictEqual(Object.keys(prepareEdits(noPluginEntries, '1.6.1', {}, '2026-10-13').files).sort(), ['.claude-plugin/marketplace.json', 'CHANGELOG.md', 'README.md']); }); +test('prepare: writes every file, or restores them all when the release notes fail', () => { + const os = require('os'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'axis-release-')); + const write = () => { + for (const [rel, text] of Object.entries(FILES)) { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), text); + } + }; + const snapshot = () => Object.fromEntries(Object.keys(FILES).map((rel) => [rel, fs.readFileSync(path.join(dir, rel), 'utf8')])); + try { + write(); + assert.throws(() => prepare(['1.7.0', 'foreman=1.6.1', '--date', '2026-10-13'], dir, () => ({ error: 'broken' })), /notes: broken \(files restored\)/); + assert.deepStrictEqual(snapshot(), FILES); + assert.throws(() => prepare(['1.7.0', 'foreman=1.6.1', '--date', '2026-10-13'], dir, () => { throw new Error('unreadable'); }), /notes: unreadable \(files restored\)/); + assert.deepStrictEqual(snapshot(), FILES); + const lines = prepare(['1.7.0', 'foreman=1.6.1', '--date', '2026-10-13'], dir); + assert.strictEqual(lines[lines.length - 1], 'notes: v1.7.0 ok'); + assert.match(fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'), /## \[1\.7\.0\] - 2026-10-13/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test('pushRemote: by Black-Axis/axis-hub URL, never by name', () => { const v = [ 'fork\thttps://github.com/someone/axis-hub.git (fetch)',