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.
17 KiB
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; addhomepage = "https://relplay.org",keywords,categories = ["command-line-utilities", "database"], and anexclude(/website,/docs,/.gitea,/.codegraph) so the published crate is code-only (585 files/8.3 MiB → 353/913 KiB compressed). - Author
README.md(thereadmetarget + 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-MITandLICENSE-APACHE(the latter the verbatim canonical text, added by the maintainer; both © Lazy Evaluation Ltd — the publication entity), and aCONTRIBUTING.mdstating 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 basepkg-urlusingv{ version }(the{ version }placeholder excludes the leadingv).- Per-target
overridesmapping the common host triples to the asset we actually publish:x86_64/aarch64-unknown-linux-gnu→ the-muslasset;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 publishis 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-urlresolves 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 publish0.1.0— it would diverge from the already-released0.1.0binaries (which predate--version, ADR-0054). The clean path: bump → tag → release builds →cargo publish.
Verification
cargo publish --dry-run --allow-dirtypackages + verify-builds cleanly (353 files, 913 KiB compressed; no metadata errors).cargo metadataconfirms thebinstallblock + all fouroverridesparse.- Unverified: an actual
cargo binstallrun — 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-playgroundandcargo binstall rdbms-playgroundthen work. - Remaining D3: Scoop, Homebrew (
lazyevaltap), winget (komac/manual) — each a manifest + a per-release bump, tracked in the plan. - Remaining follow-up: run the real
cargo binstallvalidation 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.jsonat bucket root):64bit=x86_64-pc-windows-gnu.exe,arm64=aarch64-pc-windows-gnullvm.exe; each URL carries a#/rdbms-playground.exerename fragment so thebinshim resolves regardless of version. Carriescheckver(letsscoop status/ the community excavator see lag) but noautoupdate— our pipeline is the updater. - Homebrew (
Formula/rdbms-playground.rb):on_macos/on_linux×on_arm/on_intelselecting the four bare-binary assets (macOS direct; Linux = the static-muslbuild). Windows absent — Homebrew has no Windows port.installdrops the single staged binary under a stable name; thetestblock 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.