Publishing (Canonical Hybrid Rust/Python)
May 30, 2026 · View on GitHub
Doc status: Parallel to upstream
docs/publishing.md. Both cover the same role; content differs because the Rust toolchain (cargo, multi-channel release) and the Python toolchain (uv, PyPI-only) require different commands and steps.
This file is the canonical release runbook for flowmark-rs and the template baseline
for future hybrid Rust/Python projects.
Release Invariants
- Complex workflow logic must live in testable scripts (
scripts/*.py), not long inline bash in Actions YAML. - A release tag (
vX.Y.Z) must flow into build env (FLOWMARK_RELEASE_TAG) so built binaries/wheels embed the stable version, notdev.unknown. - Release steps must be rerun-safe:
- crates publishes are skip-safe when version already exists
- PyPI publishes are skip-safe via
uv publish --check-url - GitHub release updates are idempotent
- Homebrew tap updates remain explicit and auditable (manual commit in tap repo).
Execution Defaults (for agents — no clarification needed)
When asked to “publish the release” / “do the release” / “follow the release process”, treat that as full authorization to run this runbook end-to-end with these defaults. Do not stop to ask about any of the following — they are pre-decided:
- Drive the whole flow. The agent prepares the release-prep change (version +
CHANGELOG.md), opens the PR, waits for CI green, merges it tomain, then runs the publish workflows. Merging the agent’s own release-prep PR is part of the process. - Version = current
Cargo.tomlversion. IfCHANGELOG.mdlacks that version’s section, add it (that is the release-prep change). Never bump the version number without explicit instruction. - Publishing is authorized and runs without pausing. crates.io + PyPI are
immutable, but “publish the release” is the go-ahead.
Run the dry-run gate first; if it is green, proceed straight through
publish.yml(crates) andrelease.yml(PyPI + GitHub release) without asking again. If the dry-run fails, stop and report. - Homebrew (Phase 3): the agent does it via
ghcredentials (git commit --no-gpg-sign,git -c credential.helper='!gh auth git-credential' push), after the GitHub release publishesSHA256SUMS. Only escalate if tap auth genuinely fails. - CI status checks: a
DeepSourcecheck reportingfailurewith body “Analysis in progress…” is a transient placeholder, not a real finding — wait for it to flip rather than treating it as a blocker. All first-party gates (fmt, clippy, tests, parity, semver, README/markdown) must be genuinely green. - Stop only for: a failed dry-run, a real (non-placeholder) CI failure, a genuine secret/security finding, or a registry/tag collision (version already published).
Variables
REPO=jlevy/flowmark-rs
TAP_REPO=jlevy/homebrew-flowmark
VERSION=X.Y.Z
TAG=v${VERSION}
One-Time Setup
gh auth statussucceeds for the maintainer account.- crates.io trusted publisher is configured for this repo.
- Current working path can be
publish.yml(crate-first flow). - If you want fully one-step release from
release.yml, add that workflow in crates trusted publisher settings too.
- Current working path can be
- PyPI trusted publisher is configured for project
flowmark-rs, workflowpypi.yml, environment blank. - Homebrew tap access is configured for
jlevy/homebrew-flowmark(maintainer account or token with push access).
Phase 0: Preflight
- Ensure release bump is merged to
main:Cargo.tomlCargo.lockCHANGELOG.md- README/docs sync updates
- Confirm local checkout is clean and on current
main. - Run required dry-run orchestration:
gh workflow run release.yml --repo "$REPO" -r main \
-f tag=dry-run \
-f publish=false \
-f publish_prerelease=false
gh run list --repo "$REPO" --workflow release.yml --limit 1
gh run watch --repo "$REPO" <run-id>
Do not proceed until dry-run is green.
Phase 1: Publish Registry Channels
1A. Publish crate first (current trusted-publisher-safe path)
gh workflow run publish.yml --repo "$REPO" -r main -f publish=true
gh run list --repo "$REPO" --workflow publish.yml --limit 1
gh run watch --repo "$REPO" <run-id>
Verify crate availability (crates.io requires a User-Agent or it returns HTTP 403):
curl -fsSL -H "User-Agent: flowmark-release-check (maintainer@example.com)" \
"https://crates.io/api/v1/crates/flowmark/${VERSION}" >/dev/null
1B. Run orchestrated tagged release
This publishes PyPI and creates/updates the GitHub release.
The workflow passes release_tag through to reusable publish workflows so compiled
artifacts embed the stable version string.
gh workflow run release.yml --repo "$REPO" -r main \
-f tag="$TAG" \
-f publish=true \
-f publish_prerelease=false
gh run list --repo "$REPO" --workflow release.yml --limit 1
gh run watch --repo "$REPO" <run-id>
Expected:
- crates job skips as already published
- PyPI publishes
- GitHub release/tag is created or updated
Phase 2: Verify Outputs
GitHub release:
gh release view "$TAG" --repo "$REPO" --json url,publishedAt,isDraft,isPrerelease
gh release view "$TAG" --repo "$REPO" --json assets --jq '.assets[].name'
Registry versions:
# crates.io rejects requests without a User-Agent (HTTP 403), so always send one.
curl -fsSL -H "User-Agent: flowmark-release-check (maintainer@example.com)" \
"https://crates.io/api/v1/crates/flowmark/${VERSION}" | jq -r '.version.num'
# On PyPI, info.version is the aggregate "latest" field and can lag for a minute or
# two right after upload — check the version-specific release instead so a fresh
# publish is not reported as missing.
python3 - "$VERSION" <<'PY'
import json, sys, urllib.request
version = sys.argv[1]
data = json.load(urllib.request.urlopen("https://pypi.org/pypi/flowmark-rs/json", timeout=10))
print("published" if version in data["releases"] else f"MISSING (info.version={data['info']['version']})")
PY
CLI version smoke check from PyPI package:
uvx "flowmark-rs==${VERSION}" --version
Expected output starts with flowmark ${VERSION} and does not include
-dev.unknown+gunknown.
Phase 3: Homebrew Tap Update (Manual, Required)
Download checksums and clone tap:
workdir="$(mktemp -d)"
gh release download "$TAG" --repo "$REPO" --pattern SHA256SUMS --dir "$workdir"
gh repo clone "$TAP_REPO" "$workdir/homebrew-flowmark"
Update Formula/flowmark.rb:
- Set
versionto${VERSION}. - Update the four
sha256values fromSHA256SUMS:aarch64-apple-darwinx86_64-apple-darwinaarch64-unknown-linux-muslx86_64-unknown-linux-musl
Commit and push:
cd "$workdir/homebrew-flowmark"
git add Formula/flowmark.rb
git commit -m "Update flowmark to ${TAG}"
git push origin main
Releasing from an automated/ephemeral environment (e.g. Claude Code on the web): the tap is a separate repo cloned over HTTPS, so the local checkout’s commit-signing and credential setup do not apply to it. If
git commitfails with a signing error, add--no-gpg-sign; ifgit pushfails withcould not read Username for 'https://github.com', supply credentials via theghhelper:git commit --no-gpg-sign -m "Update flowmark to ${TAG}" git -c credential.helper='!gh auth git-credential' push origin main
Homebrew verification:
brew update
brew tap jlevy/flowmark
brew upgrade flowmark || true
brew install jlevy/flowmark/flowmark
flowmark --version
Failure Handling and Partial Success
Cross-channel publishing is not atomic. Treat release as a controlled two-phase process:
- Publish immutable registries first (crates, PyPI).
- Update Homebrew only after registries and GitHub release are confirmed good.
If a channel fails:
- Fix the issue.
- Rerun the same workflow/tag.
- Verify channel state before proceeding.
Current channel behavior:
- crates: already-published versions are detected and skipped.
- PyPI:
uv publish --check-urlavoids duplicate uploads. - Homebrew: push a corrective follow-up formula commit if needed.
Template Guidance (For Upstream Playbook)
Use this as the baseline pattern for hybrid Rust/Python projects:
release.ymlorchestrates packaging + checksums + announce.- Reusable channel workflows (
publish.yml,pypi.yml) do channel-specific publish. - Workflow decision logic is delegated to tested
scripts/*.py. - Script tests run in CI
(
python3 -m unittest discover -s scripts/tests -p 'test_*.py'). - Naming conventions stay consistent (
snake_case.pyfor Python scripts).