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.
241 lines
13 KiB
Markdown
241 lines
13 KiB
Markdown
# 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-slim` — **no 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.
|