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
227
228
name: release
on:
push:
tags:
- 'v*'
# `release: published` fires when the maintainer flips the draft
# release (created by the tag-push run below) to published. The
# crates_io job below is gated on this event so cargo publish only
# runs after the maintainer has sanity-checked the GitHub Release
# artefacts — once published, crates.io can't be unpublished, so the
# human gate matters.
release:
types:
# Manual re-run, e.g. when CARGO_REGISTRY_TOKEN was missing on the
# first `release.published` fire and the crates_io job no-op'd.
# Run from `main` (which has this trigger), passing the tag to
# publish as an input:
# gh workflow run release.yml -f tag=v0.7.0
# Build + publish-to-GH-release stay gated on `push` (artefacts
# already exist for the tag); only the crates_io job re-fires.
workflow_dispatch:
inputs:
tag:
description: 'Tag to publish to crates.io (e.g. v0.7.0)'
required: true
type: string
# When a new tag like `v0.2.0` is pushed:
# 1. Build a release binary on each target platform.
# 2. Tarball it together with README + LICENSE files.
# 3. Attach the tarballs to a GitHub Release for the tag.
# Homebrew can then point at the macOS aarch64 / x86_64 tarballs by URL +
# SHA-256. The workflow uses softprops/action-gh-release which is the
# de-facto standard for this pattern and re-uses GITHUB_TOKEN.
#
# Once the maintainer publishes the draft release, the `crates_io` job
# runs `cargo publish` so crates.io is kept in sync with the GitHub
# Release without a manual `cargo publish` step (which was forgotten
# for 0.4.0 / 0.4.1 — see BACKLOG).
# Only `publish` creates the GitHub Release. `build` uploads via
# `actions/upload-artifact` (job-scoped, no repo write) and `crates_io`
# authenticates with its own token, so neither needs write on the repo.
# The default here is read; `publish` opts up.
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: short
jobs:
build:
name: build ${{ matrix.target }}
# Only run on tag push — the release.published event reruns the
# whole workflow but the build matrix already produced the artefacts
# on the original tag-push run. Skipping here saves ~10 min of CI
# time per published release.
if: github.event_name == 'push'
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-gnu
runner: ubuntu-latest
archive_ext: tar.gz
# Native ARM64 Linux runner (GA for public repos) — avoids the
# cross-compile/linker pain of building aarch64 on an x86 host.
- target: aarch64-unknown-linux-gnu
runner: ubuntu-24.04-arm
archive_ext: tar.gz
- target: aarch64-apple-darwin
runner: macos-latest
archive_ext: tar.gz
- target: x86_64-apple-darwin
runner: macos-latest
archive_ext: tar.gz
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
- name: Build release binary
run: cargo build --locked --release --target ${{ matrix.target }}
- name: Stage binary + docs
run: |
set -euo pipefail
stage="ebman-${GITHUB_REF_NAME}-${{ matrix.target }}"
mkdir "$stage"
cp "target/${{ matrix.target }}/release/ebman" "$stage/"
cp README.md LICENSE-MIT LICENSE-APACHE "$stage/"
tar -czf "$stage.tar.gz" "$stage"
shasum -a 256 "$stage.tar.gz" | tee "$stage.tar.gz.sha256"
- name: Upload as workflow artifact
uses: actions/upload-artifact@v4
with:
name: ebman-${{ matrix.target }}
path: |
ebman-*.tar.gz
ebman-*.tar.gz.sha256
publish:
name: publish release
needs: build
# Tag-push only. THIS job creates the draft release and attaches
# the artefacts `build` uploaded; the `release.published` event
# (which fires when the maintainer un-drafts it) must not re-run
# it. The old comment said the attach happened in the build phase,
# which was never true of this file.
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
path: dist
pattern: ebman-*
merge-multiple: true
- name: Attach artefacts to release
uses: softprops/action-gh-release@v2
with:
# GITHUB_REF_NAME is the tag, e.g. v0.2.0.
tag_name: ${{ github.ref_name }}
name: ebman ${{ github.ref_name }}
files: |
dist/ebman-*.tar.gz
dist/ebman-*.tar.gz.sha256
# Don't auto-publish a draft — the maintainer can flip the toggle
# once they've sanity-checked the artefacts.
draft: true
generate_release_notes: true
crates_io:
# Publish to crates.io once the maintainer has reviewed the draft
# GitHub Release and flipped it to published. Gated on the
# `CARGO_REGISTRY_TOKEN` repository secret — when the secret isn't
# configured the step is skipped (rather than failing), so forks /
# scratch installations that don't own the crate don't fail loudly.
#
# Two earlier releases (0.4.0 / 0.4.1) shipped as GitHub Releases
# only because the manual `cargo publish` step was forgotten; the
# in-app update-check polls crates.io, so the gap surfaced as a
# stale "latest" reported back to users. Automating closes that
# gap. The release.published trigger keeps the maintainer in the
# loop — crates.io can't be unpublished, so the human gate matters.
name: publish to crates.io
if: (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && !github.event.repository.fork
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# The release event payload's `tag_name` is what was published;
# workflow_dispatch passes the tag via `inputs.tag`. Either
# way we check out exactly that ref so cargo publishes the
# released code, not whatever HEAD happens to be at workflow-
# fire time.
ref: ${{ github.event.release.tag_name || inputs.tag }}
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: cargo publish
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: |
if [ -z "${CARGO_REGISTRY_TOKEN:-}" ]; then
echo "CARGO_REGISTRY_TOKEN not set — skipping crates.io publish"
exit 0
fi
# ebman is the only crate published from this repo now — its
# shared `tb-tui-common` dependency lives in its own repo
# (tombaldwin/tb-tui-common) and publishes from there, so it's
# already on crates.io by the time ebman resolves it.
#
# `--locked` matches the build matrix's lockfile pinning so a
# workflow that built clean from Cargo.lock publishes the same
# resolved dependency graph.
cargo publish --locked
mcp_registry:
# Publish server.json to the official MCP Registry (preview) so agents
# and directories can discover ebman. Runs after crates_io because the
# registry verifies package ownership by fetching the crate's README
# from crates.io — the visible `mcp-name:` marker in README.md — so the
# crate carrying that marker must be live first. GitHub OIDC proves the
# io.github.tombaldwin namespace, so no secret is needed. Isolated from
# the build/publish jobs: a registry hiccup (it's preview) can't affect
# the already-shipped GitHub Release or crates.io publish.
name: publish to MCP Registry
needs: crates_io
if: (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && !github.event.repository.fork
runs-on: ubuntu-latest
permissions:
id-token: write # GitHub OIDC → registry namespace auth
contents: read
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.release.tag_name || inputs.tag }}
- name: Sync server.json version to the release tag
run: |
set -euo pipefail
TAG="${{ github.event.release.tag_name || inputs.tag }}"
VERSION="${TAG#v}"
jq --arg v "$VERSION" '.version = $v | .packages[0].version = $v' server.json > server.tmp
mv server.tmp server.json
- name: Install mcp-publisher
run: |
curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
- name: Authenticate (GitHub OIDC)
run: ./mcp-publisher login github-oidc
- name: Publish server.json
# crates.io's rendered README — which the registry reads to verify
# the mcp-name marker — can lag a cargo publish by up to a minute,
# so retry rather than fail a fresh release on propagation timing.
run: |
set -eu
for attempt in 1 2 3 4 5; do
if ./mcp-publisher publish; then
echo "published to MCP Registry"; exit 0
fi
echo "attempt $attempt failed (crate README may still be propagating) — waiting 30s"
sleep 30
done
echo "MCP Registry publish failed after retries"; exit 1