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
46
CLAUDE.md
46
CLAUDE.md
|
|
@ -73,8 +73,10 @@ their own Forgejo repositories under `https://git.fromaitochitta.com/open/`.
|
|||
orthogonal to `TAG-ANNOTATED`: it is the `-a` that makes the tag a real tag object, not the
|
||||
signature. Do not treat `tag.gpgsign` as the enforcement for annotation.
|
||||
- **Releasing a plugin (canonical path — `scripts/release-plugin.mjs`):** since the polyrepo split,
|
||||
a release is a TWO-repo act — tag the plugin repo AND bump the catalog `ref`. Forgetting the second
|
||||
step strands users on the old version (the exact drift this helper exists to prevent). Run
|
||||
a release is a THREE-part act — tag the plugin repo, bump the catalog `ref`, AND file the
|
||||
Forgejo **release object**. Forgetting the catalog `ref` strands users on the old version;
|
||||
forgetting the release object leaves the plugin's public releases page a version behind
|
||||
(both are drift this helper exists to prevent). Run
|
||||
`node scripts/release-plugin.mjs <plugin> [--version X.Y.Z]` — dry-run by default; it REFUSES unless
|
||||
`plugin.json` == README badge == the target version AND the `vX.Y.Z` tag exists, then prints the
|
||||
planned bump. Apply with `--write [--commit] [--push]`; `--create-tag --write` mints+pushes a missing
|
||||
|
|
@ -84,6 +86,46 @@ 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`.
|
||||
- **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
|
||||
added, `release-plugin.mjs` made a tag and nothing else — `grep releases scripts/*.mjs`
|
||||
(excluding `.test.`) returned **0**, against **6** hits for `tags` in the same file as the
|
||||
control that the query could find. Consequence, measured 2026-09-18 against the instance's
|
||||
own API: **24 repos in org `open`, 21 with at least one tag, and 11 of those 21 had NO release
|
||||
object for their newest tag** — llm-security's releases page said `v7.8.3` while the catalog
|
||||
pinned `v8.0.0`. That is the same tag-vs-published drift the catalog-`ref` bump exists to
|
||||
prevent, one surface further out, and it is what a human reads to answer "what version is out?".
|
||||
- **The release body is the tag's own message, VERBATIM, or empty.** Never generated prose —
|
||||
an invented release note is a claim about the release that nobody made. Read it with
|
||||
`%(contents:subject)` + `%(contents:body)`, **never `%(contents)`**: `~/.gitconfig` sets
|
||||
`tag.gpgsign` with `gpg.format ssh`, so `%(contents)` drags the whole
|
||||
`-----BEGIN SSH SIGNATURE-----` block into the release notes (verified on `llm-security`
|
||||
`v8.0.0`).
|
||||
- **It fires only on a run that PUBLISHES** (`--create-tag --write`, or `--push`). Filing a
|
||||
release object is itself a publish, so it must not ride along on a local `--write` and slip
|
||||
past the operator's one-shot push token. A non-publishing `--write` says so and names the
|
||||
backfill instead of going quiet.
|
||||
- **The step is synchronous (curl via `execFileSync`), like `check-versions.mjs`'s
|
||||
`checkHomepage`, and for a sharper reason:** `runRelease` is called from `main()` WITHOUT an
|
||||
`await` and returns the code `main()` hands to `process.exit`. An async step would return a
|
||||
Promise nobody awaits, so a rejected POST would surface as an unhandled rejection *after* the
|
||||
run had already exited 0 and called the release complete.
|
||||
- **`429` and the `502`/`503`/`504` gateway family are retried with backoff, never swallowed.**
|
||||
Both were seen live on 2026-09-18 — an unthrottled sweep of the 24 repos drew **17 HTTP
|
||||
429s**, and the first version of that sweep coerced every one of them to an empty list, so
|
||||
"verified nothing" and "verified everything, all clean" were indistinguishable. Anything
|
||||
else, including a `403` from a token without `write:repository`, is an ANSWER and is raised.
|
||||
- **The token reaches curl through a 0600 header file, never through `argv`** — `argv` is
|
||||
readable via `ps(1)`, and this token can write to every repository in the org.
|
||||
- **Backfill / retry: `scripts/backfill-forgejo-releases.mjs`** (dry-run by default, `--write`
|
||||
applies, `--repo <owner>/<name>` narrows). It reuses `release-plugin.mjs`'s own planner and API
|
||||
shell rather than reimplementing them, so the backlog and the release path cannot drift apart.
|
||||
It considers **only the newest tag** (that is the FERDIG-criterion the order set), and reports
|
||||
the denominator. **Documented exception:** `ktg-plugin-marketplace` `pre-polyrepo-archive` —
|
||||
an archive marker for the monorepo before the polyrepo split, not a release (non-semver, and
|
||||
no install path consumes it). The exclusion register is keyed by repo **AND tag**, never by
|
||||
repo alone, so that repo's next real release is still backfilled.
|
||||
- **`--create-tag --write` and `--push` each require the operator's push-approval token FIRST**
|
||||
(Q3, decided 2026-09-12): `~/.claude/hooks/pre-push-gate.sh` matches `git push` in command
|
||||
text and cannot see a push this script issues via `execFileSync` inside node — the script
|
||||
|
|
|
|||
135
scripts/backfill-forgejo-releases.mjs
Normal file
135
scripts/backfill-forgejo-releases.mjs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
#!/usr/bin/env node
|
||||
// Backfill Forgejo RELEASE OBJECTS for tags that already exist.
|
||||
//
|
||||
// Order 20260917T235642Z-730962924-from-from-ai-to-chitta. The operator noticed that
|
||||
// llm-security's /releases page showed v7.8.3 while its newest tag was v8.0.0. That was
|
||||
// not an llm-security bug: release-plugin.mjs only ever made a git TAG, and Forgejo files
|
||||
// a pushed tag under /tags — only an explicit release object appears under /releases.
|
||||
// Measured 2026-09-18 against the instance's own API: 24 repos in org `open`, 21 with at
|
||||
// least one tag, and 11 of those 21 had NO release object for their newest tag.
|
||||
//
|
||||
// release-plugin.mjs now files the release object as part of a release, so this script is
|
||||
// for the backlog and for retrying that one step. It reuses the SAME planner and API shell
|
||||
// as the release path — one behaviour, not a second implementation that can drift.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/backfill-forgejo-releases.mjs # dry-run: print the plan
|
||||
// node scripts/backfill-forgejo-releases.mjs --write # file the missing release objects
|
||||
// node scripts/backfill-forgejo-releases.mjs --repo open/voyage # limit to one repo
|
||||
//
|
||||
// Needs FORGEJO_TOKEN:
|
||||
// export FORGEJO_TOKEN="$(security find-generic-password -a ktg -s forgejo-token -w login.keychain-db)"
|
||||
|
||||
import { forgejoApi, planForgejoRelease, ensureForgejoRelease, sleepMs } from './release-plugin.mjs';
|
||||
|
||||
const ORG = 'open';
|
||||
|
||||
// Tags that are NOT releases. Keyed by repo AND by tag, never by repo alone — a blanket
|
||||
// repo exclusion would silently swallow that repo's next real release too.
|
||||
export const EXCLUDED_TAGS = {
|
||||
'ktg-plugin-marketplace': {
|
||||
'pre-polyrepo-archive': 'archive marker for the monorepo before the polyrepo split, not a release '
|
||||
+ '(non-semver; the catalog pins no plugin to it and no install path consumes it)',
|
||||
},
|
||||
};
|
||||
|
||||
// The newest tag is the FIRST one the Forgejo tags API returns. Measured 2026-09-18: that
|
||||
// ordering matched the known-newest tag for all 21 tagged repos in the org, and the 11-repo
|
||||
// result it produces reproduces the order's own independently-measured list exactly.
|
||||
export function planBackfill({ repos, excluded = EXCLUDED_TAGS }) {
|
||||
const create = [];
|
||||
const skip = [];
|
||||
|
||||
for (const r of repos) {
|
||||
const newest = r.tags?.[0] ?? null;
|
||||
if (!newest) { skip.push({ repo: r.name, tag: null, excluded: false, reason: 'no tags — nothing to release' }); continue; }
|
||||
|
||||
const exception = excluded[r.name]?.[newest.name];
|
||||
if (exception) { skip.push({ repo: r.name, tag: newest.name, excluded: true, reason: exception }); continue; }
|
||||
|
||||
if ((r.releases ?? []).includes(newest.name)) {
|
||||
skip.push({ repo: r.name, tag: newest.name, excluded: false, reason: `already has a release object for ${newest.name}` });
|
||||
continue;
|
||||
}
|
||||
|
||||
create.push({ repo: r.name, tag: newest.name, name: newest.name, body: (newest.message ?? '').trim() });
|
||||
}
|
||||
|
||||
return { create, skip, total: repos.length, tagged: repos.filter(r => (r.tags?.length ?? 0) > 0).length };
|
||||
}
|
||||
|
||||
// --- I/O shell ---------------------------------------------------------------
|
||||
|
||||
// The instance sits behind nginx with a rate limit: an unthrottled sweep of 24 repos
|
||||
// returned 17 HTTP 429s, and the first version of this measurement read every one of them
|
||||
// as "no tags" — a run that measured nothing was indistinguishable from a clean one
|
||||
// (Verifiseringsloven ansikt 4). Pace the sweep and let forgejoApi surface any 429 as an
|
||||
// error rather than as an empty list.
|
||||
function paced(fn) { const v = fn(); sleepMs(400); return v; }
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { write: false, repo: null };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '--write') out.write = true;
|
||||
else if (argv[i] === '--repo') out.repo = argv[++i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const api = forgejoApi({ token: process.env.FORGEJO_TOKEN });
|
||||
|
||||
let names;
|
||||
if (args.repo) {
|
||||
const [owner, name] = args.repo.includes('/') ? args.repo.split('/') : [ORG, args.repo];
|
||||
if (owner !== ORG) { console.error(`this script only sweeps org "${ORG}" (got ${owner})`); process.exit(2); }
|
||||
names = [name];
|
||||
} else {
|
||||
names = paced(() => api.listOrgRepos(ORG)).sort();
|
||||
}
|
||||
|
||||
const repos = names.map(name => ({
|
||||
name,
|
||||
tags: paced(() => api.listTags(ORG, name)),
|
||||
releases: paced(() => api.listReleaseTags(ORG, name)),
|
||||
}));
|
||||
|
||||
const plan = planBackfill({ repos });
|
||||
|
||||
console.log(`\nbackfill-forgejo-releases: org "${ORG}" — ${plan.total} repo, ${plan.tagged} with at least one tag`);
|
||||
console.log(` ${plan.create.length} newest tag(s) missing a release object\n`);
|
||||
|
||||
for (const s of plan.skip) {
|
||||
if (s.excluded) console.log(` ⊘ ${s.repo} ${s.tag} — EXCEPTION: ${s.reason}`);
|
||||
}
|
||||
for (const c of plan.create) {
|
||||
console.log(` ${args.write ? '→' : '·'} ${c.repo} ${c.tag}${c.body ? '' : ' (empty tag message -> empty release body)'}`);
|
||||
}
|
||||
|
||||
if (!args.write) {
|
||||
console.log('\n (dry-run) re-run with --write to file them.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let filed = 0;
|
||||
const failed = [];
|
||||
for (const c of plan.create) {
|
||||
const url = `https://git.fromaitochitta.com/${ORG}/${c.repo}`;
|
||||
try {
|
||||
const p = planForgejoRelease({ url, tag: c.tag, releaseTags: paced(() => api.listReleaseTags(ORG, c.repo)), tagMessage: c.body });
|
||||
const res = ensureForgejoRelease(p, api);
|
||||
sleepMs(400);
|
||||
if (res.created) { filed++; console.log(` ✓ ${c.repo} ${c.tag}${res.url ? ` (${res.url})` : ''}`); }
|
||||
else console.log(` · ${c.repo} ${c.tag} — ${res.reason}`);
|
||||
} catch (err) {
|
||||
failed.push({ repo: c.repo, tag: c.tag, error: err.message });
|
||||
console.log(` ✗ ${c.repo} ${c.tag} — ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n filed ${filed}/${plan.create.length}; ${failed.length} failed`);
|
||||
process.exit(failed.length ? 1 : 0);
|
||||
}
|
||||
|
||||
if (process.argv[1] && process.argv[1].endsWith('backfill-forgejo-releases.mjs')) main();
|
||||
64
scripts/backfill-forgejo-releases.test.mjs
Normal file
64
scripts/backfill-forgejo-releases.test.mjs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// Tests for the Forgejo release-object backfill.
|
||||
// Pure selector is the unit under test — the API shell (curl via release-plugin.mjs's
|
||||
// forgejoApi) is exercised against the live instance, not here.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { planBackfill, EXCLUDED_TAGS } from './backfill-forgejo-releases.mjs';
|
||||
|
||||
const repo = (name, tags, releases = []) => ({ name, tags, releases });
|
||||
const tag = (name, message = '') => ({ name, message });
|
||||
|
||||
test('plans a CREATE for a repo whose newest tag has no release object', () => {
|
||||
const r = planBackfill({ repos: [repo('llm-security', [tag('v8.0.0', 'llm-security v8.0.0'), tag('v7.8.3')], ['v7.8.3'])] });
|
||||
assert.equal(r.create.length, 1);
|
||||
assert.equal(r.create[0].repo, 'llm-security');
|
||||
assert.equal(r.create[0].tag, 'v8.0.0');
|
||||
});
|
||||
|
||||
test('the release body is the tag message VERBATIM — the backfill invents no release notes', () => {
|
||||
const msg = '0.10.0 — a bundle carries the images its sources declare\n\nFive readers place them.';
|
||||
const r = planBackfill({ repos: [repo('llm-ingestion-okf', [tag('v0.10.0', msg)], [])] });
|
||||
assert.equal(r.create[0].body, msg);
|
||||
});
|
||||
|
||||
test('skips a repo whose newest tag already has a release object (idempotent re-run)', () => {
|
||||
const r = planBackfill({ repos: [repo('ai-psychosis', [tag('v1.2.2')], ['v1.2.2'])] });
|
||||
assert.equal(r.create.length, 0);
|
||||
assert.equal(r.skip.length, 1);
|
||||
assert.match(r.skip[0].reason, /already has a release/);
|
||||
});
|
||||
|
||||
test('skips a repo with no tags at all, and NAMES the reason — never a silent drop', () => {
|
||||
const r = planBackfill({ repos: [repo('jobbsok', [])] });
|
||||
assert.equal(r.create.length, 0);
|
||||
assert.equal(r.skip.length, 1);
|
||||
assert.equal(r.skip[0].repo, 'jobbsok');
|
||||
assert.match(r.skip[0].reason, /no tags/);
|
||||
});
|
||||
|
||||
test('only the NEWEST tag is backfilled — an older tag without a release stays untouched', () => {
|
||||
const r = planBackfill({ repos: [repo('config-audit', [tag('v6.0.0'), tag('v5.9.0'), tag('v5.8.0')], [])] });
|
||||
assert.deepEqual(r.create.map(c => c.tag), ['v6.0.0']);
|
||||
});
|
||||
|
||||
test('an excluded tag is reported as a documented EXCEPTION, not dropped silently', () => {
|
||||
const r = planBackfill({ repos: [repo('ktg-plugin-marketplace', [tag('pre-polyrepo-archive', 'Archive of the monorepo')], [])] });
|
||||
assert.equal(r.create.length, 0);
|
||||
assert.equal(r.skip.length, 1);
|
||||
assert.equal(r.skip[0].excluded, true);
|
||||
assert.match(r.skip[0].reason, /archive/i);
|
||||
});
|
||||
|
||||
test('the exclusion register names the repo AND the tag — it can never blanket-skip a repo', () => {
|
||||
const entry = EXCLUDED_TAGS['ktg-plugin-marketplace'];
|
||||
assert.ok(entry, 'the archive tag exception must be registered');
|
||||
assert.ok(entry['pre-polyrepo-archive'], 'the exception is keyed by TAG, so a future real release in that repo is still backfilled');
|
||||
const r = planBackfill({ repos: [repo('ktg-plugin-marketplace', [tag('v9.0.0')], [])] });
|
||||
assert.equal(r.create.length, 1, 'a normal tag in an excluded repo is still a release');
|
||||
});
|
||||
|
||||
test('the summary reports the DENOMINATOR, so a run that verified nothing cannot read as clean', () => {
|
||||
const r = planBackfill({ repos: [repo('a', [tag('v1')], ['v1']), repo('b', [tag('v2')], []), repo('c', [])] });
|
||||
assert.equal(r.total, 3);
|
||||
assert.equal(r.tagged, 2);
|
||||
});
|
||||
|
|
@ -35,6 +35,7 @@
|
|||
import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import {
|
||||
normalizeVersion, runGate, extractCatalogStats, extractStatBadges, pickStatSource, statMismatchFindings,
|
||||
|
|
@ -335,6 +336,149 @@ export function pushWithToken({ cwd, home, exists, unlink, push }) {
|
|||
return { pushed: true, blocked: false, tokenPath: auth.tokenPath };
|
||||
}
|
||||
|
||||
// --- 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. Forgejo files a pushed tag under /tags and shows only an explicit
|
||||
// release object under /releases — so this helper, which only ever made a git TAG, left
|
||||
// every releases page one release behind. llm-security showed v7.8.3 while the catalog
|
||||
// pinned v8.0.0: the same tag-vs-published drift the catalog-ref bump exists to prevent,
|
||||
// one surface further out. A release is therefore not complete until this object exists.
|
||||
//
|
||||
// SYNCHRONOUS ON PURPOSE (curl via execFileSync, not fetch) — the same reason
|
||||
// check-versions.mjs's checkHomepage is, and it bites harder here: runRelease is called
|
||||
// from main() WITHOUT an await and returns an exit code that main() hands to process.exit.
|
||||
// An async step here would return a Promise nobody awaits, so a rejected POST would
|
||||
// surface as an unhandled rejection AFTER the run had already exited 0 and reported the
|
||||
// release as complete — "unmeasured reads as green", in the gate built to stop exactly that.
|
||||
|
||||
const FORGEJO_API = 'https://git.fromaitochitta.com/api/v1';
|
||||
|
||||
// "https://git.fromaitochitta.com/open/llm-security" -> { owner: 'open', repo: 'llm-security' }.
|
||||
// null when the URL is not a plain <host>/<owner>/<repo> — the helper must never GUESS an
|
||||
// owner, because a guessed owner POSTs a release into somebody else's repository.
|
||||
export function parseForgejoRepo(url) {
|
||||
if (typeof url !== 'string') return null;
|
||||
const cleaned = url.trim().replace(/\/+$/, '').replace(/\.git$/, '');
|
||||
const m = cleaned.match(/^https?:\/\/[^/]+\/([^/]+)\/([^/]+)$/);
|
||||
return m ? { owner: m[1], repo: m[2] } : null;
|
||||
}
|
||||
|
||||
// The release body is the TAG'S OWN message, verbatim, or empty. Never generated prose:
|
||||
// an invented release note is a claim about the release that nobody actually made.
|
||||
export function planForgejoRelease({ url, tag, releaseTags = [], tagMessage = '' }) {
|
||||
const loc = parseForgejoRepo(url);
|
||||
if (!loc) return { verdict: 'BLOCKED', reason: `cannot derive owner/repo from the catalog source url: ${JSON.stringify(url)}` };
|
||||
if (!tag) return { verdict: 'BLOCKED', reason: 'no tag to file a release object for' };
|
||||
if (releaseTags.includes(tag)) return { verdict: 'NOOP', ...loc, tag, reason: `a release object for ${tag} already exists` };
|
||||
return { verdict: 'CREATE', ...loc, tag, name: tag, body: (tagMessage ?? '').trim() };
|
||||
}
|
||||
|
||||
export function ensureForgejoRelease(plan, api) {
|
||||
if (plan.verdict !== 'CREATE') return { created: false, verdict: plan.verdict, reason: plan.reason ?? null };
|
||||
const res = api.createRelease(plan.owner, plan.repo, { tag_name: plan.tag, name: plan.name, body: plan.body });
|
||||
return { created: true, verdict: 'CREATED', url: res?.html_url ?? null };
|
||||
}
|
||||
|
||||
// A tag's message WITHOUT its signature. `%(contents)` would carry the whole
|
||||
// "-----BEGIN SSH SIGNATURE-----" block into the release notes — ~/.gitconfig sets
|
||||
// tag.gpgsign with gpg.format ssh, so every tag this helper mints is signed (verified
|
||||
// 2026-09-18 on llm-security v8.0.0). subject+body is the message and nothing else.
|
||||
export function readTagMessage(repoDir, tag) {
|
||||
try {
|
||||
const subject = execFileSync('git', ['-C', repoDir, 'tag', '-l', '--format=%(contents:subject)', tag], { encoding: 'utf8' }).trim();
|
||||
const body = execFileSync('git', ['-C', repoDir, 'tag', '-l', '--format=%(contents:body)', tag], { encoding: 'utf8' }).trim();
|
||||
return body ? `${subject}\n\n${body}` : subject;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// The token reaches curl through a 0600 header FILE, never through argv — argv is world
|
||||
// readable via ps(1), and this token can write to every repository in the org.
|
||||
export function forgejoApi({ baseUrl = FORGEJO_API, token, exec = execFileSync, mkHeaderFile } = {}) {
|
||||
const headerFile = () => {
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
'FORGEJO_TOKEN is not set, so the release object cannot be filed.\n'
|
||||
+ ' export FORGEJO_TOKEN="$(security find-generic-password -a ktg -s forgejo-token -w login.keychain-db)"',
|
||||
);
|
||||
}
|
||||
if (mkHeaderFile) return mkHeaderFile(token);
|
||||
const path = join(tmpdir(), `fj-hdr-${process.pid}-${Date.now()}`);
|
||||
writeFileSync(path, `Authorization: token ${token}\n`, { mode: 0o600 });
|
||||
return path;
|
||||
};
|
||||
|
||||
// The instance sits behind nginx with a rate limit. Measured 2026-09-18: an unthrottled
|
||||
// sweep of 24 repos drew 17 HTTP 429s — and the first version of that sweep coerced every
|
||||
// one of them to an empty list, so "verified nothing" and "verified everything, all clean"
|
||||
// looked identical (Verifiseringsloven ansikt 4). Retry 429 with backoff; never swallow it.
|
||||
function callOnce(method, path, payload) {
|
||||
const hdr = headerFile();
|
||||
try {
|
||||
const args = ['-sS', '-X', method, '-H', `@${hdr}`, '-H', 'Accept: application/json',
|
||||
'-w', '\n%{http_code}', '--max-time', '30', `${baseUrl}${path}`];
|
||||
if (payload !== undefined) args.push('-H', 'Content-Type: application/json', '-d', JSON.stringify(payload));
|
||||
const out = exec('curl', args, { encoding: 'utf8' });
|
||||
const nl = out.lastIndexOf('\n');
|
||||
const status = Number(out.slice(nl + 1).trim());
|
||||
const text = out.slice(0, nl);
|
||||
// A network failure must never read as a definitive answer: curl writes http_code 0
|
||||
// when it never got a response at all.
|
||||
if (!Number.isFinite(status) || status === 0) throw new Error(`${method} ${path} -> no HTTP response (network failure, not a verdict)`);
|
||||
return { status, text };
|
||||
} finally {
|
||||
try { unlinkSync(hdr); } catch { /* already gone */ }
|
||||
}
|
||||
}
|
||||
|
||||
// 429 (nginx rate limit) and the 502/503/504 gateway family are all "the server is not
|
||||
// answering right now", not verdicts about the resource — both were seen live on
|
||||
// 2026-09-18 during a single org sweep. Retried with backoff; anything else, including a
|
||||
// 403 from a token without write:repository, is an answer and is raised as one.
|
||||
const TRANSIENT = new Set([429, 502, 503, 504]);
|
||||
|
||||
function call(method, path, payload) {
|
||||
let wait = 1000;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const { status, text } = callOnce(method, path, payload);
|
||||
if (TRANSIENT.has(status) && attempt < 5) { sleepMs(wait); wait *= 2; continue; }
|
||||
if (status >= 400) throw new Error(`${method} ${path} -> HTTP ${status}: ${text.trim().slice(0, 300)}`);
|
||||
return text.trim() ? JSON.parse(text) : null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
listOrgRepos(owner) {
|
||||
const names = [];
|
||||
for (let page = 1; ; page++) {
|
||||
const d = call('GET', `/orgs/${owner}/repos?limit=50&page=${page}`);
|
||||
if (!Array.isArray(d) || d.length === 0) break;
|
||||
names.push(...d.map(r => r.name));
|
||||
}
|
||||
return names;
|
||||
},
|
||||
listTags(owner, repo) {
|
||||
const d = call('GET', `/repos/${owner}/${repo}/tags?limit=100`);
|
||||
return Array.isArray(d) ? d.map(t => ({ name: t.name, message: t.message ?? '' })) : [];
|
||||
},
|
||||
listReleaseTags(owner, repo) {
|
||||
const d = call('GET', `/repos/${owner}/${repo}/releases?limit=100`);
|
||||
return Array.isArray(d) ? d.map(r => r.tag_name) : [];
|
||||
},
|
||||
createRelease(owner, repo, payload) {
|
||||
return call('POST', `/repos/${owner}/${repo}/releases`, payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Blocking sleep — this whole path is synchronous on purpose (see the section header).
|
||||
export function sleepMs(ms) {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
// Run the gate FIRST, then write. The old order wrote both files and only then ran the gate
|
||||
// (which throws on exit 1), leaving a half-applied release in the working tree for a parallel
|
||||
// session to carry to the public remote. `io` is injected so the ORDER is testable.
|
||||
|
|
@ -427,7 +571,7 @@ function parseArgs(argv) {
|
|||
// build the pushGate) and minus the final process.exit call — pulled out so it is
|
||||
// testable against a real temp git repo (Q3c/S1) and so it can return an exit code
|
||||
// instead of calling process.exit at each branch (Q3c/D3, see main() below for why).
|
||||
export function runRelease({ args, catalogDir, mktPath, marketplace, pushGate, runCheckVersions }) {
|
||||
export function runRelease({ args, catalogDir, mktPath, marketplace, pushGate, runCheckVersions, forgejo }) {
|
||||
const checkVersionsRunner = runCheckVersions
|
||||
|| (() => execFileSync('node', [join(catalogDir, 'scripts', 'check-versions.mjs')], { cwd: catalogDir, encoding: 'utf8' }));
|
||||
|
||||
|
|
@ -544,6 +688,48 @@ export function runRelease({ args, catalogDir, mktPath, marketplace, pushGate, r
|
|||
console.log(' ✓ pushed');
|
||||
}
|
||||
}
|
||||
|
||||
// The release object. A run that PUBLISHES (pushed the tag, or pushed the catalog) is
|
||||
// not finished until Forgejo's /releases page shows the tag it just made current — that
|
||||
// page is what a human reads to answer "what version is out?". A run that publishes
|
||||
// nothing leaves it alone and says so: filing a release object is itself a publish, and
|
||||
// it must not slip past the operator's one-shot push token by riding along on a local
|
||||
// --write.
|
||||
const publishes = tagStep === 'create' || args.push;
|
||||
if (!publishes) {
|
||||
console.log(` · Forgejo release object for ${plan.newRef}: not filed — this run publishes nothing.`);
|
||||
console.log(' File it with --push (or --create-tag --write), or with scripts/backfill-forgejo-releases.mjs.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const sourceUrl = marketplace.plugins?.find(x => x.name === args.name)?.source?.url ?? null;
|
||||
const loc = parseForgejoRepo(sourceUrl);
|
||||
if (!loc) {
|
||||
console.log(` ✗ Forgejo release object NOT filed: cannot derive owner/repo from the catalog source url: ${JSON.stringify(sourceUrl)}`);
|
||||
console.log(' The tag and the catalog are published; only the /releases page is behind.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Never let this step throw upward: by here the tag — and possibly the catalog commit —
|
||||
// are already public, and an unhandled exception would report that as a crash instead of
|
||||
// as the one precise thing still undone.
|
||||
try {
|
||||
const fjPlan = planForgejoRelease({
|
||||
url: sourceUrl,
|
||||
tag: plan.newRef,
|
||||
releaseTags: (forgejo ?? (forgejo = forgejoApi({ token: process.env.FORGEJO_TOKEN }))).listReleaseTags(loc.owner, loc.repo),
|
||||
tagMessage: readTagMessage(obs.repoDir, plan.newRef),
|
||||
});
|
||||
const res = ensureForgejoRelease(fjPlan, forgejo);
|
||||
if (res.created) console.log(` ✓ filed the Forgejo release object for ${plan.newRef}${res.url ? ` (${res.url})` : ''}`);
|
||||
else console.log(` · Forgejo release object for ${plan.newRef} already exists — nothing to file.`);
|
||||
} catch (err) {
|
||||
console.log(` ✗ Forgejo release object NOT filed for ${plan.newRef}: ${err.message}`);
|
||||
console.log(' The tag and the catalog are published — the /releases page is the only thing behind.');
|
||||
console.log(` Retry just this step: node scripts/backfill-forgejo-releases.mjs --repo ${loc.owner}/${loc.repo} --write`);
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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