fix(release): name the running model in the catalog commit via --model
The catalog commit hardcoded "Co-Authored-By: Claude Opus 4.8 (1M context)" whatever model actually ran the release. A script cannot know its driver, so the running session now states it with --model "<name>"; without the flag the trailer reads "Claude" and names no model. A value with a newline or angle brackets is refused before any tag or write. Operator decision 2026-11-04. Tests first (7 red on assertions), suite 238/238. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0231c2fb6e
commit
e52805e317
3 changed files with 71 additions and 4 deletions
|
|
@ -91,6 +91,10 @@ their own Forgejo repositories under `https://git.fromaitochitta.com/open/`.
|
|||
consistent version, `check-versions.mjs` is green by construction. Never hand-edit a `ref` or a
|
||||
README label for a release — use this. Pure planner + label reconciler + pre-flight/write step
|
||||
covered by `scripts/release-plugin.test.mjs`.
|
||||
- **Pass `--model "<your model>"` with `--commit`** (operator decision 2026-11-04). The catalog
|
||||
commit's `Co-Authored-By` trailer names the model the running session states; without the flag
|
||||
it reads `Claude` and no model. It used to hardcode `Claude Opus 4.8 (1M context)` regardless
|
||||
of who ran it. A value with a newline or `<>` is refused before any tag or write.
|
||||
- **The Forgejo RELEASE OBJECT is the third part of a release (added 2026-09-18, order
|
||||
`20260917T235642Z-730962924-from-from-ai-to-chitta`).** A pushed git tag is filed by Forgejo
|
||||
under `/tags`; **only an explicit release object appears under `/releases`**. Until this was
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@
|
|||
// node scripts/release-plugin.mjs <name> --write # write the bumped catalog ref (no commit)
|
||||
// node scripts/release-plugin.mjs <name> --write --commit # + git commit the catalog
|
||||
// node scripts/release-plugin.mjs <name> --write --commit --push # + push
|
||||
// ... --commit --model "Opus 5.5" # name the model running the release
|
||||
// # in the commit trailer (else: "Claude" only)
|
||||
//
|
||||
// PUSHING NEEDS THE SAME ONE-SHOT TOKEN AS pre-push-gate.sh. That hook matches `git
|
||||
// push` in command text and cannot see a push this script issues via execFileSync
|
||||
|
|
@ -665,8 +667,24 @@ function observePlugin(catalogDir, name) {
|
|||
return { repoDir, pluginVersion, readmeBadge, tags: gitTags(repoDir) };
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { name: null, version: null, write: false, commit: false, push: false, createTag: false };
|
||||
// The catalog commit's attribution trailer (operator decision 2026-11-04). It used to
|
||||
// hardcode `Claude Opus 4.8 (1M context)` whatever model actually ran the release — a
|
||||
// claim nobody made, carried by every release commit since. A script cannot know which
|
||||
// model drives it, so the running session states it with `--model "<name>"`; without
|
||||
// it the trailer names Claude and no model: incomplete, never wrong. A value that would
|
||||
// break the one-line trailer (a newline smuggling in a second trailer, or angle
|
||||
// brackets that would forge the address part) is refused rather than written.
|
||||
export function coAuthorTrailer(model) {
|
||||
const raw = typeof model === 'string' ? model.trim() : '';
|
||||
if (/[\r\n<>]/.test(raw)) {
|
||||
throw new Error(`--model must be a plain model name on one line (e.g. "Opus 5.5"), got ${JSON.stringify(model)}`);
|
||||
}
|
||||
const name = raw.replace(/^claude\s+/i, '').trim();
|
||||
return name ? `Co-Authored-By: Claude ${name} <noreply@anthropic.com>` : 'Co-Authored-By: Claude <noreply@anthropic.com>';
|
||||
}
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const out = { name: null, version: null, write: false, commit: false, push: false, createTag: false, model: null };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--version') out.version = argv[++i];
|
||||
|
|
@ -674,6 +692,7 @@ function parseArgs(argv) {
|
|||
else if (a === '--commit') out.commit = true;
|
||||
else if (a === '--push') out.push = true;
|
||||
else if (a === '--create-tag') out.createTag = true;
|
||||
else if (a === '--model') out.model = argv[++i] ?? null;
|
||||
else if (!a.startsWith('--') && out.name === null) out.name = a;
|
||||
}
|
||||
return out;
|
||||
|
|
@ -687,6 +706,12 @@ export function runRelease({ args, catalogDir, mktPath, marketplace, pushGate, r
|
|||
const checkVersionsRunner = runCheckVersions
|
||||
|| (() => execFileSync('node', [join(catalogDir, 'scripts', 'check-versions.mjs')], { cwd: catalogDir, encoding: 'utf8' }));
|
||||
|
||||
// Validate the attribution BEFORE anything touches a remote: a bad --model found only
|
||||
// at commit time would arrive after --create-tag had already pushed the plugin tag.
|
||||
let trailer;
|
||||
try { trailer = coAuthorTrailer(args.model); }
|
||||
catch (e) { console.error(`release-plugin: ${e.message}`); return 1; }
|
||||
|
||||
let obs = observePlugin(catalogDir, args.name);
|
||||
const target = normalizeVersion(args.version ?? obs.pluginVersion ?? '');
|
||||
const readmePath = join(catalogDir, 'README.md');
|
||||
|
|
@ -757,7 +782,8 @@ export function runRelease({ args, catalogDir, mktPath, marketplace, pushGate, r
|
|||
if (args.commit) {
|
||||
const subject = plan.commitSubject ?? `chore(catalog): release ${plan.name} ${plan.newRef}`;
|
||||
const body = `${plan.name} ${plan.newRef} — release. Catalog ref now pins the ${plan.newRef} tag so \`claude plugin update\` resolves the release.`;
|
||||
const msg = `${subject}\n\n${body}\n\nCo-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>\n`;
|
||||
const msg = `${subject}\n\n${body}\n\n${trailer}\n`;
|
||||
if (!args.model) console.log(' (no --model given: the commit credits Claude without naming a model)');
|
||||
execFileSync('git', ['-C', catalogDir, 'add', '.claude-plugin/marketplace.json', 'README.md'], { stdio: 'inherit' });
|
||||
execFileSync('git', ['-C', catalogDir, 'commit', '-m', msg], { stdio: 'inherit' });
|
||||
console.log(' ✓ committed the catalog');
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
runRelease, preflightStatMismatches, reportPostWriteCheck,
|
||||
parseForgejoRepo, planForgejoRelease, ensureForgejoRelease,
|
||||
extractChangelogSection, releaseBodyFrom, pendingCatalogChanges,
|
||||
coAuthorTrailer, parseArgs,
|
||||
} from './release-plugin.mjs';
|
||||
import { classifyPlugin } from './check-versions.mjs';
|
||||
|
||||
|
|
@ -701,12 +702,15 @@ test('BUG 2 (order 20260922T185613Z): --write --commit after a prior --write-onl
|
|||
// Invocation 2 is a FRESH process re-reading marketplace.json off disk (already v1.1.0).
|
||||
const marketplaceOnDisk = JSON.parse(fsReadFileSync(mktPath, 'utf8'));
|
||||
const code2 = runRelease({
|
||||
args: { name: 'demo-plugin', version: undefined, createTag: false, write: true, commit: true, push: false },
|
||||
args: { name: 'demo-plugin', version: undefined, createTag: false, write: true, commit: true, push: false, model: 'Opus 5.5' },
|
||||
catalogDir, mktPath, marketplace: marketplaceOnDisk, pushGate,
|
||||
runCheckVersions: () => '1 plugins — 1 OK, 0 WARN, 0 ERROR, 0 SKIP — verified 1/1\n',
|
||||
});
|
||||
|
||||
assert.equal(code2, 0, 'finishing a pending release must succeed');
|
||||
const body = execFileSync('git', ['-C', catalogDir, 'log', '-1', '--pretty=%B'], { encoding: 'utf8' });
|
||||
assert.match(body, /^Co-Authored-By: Claude Opus 5\.5 <noreply@anthropic\.com>$/m, 'the commit names the model the running session passed with --model');
|
||||
assert.doesNotMatch(body, /4\.8/, 'the stale hardcoded model must be gone');
|
||||
const statusAfter2 = execFileSync('git', ['-C', catalogDir, 'status', '--porcelain'], { encoding: 'utf8' });
|
||||
assert.equal(statusAfter2.trim(), '', 'BUG 2: --write --commit after --create-tag --write must actually commit the pending write');
|
||||
const log = execFileSync('git', ['-C', catalogDir, 'log', '-1', '--pretty=%s'], { encoding: 'utf8' }).trim();
|
||||
|
|
@ -764,6 +768,8 @@ test('BUG 3 (order 20260922T185613Z): --write --commit --push after a prior --wr
|
|||
assert.equal(code2, 0, 'finishing a pending release (commit+push) must succeed');
|
||||
const originLog = execFileSync('git', ['--git-dir', bare, 'log', '-1', '--pretty=%s', 'refs/heads/main'], { encoding: 'utf8' }).trim();
|
||||
assert.match(originLog, /demo-plugin/, 'BUG 3: NOOP must not skip the push of a pending catalog commit');
|
||||
const originBody = execFileSync('git', ['--git-dir', bare, 'log', '-1', '--pretty=%B', 'refs/heads/main'], { encoding: 'utf8' });
|
||||
assert.match(originBody, /^Co-Authored-By: Claude <noreply@anthropic\.com>$/m, 'no --model: the trailer names Claude without inventing a model');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -1395,3 +1401,34 @@ test('releaseBodyFrom: a MECHANICAL tag message loses to the CHANGELOG — that
|
|||
const r = releaseBodyFrom({ changelogText: CL_BRACKET, tag: 'v8.0.0', tagMessage: 'llm-security v8.0.0' });
|
||||
assert.notEqual(r.body, 'llm-security v8.0.0');
|
||||
});
|
||||
|
||||
// --- Commit attribution (operator decision 2026-11-04) ----------------------
|
||||
// The catalog commit used to hardcode `Claude Opus 4.8 (1M context)` whatever model
|
||||
// actually ran the release. A script cannot know that, so the running session says it
|
||||
// with --model; without it the trailer names Claude and no model — never a wrong one.
|
||||
|
||||
test('coAuthorTrailer: a given model is named in the trailer', () => {
|
||||
assert.equal(coAuthorTrailer('Opus 5.5'), 'Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>');
|
||||
});
|
||||
|
||||
test('coAuthorTrailer: a leading "Claude " in the model is not doubled', () => {
|
||||
assert.equal(coAuthorTrailer('Claude Opus 5.5'), 'Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>');
|
||||
assert.equal(coAuthorTrailer(' claude Sonnet 5.5 '), 'Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>');
|
||||
});
|
||||
|
||||
test('coAuthorTrailer: no model (absent, empty, blank) names Claude alone', () => {
|
||||
for (const m of [undefined, null, '', ' ']) {
|
||||
assert.equal(coAuthorTrailer(m), 'Co-Authored-By: Claude <noreply@anthropic.com>', `model=${JSON.stringify(m)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('coAuthorTrailer: a model that would break the trailer line is refused, not written', () => {
|
||||
assert.throws(() => coAuthorTrailer('Opus 5.5\nSigned-off-by: someone'), /--model/);
|
||||
assert.throws(() => coAuthorTrailer('Opus <x@y>'), /--model/);
|
||||
});
|
||||
|
||||
test('parseArgs: --model takes the next argument; absent means null', () => {
|
||||
assert.equal(parseArgs(['voyage', '--model', 'Opus 5.5', '--write']).model, 'Opus 5.5');
|
||||
assert.equal(parseArgs(['voyage', '--write']).model, null);
|
||||
assert.equal(parseArgs(['voyage', '--write']).name, 'voyage');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue