Publish the Rust crate to crates.io on the release tag, with trusted publishing
Publishes hey-sdk to crates.io on every vX.Y.Z tag (card: hey-sdk Rust: crates.io publishing + release automation). Stacked on #160 → #159 → #157 → #155; merge those first.
This PR does not publish anything, and merging it does not publish anything. The first version on crates.io is a one-time manual publish by a human (steps below and in CONTRIBUTING). Until that is done crates.io/api/v1/crates/hey-sdk answers 404 and the publish job fails closed, pointing at those steps, which holds the GitHub release for that tag until the bootstrap is done and the run re-run — so merge this after v0.30.0 is cut (it is a new workflow and does not fire retroactively) and do the bootstrap before the next tag. The first automated publish is also gated on the API-governance card landing its #[non_exhaustive] decision first, so the first crates.io version is not immediately followed by a breaking one. Both are said again at the bottom.
What changes
rust/hey-sdk/Cargo.toml — publish = false goes; homepage, documentation (docs.rs), keywords, categories, an include allow-list (src/**, examples/**, tests/**, README.md, LICENSE — the licence is a copy of the repository's, since the SPDX id alone does not put the text in the tarball; the tests ship because a test target on disk but out of the tarball is a cargo warning per file, and they run from the tarball on their own fake HEY — Stanko's commit on this branch), [package.metadata.docs.rs] with all-features and --cfg docsrs. license, repository and rust-version were already there through the workspace; readme is inferred (the explicit key is what nightly cargo now warns about).
make rs-publish-check becomes cargo publish --dry-run --locked — everything a publish does short of the upload — and stays in make rs-check.
.github/workflows/release-rust.yml, shaped like basecamp-sdk's release-python.yml:
smithy→test(tag onmain; the samemake rs-check rs-check-drift conformance-rsa PR passes) →package(version read withcargo metadata, must equal the tag;cargo package --locked;.cargo_vcs_info.jsonmust name$GITHUB_SHAatrust/hey-sdk; sha256 as an output;.crateartifact; onworkflow_dispatcha dry-run publish and a notice, never more) →publish.publishruns only on a pushed tag, in therelease-cratesenvironment (restricted tov*tags; no required reviewers, see the bootstrap), withid-token: writeand nothing else. It publishes only on a clean 404 for the version: a 200 means already there and the job succeeds without publishing, provided the checksum crates.io holds equals what this commit packages to (re-run after a partial failure is idempotent; acargo publishthat timed out waiting on the index still stands; a version published from other bytes fails); a yanked 200 fails with "release the next one"; a body that does not parse, or any other status, fails closed. It packages again and refuses unless the sha256 equals thepackagejob's; mints a short-lived token withrust-lang/crates-io-auth-action(revoked at job end; no long-lived secret exists);cargo publish --locked; then compares the checksum crates.io holds with what was packaged.cargo packageoutput is byte-reproducible from the same commit and toolchain (verified locally: identical sha256 across runs, with and withoutSOURCE_DATE_EPOCH; cargo fixes the tarball mtimes itself), which is what makes the cross-job comparison meaningful.
release-github.yml — languages: go,rust, so the GitHub release waits on the crate.
make bump already stamps Cargo.toml and both Cargo.lock files (verified: scripts/bump-version.sh 0.99.0 rewrote all four version files, then reverted). No change needed.
Docs — CONTRIBUTING's release section describes the three tag-triggered workflows, the re-run semantics, and the one-time bootstrap; both READMEs switch the install line to crates.io with the git-tag form as the alternative.
Verified locally: make rs-check rs-check-drift green (including the dry-run publish); actionlint and zizmor clean apart from the pre-existing self-repository note, which is the repo's convention.
Human bootstrap (one-time, not automatable)
- Create the
release-cratesenvironment (Settings → Environments), deployment tags restricted tov*, no required reviewers (release-github's orchestrator polls for 30 minutes; an approval wait would time it out, and the tag is already a deliberatemake release— same footing as basecamp-sdk's release environments). Before the first tag: a job that references a missing environment creates an unprotected one. - On a clean
mainat the commit about to be tagged,make checkgreen: mint a crates.io token scopedpublish-new+change-owners, crate-name patternhey-sdk, one-day expiry. cd rust && CARGO_REGISTRY_TOKEN=… cargo publish -p hey-sdk --lockedcargo owner --add github:basecamp:cli hey-sdk- crates.io → hey-sdk → Settings → Trusted Publishing: owner
basecamp, repositoryhey-sdk, workflowrelease-rust.yml, environmentrelease-crates. - Revoke the token.
make release VERSION=x.y.zfrom the same commit. That tag's run finds the version already there and succeeds; the next tag is the first real OIDC exchange.
After the bootstrap: no flip
Nothing in this workflow changes once the trusted publisher exists. There is no dry-run flag to remove and no step to enable: on a pushed v* tag the publish job already runs for real, and the only thing standing between it and an upload today is crates.io answering 404 for the crate, which the manual first publish turns into a 200. workflow_dispatch stays a rehearsal forever; the pushed tag is the real thing. The first tag after the bootstrap is the first OIDC exchange, and its run is the proof.
Merge order
Top of a five-PR stack, each rebased on the one below and all five on current main: #155 (build/MSRV/feature matrix) → #157 (cargo deny, Dependabot, Trivy) → #159 (semver-checks, packaging) → #160 (docs, examples, rustfmt) → #162 (this). Every one touches .github/workflows/test.yml and/or the Makefiles; the stacking is the conflict resolution. Merge each after the one below has landed; GitHub retargets this PR to main when its base branch is deleted. Merging this one on its own would land it on rust-docs-examples.
This PR can merge before the crates.io bootstrap: it publishes nothing until the crate exists on crates.io — on a tag, its publish job fails closed with the bootstrap instructions and the GitHub release waits for a re-run — and nothing in it changes after the bootstrap (see "no flip" above).
Blocked on
- The bootstrap above (human).
- The API-governance card's
#[non_exhaustive]/ request-construction decision landing before the first automated publish.
Until both, a tag's release-rust.yml run fails at the publish step with the bootstrap instructions and the GitHub release waits; the fix is the bootstrap, then a re-run.
Adversarial review
An external review (Codex, gpt-6-astra) over the whole stack raised ten findings; the ones for this PR: the already-published path skipped byte verification and accepted a body that did not parse (folded: checksum must equal the package job's, jq -e on both fields); an unpublished crate produced a green release (folded: fail closed, as above); the tarball carried no licence text (folded: LICENSE copied in and included). The rest landed on #155, #157 and #160 and are noted there.