name: Release
on:
push:
branches: [main]
paths-ignore:
- 'docs/**'
- '*.md'
- '.github/workflows/docs.yml'
permissions:
contents: write
pull-requests: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, 'chore(release)')"
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache dependencies
uses: Swatinem/rust-cache@v2
- name: Determine version bump
id: version
run: |
# Get commits since last tag
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -z "$LAST_TAG" ]; then
COMMITS=$(git log --oneline)
LAST_TAG="v0.0.0"
else
COMMITS=$(git log ${LAST_TAG}..HEAD --oneline)
fi
echo "Last tag: $LAST_TAG"
echo "Commits since last tag:"
echo "$COMMITS"
# Determine bump type based on conventional commits
BUMP="patch"
if echo "$COMMITS" | grep -q "^[a-f0-9]* feat"; then
BUMP="minor"
fi
if echo "$COMMITS" | grep -q "BREAKING CHANGE\|^[a-f0-9]* feat!"; then
BUMP="major"
fi
# Derive current version from the last tag, not Cargo.toml.
# If a previous run failed after pushing the tag but before bumping
# Cargo.toml on main, reading Cargo.toml would recompute the same
# NEW_VERSION on every subsequent merge and collide with the
# already-pushed tag. The tag is the source of truth.
CURRENT_VERSION="${LAST_TAG#v}"
# Calculate new version
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"
case $BUMP in
major) NEW_VERSION="$((MAJOR + 1)).0.0" ;;
minor) NEW_VERSION="$MAJOR.$((MINOR + 1)).0" ;;
patch) NEW_VERSION="$MAJOR.$MINOR.$((PATCH + 1))" ;;
esac
echo "Current version: $CURRENT_VERSION"
echo "Bump type: $BUMP"
echo "New version: $NEW_VERSION"
echo "bump=$BUMP" >> $GITHUB_OUTPUT
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "current=$CURRENT_VERSION" >> $GITHUB_OUTPUT
echo "last_tag=$LAST_TAG" >> $GITHUB_OUTPUT
- name: Check if release needed
run: |
if [ "${{ steps.version.outputs.version }}" = "${{ steps.version.outputs.current }}" ]; then
echo "No version bump needed. Current version: ${{ steps.version.outputs.current }}"
echo "No commits found that require a release."
exit 0
else
echo "Version bump needed: ${{ steps.version.outputs.current }} -> ${{ steps.version.outputs.version }}"
fi
- name: Generate changelog
if: steps.version.outputs.version != steps.version.outputs.current
id: changelog
run: |
LAST_TAG="${{ steps.version.outputs.last_tag }}"
NEW_VERSION="${{ steps.version.outputs.version }}"
# Generate changelog from commits
CHANGELOG=$(git log ${LAST_TAG}..HEAD --pretty=format:"- %s" --grep="^feat\|^fix\|^perf\|^refactor" | sed 's/^- \([a-f0-9]*\) /- /')
# Create release notes
cat > release_notes.md << EOF
## What's Changed
$CHANGELOG
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${LAST_TAG}...v${NEW_VERSION}
EOF
# Update CHANGELOG.md
if [ ! -f CHANGELOG.md ]; then
echo "# Changelog" > CHANGELOG.md
echo "" >> CHANGELOG.md
echo "All notable changes to this project will be documented in this file." >> CHANGELOG.md
echo "" >> CHANGELOG.md
fi
# Prepend new version to changelog
{
head -n 4 CHANGELOG.md
echo "## [${NEW_VERSION}] - $(date +%Y-%m-%d)"
echo ""
echo "$CHANGELOG"
echo ""
tail -n +5 CHANGELOG.md
} > CHANGELOG.tmp && mv CHANGELOG.tmp CHANGELOG.md
- name: Update version
if: steps.version.outputs.version != steps.version.outputs.current
run: |
sed -i 's/^version = ".*"/version = "${{ steps.version.outputs.version }}"/' Cargo.toml
cargo check # Update Cargo.lock
- name: Run tests
if: steps.version.outputs.version != steps.version.outputs.current
run: |
cargo test --verbose --lib --bins --tests
cargo test --doc
- name: Run clippy
if: steps.version.outputs.version != steps.version.outputs.current
run: cargo clippy --lib --bins --tests -- -D warnings
- name: Check formatting
if: steps.version.outputs.version != steps.version.outputs.current
run: cargo fmt --check
- name: Commit version bump
if: steps.version.outputs.version != steps.version.outputs.current
run: |
NEW_TAG="v${{ steps.version.outputs.version }}"
# Belt-and-braces: if the tag already exists (locally or on the
# remote) abort before mutating anything. Without this, the next
# `git tag -a` aborts mid-step and leaves a dangling local
# chore(release) commit that never gets pushed.
if git rev-parse -q --verify "refs/tags/${NEW_TAG}" >/dev/null \
|| git ls-remote --exit-code --tags origin "${NEW_TAG}" >/dev/null 2>&1; then
echo "::error::Tag ${NEW_TAG} already exists. Aborting to avoid divergent release state."
echo "::error::This usually means a previous run created the tag but failed before pushing the version bump."
echo "::error::Manually reconcile: bump Cargo.toml past ${NEW_TAG}, or delete the orphan tag."
exit 1
fi
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add Cargo.toml Cargo.lock CHANGELOG.md
git commit -m "chore(release): bump version to ${{ steps.version.outputs.version }}"
# Push the version-bump commit BEFORE creating the tag. If `main`
# advanced while we were running (e.g. a docs PR landed under
# paths-ignore, or the concurrency group was disabled), rebase onto
# the new tip and retry. Tagging-before-pushing previously left
# orphan `v$VERSION` tags on transient push failures.
PUSH_ATTEMPTS=5
for attempt in $(seq 1 $PUSH_ATTEMPTS); do
if git push origin main; then
break
fi
if [ "$attempt" -eq "$PUSH_ATTEMPTS" ]; then
echo "::error::Failed to push version bump after $PUSH_ATTEMPTS attempts."
exit 1
fi
echo "Push rejected (attempt $attempt/$PUSH_ATTEMPTS); rebasing onto origin/main and retrying."
git fetch origin main
git rebase origin/main
done
git tag -a "${NEW_TAG}" -m "Release ${NEW_TAG}"
git push origin "${NEW_TAG}"
- name: Publish to crates.io
if: steps.version.outputs.version != steps.version.outputs.current
run: cargo publish --allow-dirty --token ${{ secrets.CRATES_IO_TOKEN }}
- name: Create GitHub Release
if: steps.version.outputs.version != steps.version.outputs.current
uses: softprops/action-gh-release@v3
with:
tag_name: v${{ steps.version.outputs.version }}
body_path: release_notes.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}