# Cargo Publish

> Publish one or more Rust crates to crates.io from the trusty-tools workspace

- Skill: `bobmatnyc/cargo-publish` (Agent Skill)
- Install (CLI): `npx skillmds@latest add bobmatnyc/cargo-publish`
- Raw SKILL.md: https://api.skillmd.com/api/skills/bobmatnyc/cargo-publish/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: bobmatnyc (https://skillmd.com/u/bobmatnyc)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/bobmatnyc/cargo-publish

---


# 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
2b. scripts/preflight-publish.sh --check-only <crate>  — MANDATORY, MUST PASS
    BEFORE TAGGING (#6508): decides whether tagging is allowed. Tags here are
    immutable (#6178), so a failure found only AFTER tagging strands the
    version — this is what trusty-common 0.46.1 and 0.46.3 both burned this
    week.
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; this run also binds the tag to the commit via CHECK 6, which step 2b cannot do — no tag exists yet at that point)
6. cargo publish
6b. scripts/check-tag-publish-parity.sh --vcs-info auto <crate>  — confirm the
    tag names the commit cargo actually recorded
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. Never
tag before step 2b passes.

## 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:

```bash
scripts/check-publish-ready.sh <crate-name-or-dir>
# or
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):

1. **GUARD 1 (merged-main)**: current HEAD is `origin/main` itself or an
   ancestor of it.
2. **GUARD 2 (version tag)**: the release tag `<crate>-v<version>` has been
   pushed to origin and its commit is on merged main. For `trusty-git-analytics`,
   push only `trusty-git-analytics-v<version>`; never push a `tga-v*` alias.

**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."

**Also now enforced independently of any human running a script (issue
#3366)**: `.github/workflows/release.yml`'s `preflight` job verifies the SAME
rule mechanically for the tag-triggered binary-release pipeline — a real
ancestry check (`git merge-base --is-ancestor` against a full-history clone
of the tagged commit vs. `origin/main`), not a naive ref-string comparison
(which would never match on a tag push and would silently disable every
release). A tag pushed from an unmerged branch now fails that job loudly and
the entire pipeline (build/release/homebrew-bump AND the publish-dry-run job
below) is skipped — this is a second, independent enforcement point, not a
replacement for running `check-publish-ready.sh` yourself before `cargo
publish`.

## Step 2b: Pre-Tag Gate (MANDATORY, issue #6508)

🔴 **Run `scripts/preflight-publish.sh --check-only <crate>` BEFORE `git tag`
— it is the gate that decides whether tagging is allowed.** Tags on this repo
are immutable (#6178); a preflight failure discovered only after the tag is
already pushed cannot be fixed by moving or deleting the tag, so it burns the
version number. trusty-common 0.46.1 and 0.46.3 both burned a version this
week this way — the canonical workflow tagged and pushed BEFORE running
preflight, so CHECK 5 (semver) failing there stranded an already-pushed tag.

```bash
scripts/preflight-publish.sh --check-only trusty-mpm
```

This runs every check unconditionally, including the checks that don't need a
tag yet (semver, identity, clean-tree, version-not-live, UI bundle freshness,
the pre-publish gate, the changelog assembler). CHECK 6 (tag/publish-commit
parity, Step 5 below) is the one exception: a `TAG-MISSING` finding here is
the expected pre-tag state, not a failure — `--check-only` reports it as
`[SKIP]` and does not block on it. Only tag once this run passes clean.

The full (non-`--check-only`) run in Step 5 below is the POST-TAG gate and
mechanically enforces this ordering: it refuses to certify a run — failing
the same way any other check does, printing the exact `--check-only` command
above — when no candidate tag for the target version exists locally yet.
This does not replace the procedural rule; it catches the case where someone
reaches for the full run as a substitute for the pre-tag one.

## 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:

```bash
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 six checks and fails loud on any of them:

1. **merged-main**: current HEAD's commit SHA is EXACTLY `origin/main`'s HEAD
   SHA (stricter than `check-publish-ready.sh`'s ancestor check).
2. **identity**: the active `gh auth status` account is `bobmatnyc`. Any
   other active account fails with the remedy `gh auth switch --user bobmatnyc`.
3. **clean-tree**: `git status --porcelain` is empty.
4. **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.
5. **semver** (#5149): runs `scripts/check_semver.sh --crate <pkg>`, which
   compares the crate's public API against its latest non-yanked crates.io
   release and fails when a break is not carried by a breaking version bump
   (0.x crates break in the MINOR position). This is the ONLY place that can
   block a bad publish — a crates.io upload is irreversible except by yank, and
   #4088 is what a gate arriving afterwards costs. Requires
   `cargo install cargo-semver-checks@0.50.0 --locked`; a missing tool is a
   failure, not a skip. No override — the fix is to bump the breaking position,
   which the gate then skips as an already-breaking release.
   `.github/workflows/semver-checks.yml` runs the same check on the tag push
   (step 4), so a red run there is visible before you reach step 6.

6. **tag/publish-commit parity**: the release tag `<crate>-v<version>` must
   name EXACTLY the commit this publish will ship. For `trusty-git-analytics`,
   this is always `trusty-git-analytics-v<version>`. Delegates to
   `scripts/check-tag-publish-parity.sh`. No override.

   **Why this is not already covered by 1-5 or by `check-publish-ready.sh`:**
   nothing bound the tag to the upload. GUARD 2 asks whether the tag is an
   ANCESTOR of `origin/main`; CHECK 1 asks whether HEAD EQUALS `origin/main`.
   A tag several commits behind HEAD satisfies both — which is where a release
   run lands whenever main moves and the run is fast-forwarded to satisfy CHECK
   1. On 2026-08-11 an earlier tga tag pointed at an older commit while the
   published `.cargo_vcs_info.json` recorded a later one, with every gate green.

   🔴 **If you fast-forward this checkout after tagging, reset back onto the
   tag before publishing.** `git reset --hard <tag>`, then re-run preflight.
   The gate prints that command with the SHAs filled in.

   🔴 **Never reach for `git tag -f` or `git tag -d` here — tags on this repo
   are immutable (#6178).** An enforcing ruleset rejects both a force-update
   and a delete with `GH013`, `admin: true` does not lift it, and the ruleset
   is invisible to the GitHub API, so you find out when the push fails. Move
   the checkout onto the tag; never the tag onto the checkout.

   If the tagged commit can never pass the publish gate, that version number is
   **burned** — bump to the next version and tag fresh. Proven 2026-08-22:
   `trusty-search-v0.49.0` and `trusty-review-v0.24.0` are permanently stranded
   at `4af0ef8ee`, and the release shipped as `0.49.1` / `0.24.1`. Tag as late
   as possible, immediately before `cargo publish`, so nothing can strand.

   After `cargo publish`, verify what cargo recorded rather than what it should
   have recorded — the only check that still works post-upload:

   ```bash
   scripts/check-tag-publish-parity.sh --vcs-info auto <crate>
   ```

   Full rationale, the four finding codes, and the residual gap:
   [docs/reference/release-workflow.md](../../../docs/reference/release-workflow.md#tagpublish-commit-parity-guard).

`scripts/preflight-publish.sh --check-only <crate>` runs all checks
unconditionally and prints a `[PASS]`/`[FAIL]`/`[SKIP]` line per check without
assuming you're mid-publish — this is the Step 2b pre-tag gate above, MANDATORY
before `git tag`, not just a status preview. `--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. Publishing `trusty-audit` has one extra
obligation: run `scripts/refresh-engagement-pins.sh` and commit the result before
the bump, because CHECK 10 fails the release when a `[tools]` pin in
`crates/trusty-audit/templates/engagement.template.toml` lags a sibling whose
workspace version this same train is about to publish (#6772).

## Step 6: Version-Parity Guard (MANDATORY, issue #3366)

Before bumping a crate's version to publish, confirm the crate ISN'T already
drifted — i.e. that its CURRENT (pre-bump) Cargo.toml version, if already
live on crates.io, still matches the local `src/` tree. This is the
`trusty-common`/`trusty-agents-common` incident: several commits of source
changes landed on `main` without a version bump while the old version number
was already published, so `cargo publish --dry-run` for a downstream crate
failed with "symbol not found" for symbols that only existed in the drifted,
unpublished source.

```bash
make version-parity-check
# or directly:
scripts/check-version-parity.sh
```

This also runs automatically on every push to `main`
(`.github/workflows/version-parity.yml`) so drift is caught right after it
merges rather than discovered as a release blocker. See
`crates/trusty-publish-guard` for the underlying check (fails closed: a
crate whose live/local comparison can't be verified is treated as a failure,
never a silent pass).

For the RELATED but distinct cross-crate ordering hazard (publishing a crate
before a sibling it depends on is live — the 2026-07-20 incident), see
`scripts/publish-dry-run-order.sh` in "Cross-Crate Publish Ordering" below —
it now computes the dependency order mechanically instead of by hand.

## Worktree Discipline (MANDATORY)

Always operate from a dedicated git worktree, never the main checkout.

```bash
# Provision a fresh worktree off origin/main
git fetch origin main
git worktree add -b feature/publish-<crate> \
    .claude/worktrees/publish-<crate> origin/main
cd .claude/worktrees/publish-<crate>

# Work, test, tag, and push from inside this worktree
# When complete: report the worktree path to the PM — see Cleanup below
```

**Why**: Concurrent sessions may hold uncommitted work in the main checkout.
Worktrees are isolated.

🔴 **Do not remove the worktree yourself (#5791).** `tm hook --pm-guard` denies
a dispatched agent's `git worktree remove`, so reaching for it mid-publish
fails; `rm -rf` is not the workaround. Report the path and let the PM run the
prune verb — see "Cleanup After Publishing" below.

## macOS cdhash Trap (RED — High Impact)

**NEVER do this**:
```bash
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**:
```bash
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:
```bash
cp target/release/<binary> ~/.cargo/bin/<binary>
codesign --force --sign - ~/.cargo/bin/<binary>  # Regenerate signature
```

## Pre-flight Quality Gates

All of these must pass. No `--allow-dirty`, `--no-verify`, or `--force` flags:

```bash
scripts/check-publish-ready.sh <crate>   # Step 0 — merged-main guard (issue #2227)
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":

```bash
git status                       # Eyeball what's flagged
git clean -fdX                   # Remove ONLY gitignored content (-X flag)
# If specific untracked yaml remains after review, rm it explicitly
git status                       # Verify clean
```

**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**:
```toml
# ✗ WRONG
license = "Elastic-2.0"

# ✓ CORRECT
license-file = "LICENSE"
```

**For MIT licensed crates**:
```toml
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:

1. Bump A's version in `crates/A/Cargo.toml`
2. **Publish A to crates.io FIRST**
3. **Wait 60-120s for propagation**
4. **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)

**Automated and CI-enforced (issue #3366)**: `scripts/publish-dry-run-order.sh`
computes the dependency order mechanically from `cargo metadata` and runs
`cargo publish --dry-run -p <crate>` for every publishable crate in that
order (dependencies before dependents), stopping at the first failure — this
replaces re-deriving the manual recipe below by hand.

This now ALSO runs automatically, with no human action required: every
`*-v*` tag push runs `.github/workflows/release.yml`'s `publish-dry-run` job,
which invokes this script scoped to just the tagged crate + its publishable
dependency closure. A human forgetting to run the manual command below no
longer means an unsafe publish goes unnoticed — the tag push itself proves
(or disproves) publish-safety, independent of whether anyone remembered the
manual step. See that job's header comment for why it is scoped
per-tag/per-crate rather than full-workspace-per-PR (cost, registry rate
limits), and why it is deliberately independent of (not blocking) the binary
build/release jobs.

For a partial-release dry run before you've even tagged anything, or to dry
run a set of crates together, use the script directly. Pass specific crate
names to restrict it (it still includes any publishable crate they depend
on, in order):

```bash
scripts/publish-dry-run-order.sh --list-only          # print the order, run nothing
scripts/publish-dry-run-order.sh                      # dry-run every publishable crate, in order
scripts/publish-dry-run-order.sh trusty-search trusty-common
```

The manual recipe below remains useful for reasoning about WHY a given order
is correct, and the crate list in "Dependency Publish Order" further down is
historical/illustrative rather than mechanically maintained — trust the
script's computed order, not that hand-written list.

### The Recipe

1. **Identify all changed crates** (from git diff, PR title, or description)
2. **Read each changed crate's `[dependencies]`** for `workspace = true` entries
3. **Resolve those deps to versions** (check `Cargo.lock`)
4. **Check crates.io** for each dependency version:
   ```bash
   curl -s https://crates.io/api/v1/crates/<crate>/<version> | head -c 100
   ```
   JSON metadata = already live; 404 = not yet published
5. **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**:
1. Publish trusty-common 0.8.0
2. Sleep 100s, verify propagation
3. Then publish trusty-search 0.13.1
4. Then publish tga 1.4.2

**What happened if we skipped**:
```bash
cargo publish --dry-run -p trusty-search
# ERROR: dependency trusty-common v0.8.0 not found on crates.io
# (because we only just published it, crates.io needs 60-120s)
```

## Propagation Wait (60-120 seconds)

After `cargo publish` succeeds with status 200 OK:

```bash
# Immediately after: ✓ crates.io ingestion complete
# Next 60-120s: ✓ metadata replicating to CDN, search index updating
```

**Before publishing a crate that depends on this one**, verify:

```bash
# Wait ~100s, then check
curl -s https://crates.io/api/v1/crates/<crate>/<version> | head -c 200

# Success: JSON metadata appears (version is now live)
# {"crate":{"name":"...","versions":[...]},...}

# Still waiting: 404 Not Found
# {"errors":[{"detail":"Crate not found"}]}
```

If 404 after 120s, something went wrong. Check:
```bash
cargo search <crate> --limit 1
```

## Tag Pattern: <crate-directory-name>-v<version>

Use the **crate directory name** under `crates/`, NOT the package name from
`Cargo.toml`. This matches `tag_prefix_for()` in `scripts/check-publish-ready.sh`,
which derives the prefix from the directory unconditionally.

**Reference: Abbreviations table from CLAUDE.md**:
- `trusty-git-analytics` → `-p tga` → tag: **`trusty-git-analytics-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`** ✓
- `trusty-agents` → `-p trusty-agents` → tag: **`trusty-agents-v0.2.3`** ✓

> **tga tag aliases (issue #1128) — push ONLY the canonical tag:** push
> `trusty-git-analytics-v<version>` only. Never push a `tga-v*` alias tag.
> Since #6771 landed in trusty-installer 0.13.5 (PR #6799), the installer
> resolves either spelling correctly, and two tags mean two independent
> non-reproducible builds whose digests differ — the installer refuses this as
> TAG-SPLIT (#1128, #6771). If an alias was pushed by mistake, delete its
> GitHub Release object with `gh release delete tga-v<version> --yes` (no
> --cleanup-tag; the tag is immutable and harmless without a Release). Then
> verify `bobmatnyc/homebrew-trusty` Formula/trusty-git-analytics.rb points at
> the canonical release's URLs and digests, because whichever CI run finished
> last wrote the formula. Release tags are immutable (#6178).

The crate name **always** comes from the `name` field in `Cargo.toml`:

```bash
# Inside the worktree, when in doubt:
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`

If `cargo -p <name>` returns "package not found":
```bash
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**
```toml
# crates/trusty-search/Cargo.toml
[[bin]]
name = "trusty-search"
path = "src/bin/main.rs"

[[bin]]
name = "trusty-embedder"  # Sidecar daemon (optional utility)
path = "src/bin/embedder.rs"
```

Users invoke:
```bash
cargo install trusty-search
# Both trusty-search AND trusty-embedder land in ~/.cargo/bin/
```

## publish=false Guard

Before running `cargo publish` for any crate, verify it is not marked non-publishable:

```bash
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:

```toml
# crates/trusty-embedder/Cargo.toml
[package]
name = "trusty-embedder"
publish = true  # ← REQUIRED even if users never cargo install it directly
```

**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:

```bash
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

```bash
# From git tags
git tag --list '<crate-name>-v*' | sort -V | tail -1

# From crates.io (if published)
cargo search <crate-name> | head -3
```

## Pre-Publish Sequence (Detailed)

```bash
# 1. Inside worktree, change to repo root
cd /Volumes/Kemono/Users/masa/Projects/trusty-tools/.claude/worktrees/publish-<crate>

# 2. Verify on origin/main (git status should be clean from worktree creation)
git status

# 3. Edit version(s) in Cargo.toml
vim crates/<crate>/Cargo.toml
# or for workspace-pinned:
vim Cargo.toml

# 4. Run pre-flight checks
cargo fmt --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test -p <crate>
cargo check --workspace

# 5. If UI changes (trusty-search, trusty-memory, trusty-analyze, trusty-console),
#    check for stale artifacts AND stale bundle content (issue #3606)
git status  # Look for node_modules/ or pnpm-workspace.yaml
git clean -fdX  # If present
#    Freshness (not just presence) matters: verify the COMMITTED ui-dist/ /
#    ui/dist/ bundle actually reflects current ui/src — cargo publish cannot
#    catch this for you (see step 7 note). Check the source digest directly
#    (no rebuild needed):
#      bash scripts/check-ui-bundle-freshness.sh <crate>
#    If it reports BUNDLE-STALE, rebuild and re-stamp before continuing:
#      cd crates/<crate>/ui && pnpm install --frozen-lockfile && pnpm run build
#      # trusty-search only: also copy ui/dist/* into ../ui-dist/ (or `make release-prep`)
#      bash scripts/stamp-ui-bundle.sh <crate>
#    then commit the regenerated bundle. `.github/workflows/ui-bundle-freshness.yml`
#    runs this same check on every push to `main`, and `preflight-publish.sh`
#    CHECK 7 runs it again immediately before step 7's dry-run — but catching
#    it here, before tagging, is cheaper.

# 6. Commit version bump
git add -A
git commit -m "chore: bump <crate> to v<version>"

# 7. Dry run (essential — catches dependency issues early)
# UI-embedding crates (trusty-search, trusty-memory, trusty-analyze,
# trusty-console) REQUIRE SKIP_UI_BUILD=1 here — and this is not optional for
# trusty-search/trusty-console specifically: their Cargo.toml `include` list
# ships only the pre-built bundle, never `ui/src` (verified via `cargo
# package --list`), so cargo has nothing to rebuild from during packaging
# either way. This dry-run therefore verifies the RUST package publishes
# cleanly — it provides NO signal on UI bundle freshness (that's step 5 /
# check-ui-bundle-freshness.sh, a structurally separate concern).
SKIP_UI_BUILD=1 cargo publish --dry-run -p <crate>

# 8. If dry-run fails:
#    - Read the error (usually "dependency X version Y not found on crates.io")
#    - Publish that dependency first
#    - Wait 100s
#    - Retry this crate's dry-run

# 8b. Pre-tag gate (MANDATORY, #6508) — MUST PASS before tagging. Tags here
#     are immutable (#6178); a failure found only after tagging strands the
#     version, which is what burned trusty-common 0.46.1 and 0.46.3 this week.
scripts/preflight-publish.sh --check-only <crate>

# 9. Tag
git tag <crate>-v<version>

# 10. Push tag to origin
git push -u origin <crate>-v<version>

# 10b. Full preflight gate (MANDATORY, Step 5 above) — run again now that the
#      tag exists; this run is what binds the tag to the commit (CHECK 6).
scripts/preflight-publish.sh <crate>

# 11. Publish to crates.io (same SKIP_UI_BUILD=1 requirement as step 7, for
#     the same reason)
SKIP_UI_BUILD=1 cargo publish -p <crate>

# 12. Verification step
sleep 100
curl -s https://crates.io/api/v1/crates/<crate>/<version> | head -c 200

# 13. For binaries: install locally
cargo install --path crates/<crate> --locked

# 14. Verify binary version
<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.

```bash
# === STEP 1: PUBLISH trusty-common 0.8.0 ===
cd .claude/worktrees/publish-trusty-common

# Edit, test, commit
vim crates/trusty-common/Cargo.toml  # 0.7.0 → 0.8.0
cargo test -p trusty-common
git commit -m "chore: bump trusty-common to v0.8.0"

# Pre-tag gate (MANDATORY, #6508) — must pass BEFORE tagging
scripts/preflight-publish.sh --check-only trusty-common

# Tag
git tag trusty-common-v0.8.0
git push origin trusty-common-v0.8.0

# Dry run
cargo publish --dry-run -p trusty-common  # ✓ PASS

# Publish
cargo publish -p trusty-common

# === PROPAGATION WAIT ===
sleep 100

# Verify on crates.io
curl -s https://crates.io/api/v1/crates/trusty-common/0.8.0 | head -c 200
# {"crate":{"name":"trusty-common",...},"versions":[...],...}  ← LIVE

# === STEP 2: PUBLISH trusty-search 0.13.1 ===
cd .claude/worktrees/publish-trusty-search

# Edit, test, commit
vim crates/trusty-search/Cargo.toml  # 0.13.0 → 0.13.1
cargo test -p trusty-search
git commit -m "chore: bump trusty-search to v0.13.1"

# Pre-tag gate (MANDATORY, #6508) — must pass BEFORE tagging
scripts/preflight-publish.sh --check-only trusty-search

# Tag
git tag trusty-search-v0.13.1
git push origin trusty-search-v0.13.1

# Dry run (now trusty-common 0.8.0 IS on crates.io)
cargo publish --dry-run -p trusty-search  # ✓ PASS

# Publish
cargo publish -p trusty-search

# Verify
sleep 100
curl -s https://crates.io/api/v1/crates/trusty-search/0.13.1 | head -c 200

# Install
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:
1. Bump version in crate's `Cargo.toml`
2. Run `cargo test -p <crate>` and lint checks
3. Commit the version bump
4. Create tag: `git tag <crate-name>-v<version>`
5. Push tag: `git push origin <crate-name>-v<version>`
6. Publish: `cargo publish -p <crate>`
7. Install binary (if applicable): `cargo install --path crates/<dir> --locked`

## Cleanup After Publishing

Once the PR merges and the main branch absorbs your commits, report — do not
remove. Worktree removal is PM-executed (#5791, owner ruling 2026-08-19): name
the merged PR, the worktree path (`.claude/worktrees/publish-<crate>`), and the
branch (`feature/publish-<crate>`), then stop.

The PM reclaims the tree:

```bash
tm session prune-worktrees --merged-prs            # preview, the default
tm session prune-worktrees --merged-prs --force    # reclaim
```

That pass removes the checkout only. The remote branch is usually already gone
via `gh pr merge --delete-branch`; the local branch survives the sweep.

## Quality Checklist

Before declaring a publish complete:

- [ ] `scripts/check-publish-ready.sh <crate>` (or `make publish-check CRATE=<crate>`) passed
- [ ] Pre-flight checks passed (fmt, clippy, tests, check)
- [ ] Dry-run succeeded
- [ ] `scripts/preflight-publish.sh --check-only <crate>` passed — MANDATORY
      before tagging (#6508); tags are immutable, so this is what catches a
      semver/changelog/gate failure before it can strand one
- [ ] Tag created with correct name pattern
- [ ] Tag pushed to origin
- [ ] `scripts/preflight-publish.sh <crate>` (full run) passed again, now that
      the tag exists — this is what binds the tag to the commit (CHECK 6)
- [ ] `cargo publish` succeeded (status 200 OK)
- [ ] Waited 100s and verified on crates.io API
- [ ] Binary installed with `cargo install --path … --locked` (if applicable)
- [ ] `<binary> --version` shows correct version
- [ ] Worktree path and branch reported to the PM for its prune verb (#5791 — never removed by the agent)
- [ ] Remote branch cleaned up

## 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:

1. The daemon stops accepting new connections.
2. All in-flight requests are drained (allowed to complete normally).
3. Cleanup code runs (addr files removed, BM25 supervisor reaped, etc.).
4. 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)

```bash
# 1. Stop the daemon gracefully (SIGTERM → drain → exit)
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/<label>.plist

# 2. Rebuild and install the new binary
cargo install --path crates/<crate-dir> --locked

# 3. Restart the daemon
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>`

