ceremony/changelog.d/191.md
cluade-reviewer-andresmgsl ca99182e80 fix(forge): percent-encode asset names, and stop the docs naming a client
Both findings are @codex's on !193 (#1583), and both are real.

The asset name travels as a QUERY VALUE, and the artifact-hook contract
permits any file the consumer drops in RELEASE_ASSETS_DIR. Raw
interpolation meant `release asset.tgz` made curl reject the URL outright
(exit 3), and '&', '#', '+', '%' silently changed the name or the query's
shape. `gh release create` handled all of those, so a 1:1 port had to.

Encoded through one boundary — jq's @uri, since jq is already a hard
dependency of this backend and a hand-rolled sed class is how the next
unescaped character gets through. Six backend cases cover it: the encoder
on a space and on the delimiters, uploads under both names, the created
release id in the path, and the multipart attachment. Mutation-checked:
dropping the encoder fails exactly the two name assertions.

docs/CONSUMERS.md's artifact-hook recovery still told operators to "run
`gh release create` by hand" and described the hook as running "before
`gh release create`" — on a Forgejo runner that is precisely the failure
this PR fixes. It now names the forge-neutral tag-door recovery first and
shows both clients for the manual path, without regressing the GitHub
guidance.

1035 assertions, 22 suites, shellcheck-all and actionlint clean.

Refs #191

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 12:11:06 +00:00

1.7 KiB

Fixed

  • The release doors run on a Forgejo consumer. lib/facts.sh and release.yml gathered and published through gh, which the runner image does not ship, so the merge door read labeled=no for a correctly labeled ceremony PR and the tag door died at the publish (#191).

  • A release fact that could not be read is no longer reported as a definite no. A completed read finding no label is still no and still fail-closed; a read that did not complete refuses and emits no fact (#191).

Added

  • forge_release_exists, forge_commit_pulls, forge_tag_create, forge_release_create and forge_pr_create on both backends, so the release path names no client (#191).

  • The forgejo backend serves one PR object at /commits/{sha}/pull where GitHub serves an array at /pulls; both verbs emit the array shape, so the call site carries one expression (#191).

  • Forgejo creates tags at POST /tags — it serves /git/refs GET-only, so GitHub's ref-POST would have 404'd there forever (#191).

  • forgejo_api_base refuses when REPO is empty. Every verb interpolates it and every call reaches the network through there, so repos//… — whose 404 reads as "no release" and "no PRs" — is now impossible (#191).

  • Release asset names are percent-encoded. The hook contract permits any filename, and the name travels as a query value: a space made curl reject the URL and &/#/+/% silently renamed the asset (#191).

Changed

  • docs/CONSUMERS.md's artifact-hook recovery no longer tells operators to run gh release create by hand — on a Forgejo runner there is no gh. It names the forge-neutral tag-door path first, with both clients shown (#191).