Skip to content

Commit fdd1b5a

Browse files
ci: release workflow publishes to crates.io on v* tag push
Triggered by `v*` tags (or manual workflow_dispatch with a tag input to re-run a prior release). Runs tests + clippy + fmt + cargo publish --dry-run, checks whether the version is already on crates.io (skips the publish step if so), publishes via CARGO_REGISTRY_TOKEN, and cuts a GitHub Release with the matching CHANGELOG slice. The release skill at .claude/skills/release/ expected `cargo publish` to just work locally, but no maintainer had `~/.cargo/credentials.toml` configured, and there was no CI path either. This workflow mirrors the release-beacon.yml pattern used in fallow-cloud: tag-vs-Cargo.toml invariant check (plus PROTOCOL_VERSION lockstep), pinned action SHAs, deny-all baseline permissions, scoped `contents: write` only for the GitHub release step. Required one-time setup before the first tag publishes: gh secret set CARGO_REGISTRY_TOKEN --repo fallow-rs/fallow-cov-protocol The token can be the same one used by the fallow repo; both publish under the same crates.io owner. All workflow inputs route through `env:` before shell use so a malicious tag name cannot inject commands.
1 parent 1c74e22 commit fdd1b5a

File tree

1 file changed

+177
-0
lines changed

1 file changed

+177
-0
lines changed

.github/workflows/release.yml

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
name: Release fallow-cov-protocol
2+
3+
# Triggered by `v*` tags. Runs tests + clippy + fmt + dry-run, then publishes
4+
# to crates.io using a repo-scoped CARGO_REGISTRY_TOKEN secret. Also cuts a
5+
# GitHub release with the CHANGELOG slice attached.
6+
#
7+
# One-time setup (before the first tag push):
8+
# gh secret set CARGO_REGISTRY_TOKEN --repo fallow-rs/fallow-cov-protocol
9+
# The token can be the same one used by the fallow repo; both publish under
10+
# the same crates.io owner.
11+
#
12+
# Re-running after a failure: push a new patch tag (e.g. v0.3.1). NEVER
13+
# force-push a release tag. Every pushed tag is a permanent audit trail.
14+
#
15+
# Security note: all workflow inputs (tag names, refs) are routed through
16+
# `env:` vars before shell use, so a malicious tag name cannot inject
17+
# commands. See https://github.blog/security/vulnerability-research/how-to-
18+
# catch-github-actions-workflow-injections-before-attackers-do/
19+
20+
on:
21+
push:
22+
tags:
23+
- "v*"
24+
workflow_dispatch:
25+
inputs:
26+
tag:
27+
description: "Existing tag to re-run the release for (e.g. v0.3.0)"
28+
required: true
29+
type: string
30+
31+
permissions: {}
32+
33+
concurrency:
34+
group: release-${{ github.ref }}
35+
cancel-in-progress: false
36+
37+
env:
38+
CARGO_TERM_COLOR: always
39+
40+
jobs:
41+
publish:
42+
name: Publish to crates.io
43+
runs-on: ubuntu-latest
44+
timeout-minutes: 15
45+
permissions:
46+
contents: write # for GitHub release
47+
steps:
48+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
49+
with:
50+
# On workflow_dispatch the caller supplies a tag; on tag-push the
51+
# ref already points at it. Either way checkout by the resolved
52+
# ref so downstream steps always see the release tree.
53+
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
54+
55+
- name: Extract version from tag
56+
id: version
57+
shell: bash
58+
env:
59+
REF: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
60+
run: |
61+
set -euo pipefail
62+
# Normalise "refs/tags/v0.3.0" and "v0.3.0" to "0.3.0".
63+
TAG="${REF#refs/tags/}"
64+
VERSION="${TAG#v}"
65+
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
66+
echo "::error::tag '${TAG}' does not match v<semver> format"
67+
exit 1
68+
fi
69+
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
70+
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
71+
72+
- name: Verify tag matches Cargo.toml version
73+
shell: bash
74+
env:
75+
TAG_VERSION: ${{ steps.version.outputs.version }}
76+
run: |
77+
set -euo pipefail
78+
PKG_VERSION=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2)
79+
if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
80+
echo "::error::tag v${TAG_VERSION} does not match Cargo.toml version ${PKG_VERSION}"
81+
exit 1
82+
fi
83+
echo "Tag and manifest agree on ${PKG_VERSION}"
84+
85+
- name: Verify PROTOCOL_VERSION matches Cargo.toml version
86+
shell: bash
87+
env:
88+
TAG_VERSION: ${{ steps.version.outputs.version }}
89+
run: |
90+
set -euo pipefail
91+
# The protocol-reviewer agent guards this invariant at PR time;
92+
# the release workflow re-checks so a tag pushed against a stale
93+
# lib.rs cannot reach crates.io.
94+
LIB_VERSION=$(grep -oE 'PROTOCOL_VERSION: &str = "[^"]+"' src/lib.rs | cut -d'"' -f2)
95+
if [ "$LIB_VERSION" != "$TAG_VERSION" ]; then
96+
echo "::error::PROTOCOL_VERSION \"${LIB_VERSION}\" in src/lib.rs does not match tag v${TAG_VERSION}"
97+
exit 1
98+
fi
99+
100+
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
101+
with:
102+
key: release
103+
104+
- name: Run tests
105+
run: cargo test --all-targets
106+
107+
- name: Clippy
108+
run: cargo clippy --all-targets -- -D warnings
109+
110+
- name: Format check
111+
run: cargo fmt --all -- --check
112+
113+
- name: Cargo publish dry-run
114+
run: cargo publish --dry-run
115+
116+
- name: Check if version is already published on crates.io
117+
id: check_published
118+
shell: bash
119+
env:
120+
VERSION: ${{ steps.version.outputs.version }}
121+
run: |
122+
set -euo pipefail
123+
STATUS=$(curl -sS -o /dev/null -w "%{http_code}" \
124+
"https://crates.io/api/v1/crates/fallow-cov-protocol/${VERSION}")
125+
if [ "$STATUS" = "200" ]; then
126+
echo "already=true" >> "$GITHUB_OUTPUT"
127+
echo "::notice::fallow-cov-protocol ${VERSION} is already on crates.io; skipping cargo publish"
128+
else
129+
echo "already=false" >> "$GITHUB_OUTPUT"
130+
fi
131+
132+
- name: Publish to crates.io
133+
if: steps.check_published.outputs.already != 'true'
134+
shell: bash
135+
env:
136+
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
137+
run: cargo publish
138+
139+
- name: Extract CHANGELOG slice for this version
140+
id: changelog
141+
shell: bash
142+
env:
143+
VERSION: ${{ steps.version.outputs.version }}
144+
TAG: ${{ steps.version.outputs.tag }}
145+
run: |
146+
set -euo pipefail
147+
# CHANGELOG uses `## [X.Y.Z] - YYYY-MM-DD` headers (Keep a
148+
# Changelog). Print lines from the matching section up to (but
149+
# not including) the next `## ` header.
150+
awk -v ver="${VERSION}" '
151+
/^## / {
152+
if (found) exit
153+
if ($0 ~ "^## \\["ver"\\]") { found=1; next }
154+
}
155+
found { print }
156+
' CHANGELOG.md > /tmp/changelog-slice.md
157+
{
158+
echo "title=fallow-cov-protocol ${TAG}"
159+
echo "body<<CHANGELOG_EOF"
160+
cat /tmp/changelog-slice.md
161+
echo ""
162+
echo "---"
163+
echo ""
164+
echo "Install: \`cargo add fallow-cov-protocol@${VERSION}\`"
165+
echo "CHANGELOG_EOF"
166+
} >> "$GITHUB_OUTPUT"
167+
168+
- name: Create GitHub Release
169+
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
170+
with:
171+
name: ${{ steps.changelog.outputs.title }}
172+
tag_name: ${{ steps.version.outputs.tag }}
173+
body: ${{ steps.changelog.outputs.body }}
174+
draft: false
175+
prerelease: false
176+
env:
177+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

0 commit comments

Comments
 (0)