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
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).
permissions:
contents: write
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
- 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 — the draft release attach is part of the build
# phase. The release.published event doesn't need to re-run this.
if: github.event_name == 'push'
runs-on: ubuntu-latest
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
# The workspace has two publishable crates: `tb-tui-common`
# (shared TUI helpers, used by ebman + sibling pgman) and
# `ebman`. ebman depends on tb-tui-common via a path-+-version
# dep, so the shared crate must land on crates.io first or
# ebman's manifest fails dep verification.
#
# `--locked` matches the build matrix's lockfile pinning so a
# workflow that built clean from Cargo.lock publishes the same
# resolved dependency graph.
#
# Tolerate the already-published case for tb-tui-common — most
# ebman releases don't bump the shared crate, so most runs
# re-attempt a publish that's already happened. We only fail
# fast if the error isn't the already-published one.
set +e
tc_out=$(cargo publish -p tb-tui-common --locked 2>&1)
tc_exit=$?
set -e
echo "$tc_out"
if [ "$tc_exit" -ne 0 ]; then
if echo "$tc_out" | grep -qiE "already (exists|uploaded|published)"; then
echo "tb-tui-common version already on crates.io — continuing to ebman"
else
echo "tb-tui-common publish failed (not already-published case)" >&2
exit "$tc_exit"
fi
fi
cargo publish -p ebman --locked