Files
rdbms-playground/docs/adr/0056-crates-io-and-cargo-binstall.md
T
claude@clouddev1 6bb2288470 docs(adr-0056): Scoop+Homebrew validated; record the Caddy HEAD fix
Amendment 3: scoop install and brew install both verified end-to-end on
real Windows + macOS (install + run), resolving Amendment 2's unverified
caveats (incl. the HEAD:main push).

Records the root cause + fix for the Homebrew 403: Gitea (>=1.25)
303-redirects release downloads to a method-bound SigV4 presigned S3 URL;
brew resolves with HEAD, captures the HEAD-signed URL, then GETs it -> 403
(GET-only tools are unaffected). Fixed by a server-side Caddy rule that
answers HEAD on release-download paths directly (200, no redirect) so the
download GET re-runs the redirect fresh. That rule lives in the Gitea edge
config, NOT this repo - documented so a server rebuild doesn't silently
break brew. Includes the reference Caddyfile block.

Also notes the ad-hoc mac signature runs fine via brew; Developer-ID +
notarization stays parked on the Apple org conversion. Remaining D3: winget.
2026-06-20 08:15:17 +00:00

13 KiB
Raw Blame History

ADR-0056: crates.io publish-readiness + cargo binstall metadata (D3)

Status

Accepted — prepared 2026-06-17 (plan: docs/plans/20260616-public-availability.md, step 3a). The crate is made ready to publish and carries cargo-binstall metadata. The actual cargo publish is a gated maintainer step (see Ordering). First D3 package-manager mechanism; builds on ADR-0054 (versioned releases), ADR-0055 (installer), ADR-ci-003 (release assets). Tracked by plan + ADR (no Gitea issue — user decision).

Context

cargo binstall rdbms-playground (and cargo install) need the crate on crates.io (user decision, 2026-06-17). The manifest had publish = false, a readme = "README.md" pointing at a missing file, and no keywords/categories. Our release assets are bare binaries (not archives) named rdbms-playground-v<version>-<target> (.exe on Windows) with .sha256 sidecars (ADR-ci-003); critically the release target triples differ from users' host triples — we ship the static *-linux-musl build (hosts are *-linux-gnu) and *-windows-gnu/-gnullvm (hosts are *-msvc); only macOS matches.

Decision

Publish-readiness (this change):

  • Drop publish = false; add homepage = "https://relplay.org", keywords, categories = ["command-line-utilities", "database"], and an exclude (/website, /docs, /.gitea, /.codegraph) so the published crate is code-only (585 files/8.3 MiB → 353/913 KiB compressed).
  • Author README.md (the readme target + crates.io front page; engine-neutral and "simple/advanced mode" wording per ADR-0002 / the website copy rules), with install instructions (curl|sh, binstall, source, prebuilt).
  • Add LICENSE-MIT and LICENSE-APACHE (the latter the verbatim canonical text, added by the maintainer; both © Lazy Evaluation Ltd — the publication entity), and a CONTRIBUTING.md stating the "inbound = outbound" dual-license arrangement (so Apache-2.0 §5 makes the §3 patent grant explicit on the self-hosted forge). Dual license kept (not MIT-only) — user decision after reviewing the patent-grant rationale.

cargo binstall metadata ([package.metadata.binstall], syntax verified against cargo-binstall SUPPORT.md):

  • pkg-fmt = "bin" (bare binary), bin-dir = "{ bin }{ binary-ext }", and a base pkg-url using v{ version } (the { version } placeholder excludes the leading v).
  • Per-target overrides mapping the common host triples to the asset we actually publish: x86_64/aarch64-unknown-linux-gnu → the -musl asset; x86_64/aarch64-pc-windows-msvc → the -gnu/-gnullvm .exe. macOS needs no override (host triple == asset triple). The docs do not promise automatic musl/gnu or msvc/gnu fallback, hence explicit overrides.

Ordering / gating (important):

  • cargo publish is irreversible (needs the crates.io token; a version can't be un-published, only yanked) — a deliberate maintainer step, not done here.
  • binstall's pkg-url resolves to a tagged release's assets, so publish at a new tagged version whose release already exists, and publish after that release is built. Do not publish 0.1.0 — it would diverge from the already-released 0.1.0 binaries (which predate --version, ADR-0054). The clean path: bump → tag → release builds → cargo publish.

Verification

  • cargo publish --dry-run --allow-dirty packages + verify-builds cleanly (353 files, 913 KiB compressed; no metadata errors).
  • cargo metadata confirms the binstall block + all four overrides parse.
  • Unverified: an actual cargo binstall run — cargo-binstall isn't a dependency and nothing is on crates.io yet. Validate at the first publish + matching release (especially the windows-msvc→gnu and linux-gnu→musl overrides).

Consequences

  • The crate can be published at the next tagged release with cargo publish (+ the token); cargo install rdbms-playground and cargo binstall rdbms-playground then work.
  • Remaining D3: Scoop, Homebrew (lazyeval tap), winget (komac/manual) — each a manifest + a per-release bump, tracked in the plan.
  • Remaining follow-up: run the real cargo binstall validation at the first publish + matching release (the license files, © holder, and CONTRIBUTING are now in place).

Amendment 1 — 2026-06-18: published live + a manual publish workflow

rdbms-playground 0.2.0 is published to crates.io (cargo install and cargo binstall rdbms-playground both verified working by the user). The "unverified binstall" caveat is resolved — the per-target overrides resolve correctly against the v0.2.0 release assets.

How publishing is wired: a new manual workflow_dispatch workflow (.gitea/workflows/publish.yaml), mirroring release-macos.yaml, takes a tag input and runs cargo publish (token via the CARGO_REGISTRY_TOKEN Gitea Actions secret — a crate-scoped, publish-update token). Not automated on the tag, by decision: the publish is irreversible (yank-only), keeping the registry token off every tag push; the release is split (Linux/Windows on the tag, macOS dispatched), so a human is the natural "all assets are up — go" gate; and crates.io has no Gitea-Actions trusted-publishing path today, so a stored token on the self-hosted runner would be the only automated option. Each registry is its own idempotent job (no inter-job needs) — the crates.io job skips cleanly if the version is already published (crates.io API pre-check + cargo publish as the backstop) — so future Scoop/Homebrew/winget jobs can be added alongside without breaking one another or re-runs. The first such job's tag-vs-Cargo.toml guard mirrors release.yaml.

Amendment 2 — 2026-06-19: Scoop bucket + Homebrew tap (D3 §3b/§3c)

Two more package managers wired as sibling publish.yaml jobs (scoop-bucket, homebrew-tap), following Amendment 1's independent + idempotent pattern. Each fetches the release's .sha256 sidecars, renders a manifest, and commits it into a per-manager repo.

Repos — org-level and multi-package. Both live under a new lazyeval Gitea organisation (created with the oli account, which gives the git.lazyeval.net/lazyeval/... paths): lazyeval/scoop-bucket and lazyeval/homebrew-tap. A Scoop bucket and a Homebrew tap are by definition collections of manifests, so these are reusable for future tools, not single-package repos. Homebrew's homebrew- repo-name prefix is mandatory (→ referenced as lazyeval/tap); Scoop's bucket name is free. Users: scoop bucket add lazyeval <url> (the label is local/arbitrary; only the URL owner is real) then scoop install rdbms-playground; and brew tap lazyeval/tap https://git.lazyeval.net/lazyeval/homebrew-tap (the explicit-URL form — the user/repo shorthand assumes GitHub) then brew install lazyeval/tap/rdbms-playground.

Credential — a scoped bot user, not an oli PAT. Gitea PATs scope by permission category, not per-repository (write:repository grants write to every repo the account can reach — there is no repo picker like GitHub fine-grained PATs). So an oli token would also be able to push to oli/rdbms-playground itself. Instead a dedicated bot user lazyeval-ci is a member of a lazyeval org team with Write to the package repos only; its write:repository PAT is therefore effectively scoped to those repos and cannot touch the main project repo. Stored as the LAZYEVAL_PKG_TOKEN Actions secret on oli/rdbms-playground (where the workflow runs — not an org secret, which wouldn't reach a user-repo workflow; not on the target repos, which only receive pushes). Passed via env: (never inlined), so it stays masked and only materialises in the clone URL at runtime; pushes go to HEAD:main (assumes the repos default to main).

Render scripts are dependency-free bash. The CI job container is node:22-bookworm-slimno jq, no ruby — so scripts/render-{scoop-manifest,homebrew-formula}.sh are pure bash (heredocs, no external deps) taking a version + the relevant hashes and emitting the manifest on stdout. scripts/test-package-renders.sh is their test (JSON validated with node — present in the image — plus jq/ruby when available; field-level assertions). The job validates the rendered Scoop JSON with node -e JSON.parse before committing.

Manifest specifics.

  • Scoop (rdbms-playground.json at bucket root): 64bit = x86_64-pc-windows-gnu.exe, arm64 = aarch64-pc-windows-gnullvm.exe; each URL carries a #/rdbms-playground.exe rename fragment so the bin shim resolves regardless of version. Carries checkver (lets scoop status / the community excavator see lag) but no autoupdate — our pipeline is the updater.
  • Homebrew (Formula/rdbms-playground.rb): on_macos/on_linux × on_arm/on_intel selecting the four bare-binary assets (macOS direct; Linux = the static -musl build). Windows absent — Homebrew has no Windows port. install drops the single staged binary under a stable name; the test block runs --version.

Unverified (validate on first real use): an actual scoop install and brew install/brew test; the HEAD:main default-branch assumption; and whether macOS Gatekeeper accepts the ad-hoc-signed mac binary via brew (execution should be fine — ad-hoc satisfies arm64's signing requirement and brew's curl download sets no quarantine xattr, unlike a browser download — but this rides on the still-parked Developer-ID signing decision). Remaining D3: winget (komac on Linux CI, or a manual PR).

Amendment 3 — 2026-06-19: validated end-to-end; a presigned-URL/HEAD gotcha + the Caddy fix it needs

Scoop and Homebrew now install and run end-to-end (v0.2.0, user-verified on real Windows + macOS): scoop install rdbms-playground and brew install lazyeval/tap/rdbms-playground both fetch, checksum, install, and the installed binary launches. Amendment 2's "unverified" caveats are resolved (the HEAD:main push populated both repos cleanly).

Distribution depends on a server-side Caddy rule — RECORD THIS: it lives in the Gitea edge config, NOT this repo, and if it is lost in a server rebuild Homebrew silently 403s again. Symptom: brew install failed with curl (22) … 403 on the asset URL. Root cause: Gitea (≥1.25; here 1.26.2) serves release-asset downloads by 303-redirecting to a method-bound AWS-SigV4 presigned S3 URL (OVH), signed for the incoming request's HTTP verb. Homebrew resolves the URL with a HEAD, captures the returned (HEAD-signed) presigned URL, then runs the download GET against that captured URL → GET-on-a-HEAD-signed-URL → 403. Reproduced precisely: GET on HEAD-resolved = 403, HEAD on HEAD-resolved = 200, GET on GET-resolved = 200, HEAD on GET-resolved = 403. GET-only tools (install.sh, install.ps1, cargo binstall, curl) are unaffected — they GET the original URL and let it redirect fresh to a GET-signed URL. This is a Homebrew defect (it reuses an ephemeral, method-scoped credential as if it were a durable resource locator); Gitea's per-request method-bound signing is correct, and SigV4 cannot sign one URL for two verbs. SERVE_DIRECT=false (Gitea proxies, no presign) would also fix it but was declined — not reshaping storage for one client.

Fix (deployed, "Tier A"): a Caddy rule that answers HEAD on release-download paths directly — 200, no redirect, no body — so brew's resolve records the original URL (no presigned credential captured) and its download GET runs through the redirect fresh → GET-signed → 200. Scoped to method HEAD + the release-download path; GET is untouched, so every working channel is unaffected, and a HEAD carries no payload so no client can break (at most a HEAD-probing download manager loses a progress-bar size — cosmetic). Reference Caddyfile (place before the Gitea reverse_proxy):

@release_head {
    method HEAD
    path_regexp ^/[^/]+/[^/]+/releases/download/.+
}
handle @release_head {
    header Accept-Ranges bytes
    header Content-Type application/octet-stream
    respond 200
}

A "Tier B" variant (a sidecar that fetches and returns the real Content-Length so even HEAD-probing clients stay fully faithful) was specced but proved unnecessary — brew is happy with the bare 200.

macOS signing: the brew-installed binary runs under the current ad-hoc signature (codesign --sign -) — confirmed on Apple Silicon. Developer-ID signing + notarization remains parked pending the user's Apple account conversion to an Organization (GA plan / ADR-ci-003); it is needed for browser-download trust, not for the package-manager paths, which are all working now.

Remaining D3: winget only.