1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
name: Publish Crates
# Sole crates.io publisher for the workspace.
#
# Division of labour with release-plz.yml:
# - release-plz owns versioning + git: it opens the version-bump PR and, on
# merge, creates the git tags and the GitHub Release. Its config sets
# `publish = false`, so it does NOT upload to crates.io.
# - THIS workflow uploads to crates.io. `cargo publish --workspace` computes
# the 7-crate dependency order itself and waits for index propagation
# between crates — which release-plz's own publisher does not, and which
# previously caused a partial publish (macp-modes tried to build against an
# unpublished macp-policy).
#
# Invoked three ways:
# - `workflow_call` from release-plz.yml, immediately after it creates the
# tags. THIS is the live path.
# - the `macp-runtime-v*` tag push. Kept as a backstop, but note it does NOT
# fire for tags release-plz creates: GitHub does not start workflow runs
# from events made with the default GITHUB_TOKEN (the recursion guard), so
# this trigger sat inert and 0.6.1 was tagged 2026-07-12 and never reached
# crates.io. It still fires for a tag pushed by a human or a PAT.
# - `workflow_dispatch`, defaulting to a dry run, for recovery.
on:
workflow_call:
inputs:
dry_run:
description: "Package and verify without uploading"
type: boolean
default: false
secrets:
CARGO_REGISTRY_TOKEN:
required: true
push:
tags:
workflow_dispatch:
inputs:
dry_run:
description: "Package and verify without uploading"
type: boolean
default: true
env:
CARGO_TERM_COLOR: always
# Keep in sync with rust-toolchain.toml.
RUST_TOOLCHAIN: "1.96.1"
jobs:
publish:
name: Publish to crates.io
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- name: Install protoc
uses: arduino/setup-protoc@f4d5893b897028ff5739576ea0409746887fa536 # v3.0.0
with:
version: "31.x"
repo-token: ${{ github.token }}
# `cargo publish --workspace` (stable since 1.90) computes the dependency
# order itself, builds each crate's verify step against its sibling path
# deps, and waits for each upload to appear on the index before publishing
# its dependents.
#
# Re-run safety: any crate whose current version is already live is passed
# via --exclude, so re-running after a mid-release failure skips the
# already-published members instead of erroring on "already exists".
- name: Publish workspace to crates.io
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
DRY_RUN: ${{ inputs.dry_run || false }}
run: |
set -euo pipefail
is_published() {
local name="$1" ver="$2" code
code=$(curl -s -o /dev/null -w '%{http_code}' \
-A "macp-runtime-release (https://github.com/multiagentcoordinationprotocol/macp-runtime)" \
"https://crates.io/api/v1/crates/$name/$ver")
[ "$code" = "200" ]
}
total=0
exclude_args=()
while read -r name ver; do
total=$((total + 1))
if is_published "$name" "$ver"; then
echo "==> skip $name@$ver (already on crates.io)"
exclude_args+=(--exclude "$name")
fi
done < <(cargo metadata --format-version 1 --no-deps \
| jq -r '.packages[] | "\(.name) \(.version)"')
if [ "${#exclude_args[@]}" -eq $((total * 2)) ]; then
echo "All workspace crates already published; nothing to do."
exit 0
fi
# ${arr[@]+...} guards against an empty array tripping `set -u`.
if [ "${DRY_RUN:-false}" = "true" ]; then
echo "==> dry-run publish workspace"
cargo publish --workspace --dry-run --locked ${exclude_args[@]+"${exclude_args[@]}"}
else
echo "==> publish workspace"
cargo publish --workspace --locked ${exclude_args[@]+"${exclude_args[@]}"}
fi