| name | cargo-publish |
| description | Publish one or more Rust crates to crates.io from the trusty-tools workspace |
| user-invocable | true |
| version | 1.0.0 |
| category | local-ops |
| tags | ["cargo","publish","release","workspace","local-ops"] |
| effort | high |
| when_to_use | When publishing one or more crates to crates.io from this workspace; when bumping versions; when investigating dry-run failures; when sequencing multi-crate releases with shared dependencies |
Cargo Publish Workflow for trusty-tools
Complete reference for publishing Rust crates to crates.io in the trusty-tools workspace.
Codifies lessons from 18+ publishes across two recent sessions.
Canonical Workflow
Every publish follows this exact sequence:
0. scripts/check-publish-ready.sh <crate> — MANDATORY, MUST PASS
1. Pre-flight checks (fmt, clippy, tests)
2. cargo publish --dry-run
3. git tag <crate-name>-v<version>
4. git push origin <crate-name>-v<version>
5. scripts/preflight-publish.sh <crate> — MANDATORY, MUST PASS (run again, immediately before step 6)
6. cargo publish
7. Wait 60-120s for propagation
8. Verify with curl to crates.io API
9. cargo install --path crates/<dir> --locked (binaries only)
10. Verify <binary> --version
Critical: Never skip dry-run. Never publish from the main checkout.
Step 0: Publish-Only-From-Merged-Main Guard (MANDATORY, issue #2227)
🔴 Publish only from merged main / the pushed release tag — never an
unmerged branch. Before running cargo publish for ANY crate, run:
scripts/check-publish-ready.sh <crate-name-or-dir>
make publish-check CRATE=<crate-name-or-dir>
Do NOT run cargo publish if this fails. It asserts two things against
the true origin/main tip (fetched fresh, never trusted from a possibly-stale
local ref):
- GUARD 1 (merged-main): current HEAD is
origin/main itself or an
ancestor of it.
- GUARD 2 (version tag): the release tag
<crate>-v<version> (accepts
tga-v<version> too, for trusty-git-analytics) has been pushed to origin
and its commit is on merged main.
Why this exists: issue #2209 — a publish ran from an unmerged feature
branch that was missing a P0 fix. That branch's build became the crates.io
"latest" version, and every concurrent worktree session that ran
cargo install <crate> picked up the regressed build until it was caught and
corrected. With concurrent sessions routinely running in this repo,
"whatever branch happens to be checked out" is a correctness hazard, not just
a process nicety — this script turns "publish only from merged main" into a
mechanical gate instead of a convention someone can forget under pressure.
Escape hatch (rare, deliberate use only): ALLOW_UNMERGED_PUBLISH=1
downgrades both guard failures to a loud warning and exits 0. Only use this
when you have a specific, understood reason to publish from an unmerged
commit — the default path is always "merge to main first, then publish."
Step 5: Identity + Clean-Tree + Version-Not-Live Guard (MANDATORY, closes the 2026-07-08 collision)
🔴 Run scripts/preflight-publish.sh immediately before every cargo publish
— treat any nonzero exit as an absolute stop. On 2026-07-08 a crate was
published to crates.io out-of-band — from an UNMERGED branch, under the
WRONG gh account — burning crates.io version 0.22.0 with fix-less content
(a burned version number can never be reused). check-publish-ready.sh
above already covers "merged main" + "tag pushed"; this script closes the two
gaps that incident fell through — WHO is publishing, and whether the target
version is already live:
scripts/preflight-publish.sh trusty-mpm
Reads the crate's name/version straight from crates/trusty-mpm/Cargo.toml
(pass an explicit version as a second argument to check a hypothetical
version instead). Runs four checks and fails loud on any of them:
- merged-main: current HEAD's commit SHA is EXACTLY
origin/main's HEAD
SHA (stricter than check-publish-ready.sh's ancestor check).
- identity: the active
gh auth status account is bobmatnyc. Any
other active account fails with the remedy gh auth switch --user bobmatnyc.
- clean-tree:
git status --porcelain is empty.
- version-not-live: the target version is not already published on
crates.io (queries
https://crates.io/api/v1/crates/<name>/<version>) —
this is the exact guard that would have caught the 0.22.0 collision.
scripts/preflight-publish.sh --check-only <crate> runs all four checks
unconditionally and prints a [PASS]/[FAIL] line per check without
assuming you're mid-publish — use it to preview status. --help documents
the rare, logged PREFLIGHT_ALLOW_DETACHED=1 override for check 1 (validated
release worktrees only — misuse of it is exactly how the incident happened).
No override exists for the identity check.
Worktree Discipline (MANDATORY)
Always operate from a dedicated git worktree, never the main checkout.
git fetch origin main
git worktree add -b feature/publish-<crate> \
.claude/worktrees/publish-<crate> origin/main
cd .claude/worktrees/publish-<crate>
Why: Concurrent sessions may hold uncommitted work in the main checkout.
Worktrees are isolated. After a squash-merge, clean up the local branch:
git branch -D feature/publish-<crate>
git push origin --delete feature/publish-<crate>
macOS cdhash Trap (RED — High Impact)
NEVER do this:
cp target/release/<binary> ~/.cargo/bin/<binary>
The kernel caches code-signing identity by cdhash (executable hash).
A plain cp over an existing on-PATH binary leaves a stale cache.
The next exec is SIGKILL'd as:
EXC_CRASH / CODESIGNING — Taskgated Invalid Signature
zsh: killed (no output — looks exactly like OOM kill)
ALWAYS do this instead:
cargo install --path crates/<dir> --locked
cargo install writes to a temp file and renames atomically, keeping the
kernel cache consistent. If a manual copy is ever unavoidable:
cp target/release/<binary> ~/.cargo/bin/<binary>
codesign --force --sign - ~/.cargo/bin/<binary>
Pre-flight Quality Gates
All of these must pass. No --allow-dirty, --no-verify, or --force flags:
scripts/check-publish-ready.sh <crate>
cargo fmt --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test -p <crate>
cargo check --workspace
Abort if anything fails. Fix it, commit, and restart.
pnpm-artifact Cleanup (NEW — Learned Today)
Crates with embedded Svelte UIs (e.g., trusty-search/ui) may generate
untracked node_modules/ and pnpm-workspace.yaml that block dry-run.
If cargo publish --dry-run fails with "working directory is dirty":
git status
git clean -fdX
git status
Critical: Never clean tracked files. git clean -fdX removes only
.gitignore-listed files, not source code.
License Field Gotcha
crates.io rejects license = "Elastic-2.0" (not in SPDX registry).
For Elastic-2.0 licensed crates:
license = "Elastic-2.0"
license-file = "LICENSE"
For MIT licensed crates:
license = "MIT"
[patch.crates-io] Semantics
Local workspace builds resolve internal deps via path, ignoring
[patch.crates-io] overrides.
But cargo publish resolves ALL dependencies from the live crates.io
registry — the same view downstream consumers will see.
Implication: When crate A's public API changes and crate B depends on A
(via workspace = true), you MUST:
- Bump A's version in
crates/A/Cargo.toml
- Publish A to crates.io FIRST
- Wait 60-120s for propagation
- Then publish B
If you skip this: cargo publish --dry-run for B fails with
"dependency not found" because crates.io doesn't yet have A at the new version.
Cross-Crate Publish Ordering (RED — Common Pitfall)
The Recipe
- Identify all changed crates (from git diff, PR title, or description)
- Read each changed crate's
[dependencies] for workspace = true entries
- Resolve those deps to versions (check
Cargo.lock)
- Check crates.io for each dependency version:
curl -s https://crates.io/api/v1/crates/<crate>/<version> | head -c 100
JSON metadata = already live; 404 = not yet published
- Build a publish order: publish all missing versions first, wait for
propagation, then downstream crates
Dependency Publish Order (trusty-tools)
Publish library crates before the crates that depend on them. The ordering for this workspace:
trusty-common → trusty-mcp-core → trusty-embedder → trusty-symgraph
→ trusty-search, trusty-memory-core, trusty-analyze
→ trusty-mpm-core → trusty-mpm-client → trusty-mpm-daemon, trusty-mpm-mcp
→ trusty-mpm-cli, trusty-mpm-tui
If only a subset of these crates changed, publish only the changed ones and their direct downstream dependents, in order.
Worked Example From Today
Session publishes: trusty-common 0.8.0, trusty-search 0.13.1, tga 1.4.2
Analysis:
trusty-search depends on trusty-common (workspace = true, resolves to 0.8.0)
tga depends on trusty-common (workspace = true, resolves to 0.8.0)
- crates.io has trusty-common 0.7.0 but NOT 0.8.0 yet
Correct order:
- Publish trusty-common 0.8.0
- Sleep 100s, verify propagation
- Then publish trusty-search 0.13.1
- Then publish tga 1.4.2
What happened if we skipped:
cargo publish --dry-run -p trusty-search
Propagation Wait (60-120 seconds)
After cargo publish succeeds with status 200 OK:
Before publishing a crate that depends on this one, verify:
curl -s https://crates.io/api/v1/crates/<crate>/<version> | head -c 200
If 404 after 120s, something went wrong. Check:
cargo search <crate> --limit 1
Tag Pattern: -v
Use the crate package name from Cargo.toml, NOT the directory name.
Reference: Abbreviations table from CLAUDE.md:
trusty-git-analytics → -p tga → tag: tga-v1.4.2 ✓
trusty-search → -p trusty-search → tag: trusty-search-v0.13.1 ✓
trusty-common → -p trusty-common → tag: trusty-common-v0.8.0 ✓
open-mpm → -p open-mpm → tag: open-mpm-v0.2.3 ✓
tga tag aliases (issue #1128): the binary-release workflow accepts both
tga-v<version> and trusty-git-analytics-v<version> — they resolve to the
same build config and Homebrew formula (the parse step canonicalizes the tga
prefix to trusty-git-analytics). The documented form above (tga-v<version>,
matching the published package name) is preferred; you no longer need a second
trusty-git-analytics-v<version> tag to trigger a successful binary release.
The crate name always comes from the name field in Cargo.toml:
grep "^name = " crates/<dir>/Cargo.toml
Crate Name vs Directory Name
Most match (crates/trusty-search/ → -p trusty-search).
Exceptions (always verify Cargo.toml):
crates/trusty-git-analytics/ → name = "tga" → -p tga
crates/open-mpm/ → name = "open-mpm" → -p open-mpm
If cargo -p <name> returns "package not found":
grep "^name = " crates/<dir>/Cargo.toml
Single-Install Convention
A main crate's binary release must include every binary required to run
that crate. Sidecar daemons are bundled via [[bin]] shims pointing at the
sidecar's run() entry point.
Example: trusty-search
[[bin]]
name = "trusty-search"
path = "src/bin/main.rs"
[[bin]]
name = "trusty-embedder"
path = "src/bin/embedder.rs"
Users invoke:
cargo install trusty-search
publish=false Guard
Before running cargo publish for any crate, verify it is not marked non-publishable:
grep "publish" crates/<dir>/Cargo.toml
If the output contains publish = false, do not publish that crate. Common non-published crates include binary/CLI crates and internal tooling crates. When in doubt, read the manifest.
Sidecar Publish Rule (RED)
Sidecar lib crates whose lib is a dependency of a published main crate
MUST be published to crates.io.
Do NOT set publish = false on such crates. Example:
[package]
name = "trusty-embedder"
publish = true
Why: When you cargo publish -p trusty-search, Cargo's dependency
resolver requires every transitive lib dependency to exist on crates.io at
the declared version, even if the binary isn't published separately.
Downstream consumers don't manually install the sidecar, but Cargo's
resolution during their build REQUIRES it to be available.
If you set publish = false: cargo publish -p trusty-search --dry-run
fails with "dependency not found" because the sidecar lib can't be resolved.
Versioning Conventions
Semver Bump Rules (by Conventional Commit Type)
Always read the git log since the last tag to determine the correct bump before editing any version:
git log <crate-name>-v<last-version>..HEAD --oneline -- crates/<dir>/
Map commit types to semver components:
| Commit type | Version component |
|---|
feat: | MINOR (x.Y.0) |
fix:, chore:, perf:, refactor: | PATCH (x.y.Z) |
BREAKING CHANGE in footer, or ! suffix on any type | MAJOR (X.0.0) |
Examples by change type:
| Change Type | Example | Bump Rule |
|---|
| New public function | feat: add auth handler | Minor (x.y → x.y+1.0) |
| Bug fix | fix: resolve race in async | Patch (x.y.z → x.y.z+1) |
| Chore / perf / refactor | chore: update deps | Patch |
| BREAKING public API | feat!: remove deprecated fn | Major post-1.0; Minor pre-1.0 |
Workspace-pinned versions:
- Crates using
[workspace.package] (trusty-mpm-* family) bump together
- Edit version once in root
Cargo.toml, all members inherit it
- Tag each crate individually (
trusty-mpm-core-v<ver>, trusty-mpm-cli-v<ver>, etc.)
- Publish in dependency order: core first, then consumers
Checking the Last Released Version
git tag --list '<crate-name>-v*' | sort -V | tail -1
cargo search <crate-name> | head -3
Pre-Publish Sequence (Detailed)
cd /Volumes/Kemono/Users/masa/Projects/trusty-tools/.claude/worktrees/publish-<crate>
git status
vim crates/<crate>/Cargo.toml
vim Cargo.toml
cargo fmt --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test -p <crate>
cargo check --workspace
git status
git clean -fdX
git add -A
git commit -m "chore: bump <crate> to v<version>"
cargo publish --dry-run -p <crate>
git tag <crate>-v<version>
git push -u origin <crate>-v<version>
cargo publish -p <crate>
sleep 100
curl -s https://crates.io/api/v1/crates/<crate>/<version> | head -c 200
cargo install --path crates/<crate> --locked
<binary> --version
Worked Example: Two-Step Publish (trusty-common + trusty-search)
Scenario: trusty-common public API changed (breaking); trusty-search
depends on it. Both need to publish.
cd .claude/worktrees/publish-trusty-common
vim crates/trusty-common/Cargo.toml
cargo test -p trusty-common
git commit -m "chore: bump trusty-common to v0.8.0"
git tag trusty-common-v0.8.0
git push origin trusty-common-v0.8.0
cargo publish --dry-run -p trusty-common
cargo publish -p trusty-common
sleep 100
curl -s https://crates.io/api/v1/crates/trusty-common/0.8.0 | head -c 200
cd .claude/worktrees/publish-trusty-search
vim crates/trusty-search/Cargo.toml
cargo test -p trusty-search
git commit -m "chore: bump trusty-search to v0.13.1"
git tag trusty-search-v0.13.1
git push origin trusty-search-v0.13.1
cargo publish --dry-run -p trusty-search
cargo publish -p trusty-search
sleep 100
curl -s https://crates.io/api/v1/crates/trusty-search/0.13.1 | head -c 200
cargo install --path crates/trusty-search --locked
trusty-search --version
Common Dry-Run Failures & Remedies
"dependency X not found"
- That dependency hasn't been published yet or caches.io hasn't synced it
- Publish the dependency first, wait 100s, retry
"working directory is dirty / changes will not be published"
- Untracked files (especially
node_modules/, pnpm-workspace.yaml)
- Run
git clean -fdX (gitignored files only)
- Never use
-f alone (deletes all untracked, including source)
"version already exists"
- This version was already published
- Bump to a new version or verify you meant a different version
"license field is invalid"
- Using
license = "Elastic-2.0" (not in SPDX registry)
- Use
license-file = "LICENSE" instead
Cannot find package in workspace
- Wrong package name (e.g.,
-p trusty-git-analytics instead of -p tga)
- Check
name field in Cargo.toml
Git Tag / Release Convention (from CLAUDE.md)
Each crate is tagged independently: <crate-name>-v<version>
Release flow:
- Bump version in crate's
Cargo.toml
- Run
cargo test -p <crate> and lint checks
- Commit the version bump
- Create tag:
git tag <crate-name>-v<version>
- Push tag:
git push origin <crate-name>-v<version>
- Publish:
cargo publish -p <crate>
- Install binary (if applicable):
cargo install --path crates/<dir> --locked
Cleanup After Publishing
Once the PR merges and the main branch absorbs your commits:
git worktree remove --force .claude/worktrees/publish-<crate>
git branch -D feature/publish-<crate>
git push origin --delete feature/publish-<crate>
Quality Checklist
Before declaring a publish complete:
Connection-Safe Daemon Restart (issue #534)
When upgrading a launchd-managed trusty-* daemon (trusty-memory, trusty-search,
trusty-analyze), use SIGTERM via launchctl bootout — never
launchctl kickstart -k which sends SIGKILL and drops live connections.
Why SIGTERM instead of SIGKILL
As of issue #534, all three daemons implement graceful shutdown via
axum::serve(...).with_graceful_shutdown(trusty_common::shutdown_signal()).
When SIGTERM arrives:
- The daemon stops accepting new connections.
- All in-flight requests are drained (allowed to complete normally).
- Cleanup code runs (addr files removed, BM25 supervisor reaped, etc.).
- The process exits cleanly.
SIGKILL bypasses all of this: active requests die mid-stream, cleanup is
skipped, and the mcp_bridge in the Claude Code session receives an abrupt
socket close.
Safe upgrade sequence (macOS launchd)
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/<label>.plist
cargo install --path crates/<crate-dir> --locked
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/<label>.plist
Do NOT use launchctl kickstart -k <label> — the -k flag sends SIGKILL
to the running instance before starting a new one, which kills in-flight
requests without draining.
When to restart
Prefer restarting between Claude Code sessions (i.e., when no .mcp.json
MCP bridge process is actively connected). Even with graceful shutdown, the
mcp_bridge will need to reconnect after a restart — it does so automatically
with exponential backoff (200ms → 30s cap), so brief mid-session restarts are
now transparent to Claude Code for requests that were between calls. Restarts
during an active in-flight request will still lose that one request.
References
- CLAUDE.md: "Build and Test Commands", "Git Tag / Release Convention", "Parallel Worktree Discipline"
- GitHub: Release tag format at
https://github.com/bobmatnyc/trusty-tools/releases
- crates.io API:
https://crates.io/api/v1/crates/<name>/<version>