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

@ -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