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
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
name: Release
# Publishes both crates to crates.io when a version tag (e.g. v0.1.0) is pushed.
#
# Publish order is FIXED: `metaflux-client` (root) first, then `metaflux`
# (facade). The facade depends on `metaflux-client = "0.1.0"` from crates.io, so
# the root crate must exist on the registry before the facade can resolve.
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: "Version tag to release (e.g. v0.1.0). Must be an EXISTING pushed tag and match the Cargo.toml versions."
required: true
type: string
# Least privilege: the job only reads the repo and uploads to crates.io via the
# CARGO_REGISTRY_TOKEN secret. It never writes back to GitHub.
permissions:
contents: read
# Serialize releases: never let two release runs race the ordered publish.
# Key the group on the resolved release tag (NOT github.ref): on a tag push
# github.ref_name is `v0.1.0`, while on workflow_dispatch github.ref is the
# branch (refs/heads/main). Keying on github.ref would put a tag-triggered run
# and a manual dispatch for the SAME version in different groups, letting them
# race the ordered publish. github.ref_name is `v0.1.0` on a tag push and the
# input on dispatch, so both triggers for one version share a single lock.
# Do NOT cancel an in-flight run — interrupting a mid-upload publish is unsafe.
concurrency:
group: release-${{ github.event.inputs.tag || github.ref_name }}
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# cargo reads this automatically for `cargo publish`; it is never echoed.
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
jobs:
release:
name: Publish to crates.io
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# Resolve the release tag from either the push ref or the manual input,
# strip the leading `v`, and assert it matches BOTH Cargo.toml versions.
# A mistagged release aborts here, before any upload.
#
# On workflow_dispatch we also assert the tag exists as a real git ref
# BEFORE relying on it: checkout already resolved `inputs.tag` to a commit,
# so re-verifying refs/tags/<tag> turns a typo into a clear "tag not found"
# message instead of leaking through as a confusing version mismatch.
- name: Assert tag matches Cargo.toml versions
id: guard
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TAG="${{ github.event.inputs.tag }}"
if ! git rev-parse --verify --quiet "refs/tags/${TAG}" >/dev/null; then
echo "::error::Tag '${TAG}' does not exist as a pushed git tag. Push the tag first, then re-run."
exit 1
fi
else
TAG="${GITHUB_REF#refs/tags/}"
fi
echo "Release tag: ${TAG}"
case "${TAG}" in
v*) ;;
*) echo "::error::Tag '${TAG}' does not start with 'v'." ; exit 1 ;;
esac
TAG_VERSION="${TAG#v}"
# `cargo metadata` is the source of truth; avoids brittle grep over the
# raw manifests.
ROOT_VERSION="$(cargo metadata --no-deps --format-version 1 \
| jq -r '.packages[] | select(.name == "metaflux-client") | .version')"
FACADE_VERSION="$(cargo metadata --no-deps --format-version 1 \
| jq -r '.packages[] | select(.name == "metaflux") | .version')"
echo "tag=${TAG_VERSION} metaflux-client=${ROOT_VERSION} metaflux=${FACADE_VERSION}"
if [ "${TAG_VERSION}" != "${ROOT_VERSION}" ]; then
echo "::error::Tag version '${TAG_VERSION}' != metaflux-client Cargo.toml version '${ROOT_VERSION}'."
exit 1
fi
if [ "${TAG_VERSION}" != "${FACADE_VERSION}" ]; then
echo "::error::Tag version '${TAG_VERSION}' != metaflux (facade) Cargo.toml version '${FACADE_VERSION}'."
exit 1
fi
echo "version=${TAG_VERSION}" >> "${GITHUB_OUTPUT}"
# Pre-publish gate: build + test against the committed lockfile so we never
# publish something that fails to compile or pass tests. Mirrors CI's
# all-features + no-default-features coverage.
- name: Build and test (gate)
run: |
set -euo pipefail
cargo build --locked --all-features
cargo test --locked --all-features
cargo test --locked --no-default-features
# Packaging sanity check for the root crate before any real upload.
#
# Deliberately WITHOUT --locked: `cargo publish` verify builds the packaged
# crate in a temp dir and regenerates a lockfile there, which can differ
# from the workspace Cargo.lock and make --locked spuriously fail with
# "the lock file ... needs to be updated". Because this dry-run is a
# pre-publish GATE, such a spurious failure would abort the entire release
# before anything is published. The --locked reproducibility guarantee is
# already covered by the build/test gate above and by the real publish
# steps below, so dropping it here only affects the sanity check.
#
# The facade dry-run is intentionally skipped: it cannot resolve
# metaflux-client from crates.io until the root crate is published, and the
# build/test gate already compiled it via the path dependency.
- name: Package dry-run (metaflux-client)
run: cargo publish -p metaflux-client --dry-run
# STEP 1 of 2: publish the root crate first. Idempotent: on a re-run after
# a partial failure, skip if this version is already on crates.io instead
# of hard-failing. Modern cargo blocks until the uploaded version is
# visible in the index before exiting.
#
# Idempotency is determined PRIMARILY by querying the crates.io sparse
# index for the exact name/version (a stable HTTP API), so the re-run
# guarantee no longer hinges on cargo's human-readable, version-dependent,
# potentially-localized stderr wording. The stderr grep is kept only as a
# secondary fallback for a race where the version lands between our check
# and our upload.
- name: Publish metaflux-client
run: |
set -uo pipefail
VERSION="${{ steps.guard.outputs.version }}"
# Primary check: is metaflux-client@VERSION already in the registry?
# Sparse-index path is the first chars of the (lower-cased) crate name:
# me/ta/metaflux-client. Each line is one version's JSON blob.
if curl -fsSL "https://index.crates.io/me/ta/metaflux-client" 2>/dev/null \
| jq -e --arg v "${VERSION}" 'select(.vers == $v and (.yanked | not))' >/dev/null; then
echo "metaflux-client ${VERSION} already on crates.io (index check); skipping."
exit 0
fi
out="$(cargo publish --locked -p metaflux-client 2>&1)" && status=0 || status=$?
# cargo never prints the token; this is plain build/upload logging.
echo "${out}"
if [ "${status}" -eq 0 ]; then
echo "Published metaflux-client ${VERSION}."
exit 0
fi
# Secondary fallback: catch a publish that lost the race to another run.
if echo "${out}" | grep -qiE "already (exists|uploaded)|crate version .* is already uploaded"; then
echo "metaflux-client ${VERSION} already on crates.io (stderr fallback); skipping."
exit 0
fi
echo "::error::Publishing metaflux-client failed."
exit "${status}"
# STEP 2 of 2: publish the facade. cargo's built-in index wait usually
# covers propagation of the just-published root crate, but the index can
# still lag, so wrap this in a bounded retry that retries specifically on
# dependency-resolution lag. Also idempotent on already-published.
#
# Same idempotency strategy as the root crate: a registry index pre-check
# is the primary skip signal; the stderr grep is only a secondary fallback.
- name: Publish metaflux (facade, with retry)
run: |
set -uo pipefail
VERSION="${{ steps.guard.outputs.version }}"
# Primary check: is metaflux@VERSION already in the registry?
# Sparse-index path for a 7-char name: me/ta/metaflux.
if curl -fsSL "https://index.crates.io/me/ta/metaflux" 2>/dev/null \
| jq -e --arg v "${VERSION}" 'select(.vers == $v and (.yanked | not))' >/dev/null; then
echo "metaflux ${VERSION} already on crates.io (index check); skipping."
exit 0
fi
attempts=5
delay=20
for i in $(seq 1 "${attempts}"); do
echo "Publish attempt ${i}/${attempts} for metaflux ${VERSION}..."
out="$(cargo publish --locked -p metaflux 2>&1)" && status=0 || status=$?
echo "${out}"
if [ "${status}" -eq 0 ]; then
echo "Published metaflux ${VERSION}."
exit 0
fi
# Secondary fallback: catch a publish that lost the race to another run.
if echo "${out}" | grep -qiE "already (exists|uploaded)|crate version .* is already uploaded"; then
echo "metaflux ${VERSION} already on crates.io (stderr fallback); skipping."
exit 0
fi
# Treat an unresolved metaflux-client as index-propagation lag and
# retry; any other failure is fatal (don't mask real errors). The
# pattern requires BOTH the metaflux-client name AND a not-found /
# no-candidate signal on the same line, so a genuine permanent
# resolution error (e.g. a yanked-only version or a real version-req
# typo) is NOT misclassified as lag and fails fast.
if echo "${out}" | grep -qiE "metaflux-client.*(no matching package|failed to select a version|not found in registry|candidate versions found)|((no matching package|failed to select a version|not found in registry|candidate versions found).*metaflux-client)"; then
if [ "${i}" -lt "${attempts}" ]; then
echo "Index appears to lag (metaflux-client ${VERSION} not visible yet); retrying in ${delay}s."
sleep "${delay}"
continue
fi
fi
echo "::error::Publishing metaflux failed on attempt ${i}."
exit "${status}"
done
echo "::error::Publishing metaflux failed after ${attempts} attempts."
exit 1