# 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-` (`.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 ` (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. ## Amendment 4 — 2026-06-20: winget (D3 §3d) — the last package manager winget wired as the fourth `publish.yaml` sibling job, **completing the D3 package-manager set** (crates.io/binstall, Scoop, Homebrew, winget; plus the install scripts + direct binaries). **Model — fundamentally unlike Scoop/Homebrew.** winget has no self-hosted-source equivalent: its default catalog is the central, **human-gated** GitHub repo `microsoft/winget-pkgs`, and you get in by **opening a PR** of manifests into it. So the job submits a PR (via **komac**, pinned `2.16.0`, prebuilt glibc binary — the CI image has no cargo) and **Microsoft's pipeline validates async** (schema, SHA256, AV/SmartScreen scan, sandbox install) + moderator review. It is *not* live on dispatch like the bucket/tap. **PackageIdentifier `LazyEvaluation.RdbmsPlayground`** (publisher segment = the publishing entity / `lazyeval` org / license holder — consistent with everything else). Bare-exe → winget **`portable`** installer type; x64 (`-pc-windows-gnu`) + arm64 (`-pc-windows-gnullvm`), komac infers arch + type from the binaries. **Auth — a dedicated GitHub bot, classic token.** komac needs a **classic `public_repo` PAT**; **fine-grained tokens cannot open the cross-fork PR** (komac #310 — the PR is created on the *target* repo you don't administer, which the fine-grained model can't express). A classic `public_repo` token can't be scoped to one repo, so it lives on a **dedicated GitHub bot account** (a leak can't reach other repos — the lazyeval-ci reasoning), as the `WINGET_GITHUB_TOKEN` secret, referenced **only** in the `winget` job (job-level scoping keeps it away from the crates.io / lazyeval tokens) and passed to the API guards via a 0600 curl config file (never argv). **Idempotency — stronger than the others need.** A re-submitted version would open a *duplicate* PR, so before submitting the job guards on **both** (1) already-merged versions (`contents` API on `manifests/l/LazyEvaluation/RdbmsPlayground/`) and (2) an already-open PR for the id+version (`search/issues`). Either → clean skip, so a repeated `publish` dispatch is safe even mid-review. **Signing:** none required to *submit* (only MSIX needs it; ours is portable). The unsigned binary may earn an AV/SmartScreen **manual-review label** on the first PR — usually clears; a nudge toward Trusted Signing (the UK Ltd clears the 3-year-history bar) but not a blocker. Continues the parked Developer-ID/notarization posture. **One-time bootstrap (manual — NOT in CI, because `komac new` is interactive).** Run once to create the package in winget-pkgs, then CI `komac update` handles every release after: ``` komac token update # paste the bot's classic public_repo PAT at the prompt (not in argv/history) komac new LazyEvaluation.RdbmsPlayground \ -v 0.2.0 \ -u https://git.lazyeval.net/oli/rdbms-playground/releases/download/v0.2.0/rdbms-playground-v0.2.0-x86_64-pc-windows-gnu.exe \ https://git.lazyeval.net/oli/rdbms-playground/releases/download/v0.2.0/rdbms-playground-v0.2.0-aarch64-pc-windows-gnullvm.exe \ --publisher "Lazy Evaluation Ltd" --package-name "RDBMS Playground" \ --moniker rdbms-playground --license "MIT OR Apache-2.0" \ --package-url https://relplay.org -s ``` komac downloads the two exes, detects the bare binary as **`portable`**, and **prompts for the command alias — enter `rdbms-playground`** so users get that on PATH (not the long versioned filename). `-s` opens the PR; fill any remaining prompts. (Flags verified against komac 2.16.0: `-v/--version`, `-u/--urls`, `-s/--submit`; the alias has no flag and is prompted.) Once that first PR merges, the CI job's `komac update` takes over. **Bootstrap learnings (2026-06-21, first real run).** GitHub bot account = `lazyeval-ci`. Its fork **`lazyeval-ci/winget-pkgs` must exist *before* `komac new`** — komac's auto-fork races on winget-pkgs (one of GitHub's largest repos) and fails with *"Could not resolve to a Repository 'lazyeval-ci/winget-pkgs'"*; fork it manually (web **Fork**, or `gh repo fork microsoft/winget-pkgs --clone=false`), wait for it to populate, then re-run. **Alias:** for a **direct single-exe portable**, winget derives the on-PATH command from **`Commands[0]`** (the interactive "Commands" prompt → `rdbms-playground`) — **not** `PortableCommandAlias`, which is **archive/nested-portable-only** and must NOT be added to our manifest (verified against winget-cli's portable-install logic + the 1.6.0 installer schema; komac correctly omits it). So komac's generated manifest needs no hand-editing. First PR: **#391335**.