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:
parent
328e92feb9
commit
ee2259f63f
5 changed files with 615 additions and 3 deletions
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue