This document is the runbook for cutting a Morph release and publishing the binaries to the Homebrew tap. It is written for the maintainer who is about to push a tag, not for end users.
End users install with brew install r/morph/morph (or cargo install --locked --path morph-cli from a checkout). They don't need to read this file.
Each release produces, for every supported target, a morph-<target>.tar.gz and a morph-<target>.tar.gz.sha256 containing both binaries:
morph— the CLI.morph-mcp— the MCP server.
Supported targets (built by .github/workflows/release-homebrew.yml):
| Target | Built on | Used by |
|---|---|---|
aarch64-apple-darwin |
macos-14 |
Apple Silicon Macs |
x86_64-apple-darwin |
macos-13 |
Intel Macs |
aarch64-unknown-linux-gnu |
ubuntu-latest (cross) |
ARM Linux (Raspberry Pi 5, Graviton) |
x86_64-unknown-linux-gnu |
ubuntu-latest |
Most Linux servers and laptops |
Binaries are stripped and tarballs are SHA-256 checksummed. The Homebrew formula pins the per-target SHA so an in-flight tarball substitution would fail formula installation.
Morph uses a single workspace-level version. It lives in one place:
# /Cargo.toml (workspace root)
[workspace.package]
version = "X.Y.Z"All crates inherit it via version.workspace = true. The build date is embedded at compile time via the MORPH_BUILD_DATE env var (set by build.rs).
While Morph is pre-1.0:
- Minor (
0.X.0) — new commands, new MCP tools, breaking CLI changes. - Patch (
0.0.X) — bug fixes and small improvements.
The release workflow refuses to publish a tag whose name doesn't match the workspace version. So v0.16.0 only works if Cargo.toml says 0.16.0.
-
Bump the version in the workspace
Cargo.toml. -
Run the workspace test suite locally and confirm it is green:
cargo test --workspace --locked -
Update
morph-cli/tests/specs/version.yamlso the spec test asserts the new version string. -
Record evaluation metrics for the release commit:
morph eval record metrics.jsonSee
.cursor/rules/behavioral-commits.mdcfor the metrics shape. -
Commit and push to
main. That's it — no manual tag step..github/workflows/auto-tag.ymlwatchesCargo.tomlonmain. When the workspace version advances to a value that has not yet been tagged on origin, it createsvX.Y.Z(annotated) and dispatchesrelease-homebrew.ymlagainst that tag. The release pipeline then:- Runs the full test suite (
testjob — release blocks on this). - Builds the four target tarballs in parallel (
build-artifacts). - Smoke-tests each native binary by parsing
morph version --json. - Publishes a GitHub release at
vX.Y.Zwith all eight files (publish). - Updates the Homebrew formula in the tap repo with the new version, URLs, and per-target SHA-256s (
update-tap).
If you ever need to force a tag manually (e.g. you bumped the version before
auto-tag.ymlexisted, or you want to retag a commit),git tag vX.Y.Z && git push origin vX.Y.Zstill works — it triggersrelease-homebrew.ymldirectly. You can also rungh workflow run auto-tag.yml --ref mainto retroactively tag whatever versionCargo.tomlonmaincurrently points at. - Runs the full test suite (
-
Verify the release in three places:
- GitHub Releases page: tarballs + checksums attached, release notes auto-populated.
- Tap repo:
Formula/morph.rbupdated with the new version and SHAs. - Locally:
brew update && brew upgrade morph && morph version --jsonshould report the new version.
Pushes to main trigger the same workflow, but the update-tap job is gated by is_tag_release. So commit pushes:
- Do run tests, build all targets, publish a
commit-<sha>GitHub release. - Do not touch the Homebrew tap.
This is intentional: commit builds let us catch packaging regressions immediately, but the tap only follows stable tags. Power users can pin a specific commit by downloading the corresponding commit-<sha> artifacts directly from the Releases page.
The release workflow validates each freshly built morph binary by running:
morph version --jsonand asserting the JSON has these keys:
name— always"morph".version— must equal the workspace version embedded at build time.build_date— RFC 3339 UTC timestamp baked in bybuild.rs.protocol_version— the SSH wire protocol version (MORPH_PROTOCOL_VERSION).supported_repo_versions— array of repo schema versions this binary can read (currently["0.0", "0.2", "0.3", "0.4", "0.5"]).
This shape is also tested in morph-cli/src/main.rs::tests::version_json_has_stable_field_set and is additive only — adding fields is fine, removing or renaming a field is a breaking change for any pipeline that consumes the JSON.
The Homebrew formula's test do block also exercises morph version --json, so a corrupt or incompatibly built tarball will fail brew test morph on the user's machine.
The workflow needs two pieces of repository configuration to update the tap. Without them, the update-tap job will fail loudly (the build and publish jobs still succeed):
| Name | Type | Description |
|---|---|---|
HOMEBREW_TAP_TOKEN |
Secret | A fine-grained PAT with contents: write on the tap repo. Used to push the formula update. |
HOMEBREW_TAP_REPO |
Variable | The tap repo, in owner/homebrew-name form (e.g. r/homebrew-morph). |
To configure them:
- Create the tap repo if it doesn't exist. Convention:
homebrew-morphunder the same owner as the main repo. Initialize it with an emptyFormula/directory and a short README. - Create the PAT at github.com/settings/tokens?type=beta:
- Repository access: only the tap repo.
- Permissions: Contents → Read and write.
- Expiration: as long as your security policy allows; the workflow will fail with a clear error when the token expires.
- Add the secret and variable to the main repo:
- Settings → Secrets and variables → Actions → New repository secret →
HOMEBREW_TAP_TOKEN= the PAT. - Settings → Secrets and variables → Actions → Variables → New repository variable →
HOMEBREW_TAP_REPO=owner/homebrew-morph.
- Settings → Secrets and variables → Actions → New repository secret →
These are deliberate gaps that need a maintainer decision before code can fill them in:
- Sigstore / cosign signing of release tarballs. The release pipeline does not currently sign artifacts. Adding
cosign sign-blobto the publish step is straightforward once a signing identity is chosen (keyless OIDC vs. a long-lived key). - Notarization on macOS. Binaries are unsigned; users will see a Gatekeeper warning on first run unless they
xattr -d com.apple.quarantine. Notarizing requires an Apple Developer ID and adds ~5 minutes per build. Not blocking for now; revisit before 1.0. - Reproducible builds. Builds use the default toolchain channel; we don't yet pin a specific stable release in
rust-toolchain.toml. Reproducibility-curious users should pin a toolchain in their fork. - Linux packaging beyond a tarball. No
.deb, no.rpm, noaptrepo. Homebrew on Linux works; native Linux distro packaging is a future line of work.
If a tagged release is shipped and turns out to be broken, the safest rollback is to ship a new tag with a fix rather than rewriting history:
- Fix the bug, bump the patch version (
0.16.0→0.16.1). - Push the new tag. The Homebrew formula is overwritten, so users get the fix on the next
brew upgrade.
Only rewrite a tag (delete + repush) if the broken release is less than an hour old and you're certain no one has installed it yet — Homebrew bottles are content-addressed by SHA, so a republished tag with the same name but a different binary will hash-mismatch on user machines.
.github/workflows/release-homebrew.yml— the workflow itself..cursor/rules/version-bump.mdc— when to bump major/minor/patch..cursor/rules/behavioral-commits.mdc— recording metrics with each commit.- INSTALLATION.md — what users see on the other end.