Files
rdbms-playground/docs/adr/0056-crates-io-and-cargo-binstall.md
claude@clouddev1 0208c67e59 ci(publish): wire winget job via komac (D3 §3d)
Add the 4th publish.yaml sibling job: opens a PR to microsoft/winget-pkgs
with komac (pinned 2.16.0 prebuilt) for LazyEvaluation.RdbmsPlayground
(portable, x64 + arm64). Unlike scoop/homebrew it's a PR into Microsoft's
central, human-gated catalog - async and validated on their side.

- Auth: a classic public_repo GitHub PAT on a dedicated bot account
  (lazyeval-ci; fine-grained tokens can't open the cross-fork PR - komac
  #310), as the WINGET_GITHUB_TOKEN secret, job-scoped and passed to the API
  guards via a 0600 curl config file (never argv).
- Idempotent via two guards before submitting (already-merged version +
  already-open PR) so a repeated publish dispatch can't open a duplicate.
- No actions/checkout (komac works off URLs + the GitHub API).

Docs: ADR-0056 Amendment 4 - the model, the one-time manual `komac new`
bootstrap recipe (flags verified vs komac 2.16.0), and first-run learnings:
the fork must pre-exist, and a direct single-exe portable takes its PATH
alias from Commands[0] (not PortableCommandAlias, which is nested-only).
Plus README index + requirements D3.

Wiring only; going live needs the bootstrap PR (#391335, submitted) to merge.
2026-06-21 21:58:47 +00:00

320 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**.