# 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.

## 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/<ver>`) 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**.
