Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions scripts/context-budget.js
Original file line number Diff line number Diff line change
Expand Up @@ -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-<value>.md` or
Expand All @@ -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('<value>')) out.add(largestVcs);
else if (r.exists(m[1])) out.add(m[1]);
if (m[1].includes('<value>')) {
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;
}
Expand Down
33 changes: 24 additions & 9 deletions scripts/release.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 `### <plugin> 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
Expand Down Expand Up @@ -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 = [];
Expand All @@ -335,20 +338,32 @@ function prepare(args) {
}
if (rest.length !== 1) throw new Error('usage: prepare X.Y.Z [<plugin>=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) {
Expand Down Expand Up @@ -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 };
14 changes: 14 additions & 0 deletions tests/foreman/context-budget.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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-<value>.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-'));
Expand Down
26 changes: 25 additions & 1 deletion tests/release-script.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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)',
Expand Down
Loading