feat(release): file the Forgejo release object as part of a release

A pushed git tag is filed by Forgejo under /tags; only an explicit release
object appears under /releases. release-plugin.mjs only ever made a tag, so
every plugin's public releases page sat a version behind the ref the catalog
pinned -- llm-security showed v7.8.3 against a v8.0.0 tag.

Measured 2026-09-18 against the instance API: 24 repos in org `open`, 21 with
at least one tag, 11 of those 21 with no release object for their newest tag.
That reproduces the order's own independently-measured list exactly.

- parseForgejoRepo / planForgejoRelease / ensureForgejoRelease: pure, tested.
  The release body is the tag's own message VERBATIM or empty -- never
  generated prose. Read via %(contents:subject)+%(contents:body), never
  %(contents), which drags the SSH signature block into the notes.
- The step fires only on a run that PUBLISHES (--create-tag --write, or
  --push): filing a release object is itself a publish and must not ride
  along on a local --write past the operator's one-shot push token.
- Synchronous (curl via execFileSync), like check-versions.mjs's
  checkHomepage: runRelease is called without an await and its return value
  becomes the exit code, so an async step would let a rejected POST surface
  after the run had already exited 0 and called the release complete.
- 429 and the 502/503/504 family are retried with backoff, never swallowed.
  An unthrottled sweep drew 17 HTTP 429s and the first version of that sweep
  read every one as an empty list -- "verified nothing" was indistinguishable
  from "verified everything, all clean".
- The token reaches curl through a 0600 header file, never argv.

scripts/backfill-forgejo-releases.mjs covers the backlog and retries the one
step, reusing the same planner and API shell so the two cannot drift. Only
the newest tag is considered. Documented exception: ktg-plugin-marketplace
pre-polyrepo-archive, an archive marker, not a release; the register is keyed
by repo AND tag so that repo's next real release is still backfilled.

Tests written red first: 20 new (12 release path, 8 backfill), and the two
real-git integration tests were probed known-negative -- breaking the wiring
turns 68/0 into 66/2. Suite 193/193; check-versions 12/12 OK.

The backfill of the 10 outstanding release objects is NOT done: it was denied
in-session as a public-surface write and is the operator's call.

Order: 20260917T235642Z-730962924-from-from-ai-to-chitta

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-18 02:23:52 +02:00
commit ee2259f63f
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
5 changed files with 615 additions and 3 deletions

View file

@ -12,6 +12,7 @@ import {
planRelease, reconcileReadmeLabel, preflightErrors, applyRelease, shouldCreateTag,
pushAuthorisation, requirePushAuthorisation, pushWithToken, consumeToken, createPushGate,
runRelease, preflightStatMismatches, reportPostWriteCheck,
parseForgejoRepo, planForgejoRelease, ensureForgejoRelease,
} from './release-plugin.mjs';
import { classifyPlugin } from './check-versions.mjs';
@ -915,3 +916,187 @@ test('D2 (Q3e, real git): a post-write check-versions failure reports precisely
rmSync(root, { recursive: true, force: true });
}
});
// --- Forgejo release object (order 20260917T235642Z-730962924-from-from-ai-to-chitta) ---
//
// Measured 2026-09-18 against the instance's own API: 11 of the 21 tagged repos in org
// `open` had NO release object for their newest tag, because this helper only ever made a
// git TAG. Forgejo files a pushed tag under /tags; only an explicit release object appears
// under /releases. So a "released" plugin could show v7.8.3 on its releases page while
// v8.0.0 was the tag the catalog pinned — the same tag-vs-published drift the catalog-ref
// bump exists to prevent, one surface further out.
test('parseForgejoRepo pulls owner/repo out of a Forgejo repo URL', () => {
assert.deepEqual(
parseForgejoRepo('https://git.fromaitochitta.com/open/llm-security'),
{ owner: 'open', repo: 'llm-security' },
);
});
test('parseForgejoRepo tolerates a .git suffix and a trailing slash', () => {
assert.deepEqual(parseForgejoRepo('https://git.fromaitochitta.com/open/repo-mailbox.git'), { owner: 'open', repo: 'repo-mailbox' });
assert.deepEqual(parseForgejoRepo('https://git.fromaitochitta.com/open/voyage/'), { owner: 'open', repo: 'voyage' });
});
test('parseForgejoRepo returns null for a URL it cannot read — it never guesses an owner', () => {
assert.equal(parseForgejoRepo('x'), null);
assert.equal(parseForgejoRepo('https://git.fromaitochitta.com/open'), null);
assert.equal(parseForgejoRepo(null), null);
assert.equal(parseForgejoRepo(undefined), null);
});
test('planForgejoRelease: CREATE when no release object exists for the tag', () => {
const p = planForgejoRelease({
url: 'https://git.fromaitochitta.com/open/llm-security',
tag: 'v8.0.0',
releaseTags: ['v7.8.3'],
tagMessage: 'llm-security v8.0.0',
});
assert.equal(p.verdict, 'CREATE');
assert.equal(p.owner, 'open');
assert.equal(p.repo, 'llm-security');
assert.equal(p.tag, 'v8.0.0');
assert.equal(p.name, 'v8.0.0');
});
test('planForgejoRelease: NOOP when a release object for the tag already exists (idempotent re-run)', () => {
const p = planForgejoRelease({
url: 'https://git.fromaitochitta.com/open/llm-security',
tag: 'v8.0.0',
releaseTags: ['v8.0.0', 'v7.8.3'],
tagMessage: 'llm-security v8.0.0',
});
assert.equal(p.verdict, 'NOOP');
});
test('planForgejoRelease: BLOCKED when the source url cannot be parsed', () => {
const p = planForgejoRelease({ url: 'x', tag: 'v1.0.0', releaseTags: [], tagMessage: 'm' });
assert.equal(p.verdict, 'BLOCKED');
assert.match(p.reason, /url/);
});
test('planForgejoRelease: BLOCKED when there is no tag to release', () => {
const p = planForgejoRelease({ url: 'https://git.fromaitochitta.com/open/x', tag: null, releaseTags: [], tagMessage: '' });
assert.equal(p.verdict, 'BLOCKED');
});
test('planForgejoRelease: the body is the tag message VERBATIM — no invented release notes', () => {
const msg = '0.10.0 — a bundle carries the images its sources declare\n\nFive readers place them.';
const p = planForgejoRelease({
url: 'https://git.fromaitochitta.com/open/llm-ingestion-okf', tag: 'v0.10.0', releaseTags: [], tagMessage: msg,
});
assert.equal(p.body, msg, 'the tag message is the release text; the helper must not write prose of its own');
});
test('planForgejoRelease: an empty tag message yields an EMPTY body, never invented prose', () => {
const p = planForgejoRelease({
url: 'https://git.fromaitochitta.com/open/x', tag: 'v1.0.0', releaseTags: [], tagMessage: '',
});
assert.equal(p.verdict, 'CREATE');
assert.equal(p.body, '');
});
test('ensureForgejoRelease: CREATE posts exactly once with tag_name/name/body', () => {
const calls = [];
const api = { createRelease: (owner, repo, payload) => { calls.push({ owner, repo, payload }); return { html_url: 'https://h/r' }; } };
const r = ensureForgejoRelease(
{ verdict: 'CREATE', owner: 'open', repo: 'llm-security', tag: 'v8.0.0', name: 'v8.0.0', body: 'llm-security v8.0.0' },
api,
);
assert.equal(r.created, true);
assert.equal(calls.length, 1);
assert.deepEqual(calls[0], {
owner: 'open', repo: 'llm-security',
payload: { tag_name: 'v8.0.0', name: 'v8.0.0', body: 'llm-security v8.0.0' },
});
assert.equal(r.url, 'https://h/r');
});
test('ensureForgejoRelease: NOOP and BLOCKED never call the API', () => {
const api = { createRelease: () => { throw new Error('BUG: must not post'); } };
assert.equal(ensureForgejoRelease({ verdict: 'NOOP', reason: 'exists' }, api).created, false);
assert.equal(ensureForgejoRelease({ verdict: 'BLOCKED', reason: 'bad url' }, api).created, false);
});
test('R-FJ1 (real git): a publishing run files the Forgejo release object for the tag it just pushed', () => {
const root = makeTempRoot('release-plugin-fj1-');
try {
const bare = join(root, 'origin.git');
execFileSync('git', ['init', '-q', '--bare', bare]);
const repoDir = join(root, 'demo-plugin');
const catalogDir = join(root, 'catalog');
mkdirSync(join(catalogDir, '.claude-plugin'), { recursive: true });
initPluginRepo(repoDir, { version: '1.1.0', remote: bare });
execFileSync('git', ['-C', repoDir, 'tag', '-a', 'v1.0.0', '-m', 'v1.0.0']);
execFileSync('git', ['-C', repoDir, 'push', '-q', 'origin', 'v1.0.0']);
const mktPath = join(catalogDir, '.claude-plugin', 'marketplace.json');
const url = 'https://git.fromaitochitta.com/open/demo-plugin';
const marketplace = { plugins: [{ name: 'demo-plugin', source: { source: 'url', url, ref: 'v1.0.0' }, description: 'd' }] };
fsWriteFileSync(mktPath, JSON.stringify(marketplace, null, 2));
fsWriteFileSync(join(catalogDir, 'README.md'), '### [Demo Plugin](https://x/open/demo-plugin) `v1.0.0`\n');
const pushGate = createPushGate({ cwd: catalogDir, home: root, exists: () => true, unlink: () => {} });
const created = [];
const forgejo = {
listReleaseTags: () => ['v1.0.0'],
createRelease: (owner, repo, payload) => { created.push({ owner, repo, payload }); return { html_url: 'https://h/rel' }; },
};
const code = runRelease({
args: { name: 'demo-plugin', version: '1.1.0', createTag: true, write: true, commit: false, push: false },
catalogDir, mktPath, marketplace, pushGate, forgejo,
runCheckVersions: () => '1 plugins — 1 OK, 0 WARN, 0 ERROR, 0 SKIP — verified 1/1\n',
});
assert.equal(code, 0);
assert.equal(created.length, 1, 'the release object is part of the release, not an afterthought');
assert.equal(created[0].owner, 'open');
assert.equal(created[0].repo, 'demo-plugin');
assert.equal(created[0].payload.tag_name, 'v1.1.0');
assert.equal(created[0].payload.body, 'demo-plugin v1.1.0', 'body is the tag message this run wrote');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('R-FJ2 (real git): a failed release-object create reports precisely and returns non-zero — the release is NOT complete', () => {
const root = makeTempRoot('release-plugin-fj2-');
try {
const bare = join(root, 'origin.git');
execFileSync('git', ['init', '-q', '--bare', bare]);
const repoDir = join(root, 'demo-plugin');
const catalogDir = join(root, 'catalog');
mkdirSync(join(catalogDir, '.claude-plugin'), { recursive: true });
initPluginRepo(repoDir, { version: '1.1.0', remote: bare });
execFileSync('git', ['-C', repoDir, 'tag', '-a', 'v1.0.0', '-m', 'v1.0.0']);
execFileSync('git', ['-C', repoDir, 'push', '-q', 'origin', 'v1.0.0']);
const mktPath = join(catalogDir, '.claude-plugin', 'marketplace.json');
const url = 'https://git.fromaitochitta.com/open/demo-plugin';
const marketplace = { plugins: [{ name: 'demo-plugin', source: { source: 'url', url, ref: 'v1.0.0' }, description: 'd' }] };
fsWriteFileSync(mktPath, JSON.stringify(marketplace, null, 2));
fsWriteFileSync(join(catalogDir, 'README.md'), '### [Demo Plugin](https://x/open/demo-plugin) `v1.0.0`\n');
const pushGate = createPushGate({ cwd: catalogDir, home: root, exists: () => true, unlink: () => {} });
const forgejo = {
listReleaseTags: () => ['v1.0.0'],
createRelease: () => { throw new Error('POST /releases -> HTTP 403: token lacks write:repository'); },
};
let code;
let threw = false;
try {
code = runRelease({
args: { name: 'demo-plugin', version: '1.1.0', createTag: true, write: true, commit: false, push: false },
catalogDir, mktPath, marketplace, pushGate, forgejo,
runCheckVersions: () => '1 plugins — 1 OK, 0 WARN, 0 ERROR, 0 SKIP — verified 1/1\n',
});
} catch { threw = true; }
assert.equal(threw, false, 'a release-object failure must become a message, never an unhandled exception over a half-done release');
assert.notEqual(code, 0, 'a release without its release object is not complete');
} finally {
rmSync(root, { recursive: true, force: true });
}
});