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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
name: Publish Crates
# Publishes the workspace crates to crates.io in dependency order when a
# version tag (e.g. v0.4.0) is pushed. Can also be run manually for a dry run.
on:
push:
tags:
workflow_dispatch:
inputs:
dry_run:
description: "Package and verify without uploading"
type: boolean
default: true
skip_semver_checks:
description: "Skip cargo-semver-checks (escape hatch for false positives)"
type: boolean
default: false
env:
CARGO_TERM_COLOR: always
# Workspace publishing (`cargo publish --workspace`, stabilized in 1.90)
# runs on the repo toolchain pin. Keep in sync with rust-toolchain.toml.
RUST_TOOLCHAIN: "1.96.1"
jobs:
publish:
name: Publish to crates.io
runs-on: ubuntu-latest
permissions:
# Needed to create the GitHub Release after a successful publish.
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # 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 }}
# Guard against tagging a release whose version doesn't match the
# workspace. The tag drives the release; the manifest must agree.
- name: Verify tag matches workspace version
if: github.ref_type == 'tag'
run: |
tag="${GITHUB_REF_NAME#v}"
ws=$(cargo metadata --format-version 1 --no-deps \
| jq -r '.packages[] | select(.name=="macp-runtime") | .version')
echo "tag=$tag workspace=$ws"
if [ "$tag" != "$ws" ]; then
echo "::error::tag $GITHUB_REF_NAME does not match workspace version $ws"
exit 1
fi
# A release tag without release notes is a process bug: the CHANGELOG
# must have a section for the version being published.
- name: Verify CHANGELOG documents this version
if: github.ref_type == 'tag'
run: |
tag="${GITHUB_REF_NAME#v}"
if ! grep -Eq "^## \[$tag\]" CHANGELOG.md; then
echo "::error::CHANGELOG.md has no '## [$tag]' section for this release"
exit 1
fi
- name: Install cargo-semver-checks
if: ${{ !inputs.skip_semver_checks }}
uses: taiki-e/install-action@899b013517f9e7774591216672bf75a46bb9a481 # v2.9.4
with:
tool: cargo-semver-checks
# Blocking: the workspace version bump must be adequate for the API
# changes since the last published release (0.x minor bumps permit
# breaking changes per cargo's semver interpretation). Escape hatch:
# the skip_semver_checks dispatch input.
- name: Semver check against latest published release
if: ${{ !inputs.skip_semver_checks }}
run: cargo semver-checks --workspace
# `cargo publish --workspace` computes the dependency order itself, builds
# each crate's verify step against its sibling path deps (so a dry-run
# validates the whole graph without anything on the index yet), and waits
# for each upload to appear on the index before publishing its dependents.
#
# Re-run safety: a 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: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
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
# Tag-driven releases also get a GitHub Release whose notes are the
# CHANGELOG section for the version (verified to exist above).
- name: Create GitHub Release
if: github.ref_type == 'tag'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME#v}"
# Extract the "## [<tag>]" section body (up to the next "## [").
awk -v ver="$tag" '
$0 ~ "^## \\[" ver "\\]" { hit = 1; next }
hit && /^## \[/ { exit }
hit { print }
' CHANGELOG.md > release-notes.md
if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "release $GITHUB_REF_NAME already exists; skipping (re-run safe)"
else
gh release create "$GITHUB_REF_NAME" \
--repo "$GITHUB_REPOSITORY" \
--title "macp-runtime $GITHUB_REF_NAME" \
--notes-file release-notes.md
fi