ci: automate releases with release-plz

Replace the 326-line semantic-release/npm workflow with release-plz, which
handles the whole chain the old one only half did:

- versioning and CHANGELOG from the conventional commits already in use
- a `chore: release vX.Y.Z` PR as the gate; merging it tags and releases
- publishes both crates to crates.io in dependency order, waiting for the
  index between them (RELEASE.md used to ask you to do this by hand)
- release-plz.toml keeps the two crates on one version, one tag, one changelog

Binaries now cover six targets instead of four. Linux switches from gnu to
musl so the downloads are statically linked and stop caring about the host's
glibc version; linux-aarch64 and windows-aarch64 are new. Archives carry
sha256 checksums plus README and LICENSE.

CI drops twelve hand-rolled actions/cache steps for Swatinem/rust-cache.
This commit is contained in:
Michel de Graaf
2026-07-24 12:37:28 +02:00
parent 5e34722a1b
commit 6403c11c8f
4 changed files with 162 additions and 442 deletions
+3 -45
View File
@@ -22,23 +22,7 @@ jobs:
with:
components: clippy
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
uses: actions/cache@v4
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
- uses: Swatinem/rust-cache@v2
- name: Run clippy
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
@@ -72,23 +56,7 @@ jobs:
with:
toolchain: ${{ matrix.rust }}
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
uses: actions/cache@v4
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
- uses: Swatinem/rust-cache@v2
- name: Run tests
run: cargo test --verbose
@@ -102,17 +70,7 @@ jobs:
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
- uses: Swatinem/rust-cache@v2
- name: Install SQLite runtime/dev libraries
run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev
+92 -295
View File
@@ -1,325 +1,122 @@
name: Release
on:
push:
branches: [master]
workflow_dispatch:
inputs:
release_type:
description: 'Release type (patch, minor, major)'
required: false
default: 'auto'
type: choice
options:
- auto
- patch
- minor
- major
permissions:
contents: write
issues: write
pull-requests: write
env:
CARGO_TERM_COLOR: always
jobs:
check-release:
name: Check for Release
# Opens (or updates) a "chore: release vX.Y.Z" PR with the version bump and
# changelog derived from the conventional commits on master. Merging it is the
# release trigger.
release-pr:
name: Release PR
runs-on: ubuntu-latest
outputs:
should_release: ${{ steps.check.outputs.should_release }}
version: ${{ steps.check.outputs.version }}
notes: ${{ steps.check.outputs.notes }}
concurrency:
group: release-pr-${{ github.ref }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v4
- uses: dtolnay/rust-toolchain@stable
- uses: release-plz/action@v0.5
with:
node-version: 'lts/*'
- name: Install semantic-release
run: |
npm install -g semantic-release@23 \
@semantic-release/commit-analyzer@13 \
@semantic-release/release-notes-generator@14
- name: Check if release needed
id: check
command: release-pr
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# If a specific release type is requested (not 'auto'), always proceed
if [ "${{ github.event.inputs.release_type }}" != "auto" ] && [ -n "${{ github.event.inputs.release_type }}" ]; then
echo "Manual release type specified: ${{ github.event.inputs.release_type }}"
echo "should_release=true" >> $GITHUB_OUTPUT
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
# Get current version from Cargo.toml
CURRENT_VERSION=$(grep '^version = ' Cargo.toml | head -1 | cut -d'"' -f2)
echo "Current version: $CURRENT_VERSION"
# Calculate next version based on release type
IFS='.' read -r major minor patch <<< "$CURRENT_VERSION"
case "${{ github.event.inputs.release_type }}" in
major)
VERSION="$((major + 1)).0.0"
;;
minor)
VERSION="${major}.$((minor + 1)).0"
;;
patch)
VERSION="${major}.${minor}.$((patch + 1))"
;;
esac
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Manual version to release: $VERSION"
else
# Auto mode - use semantic-release to determine if release is needed
cat > .releaserc.json << 'EOF'
{
"branches": ["master"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator"
],
"dryRun": true
}
EOF
OUTPUT=$(npx semantic-release --dry-run 2>&1 || true)
echo "$OUTPUT"
if echo "$OUTPUT" | grep -q "There is no relevant changes"; then
echo "should_release=false" >> $GITHUB_OUTPUT
echo "No relevant changes found for automatic release"
else
VERSION=$(echo "$OUTPUT" | grep -oP "next release version is \K[0-9]+\.[0-9]+\.[0-9]+" | head -1)
if [ -n "$VERSION" ]; then
echo "should_release=true" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Auto version to release: $VERSION"
else
echo "should_release=false" >> $GITHUB_OUTPUT
echo "Could not determine version for automatic release"
fi
fi
fi
build-binaries:
name: Build ${{ matrix.target }}
needs: check-release
if: needs.check-release.outputs.should_release == 'true'
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
binary_name: soulseek-rs
tarball_name: soulseek-rs-linux-x86_64.tar.gz
- os: macos-latest
target: x86_64-apple-darwin
binary_name: soulseek-rs
tarball_name: soulseek-rs-macos-x86_64.tar.gz
- os: macos-latest
target: aarch64-apple-darwin
binary_name: soulseek-rs
tarball_name: soulseek-rs-macos-aarch64.tar.gz
- os: windows-latest
target: x86_64-pc-windows-msvc
binary_name: soulseek-rs.exe
tarball_name: soulseek-rs-windows-x86_64.zip
# No-op until the release PR lands; then tags, publishes both crates to
# crates.io in dependency order, and creates the GitHub release.
release:
name: Tag and publish
runs-on: ubuntu-latest
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
outputs:
created: ${{ steps.release-plz.outputs.releases_created }}
tag: ${{ steps.tag.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@stable
- uses: release-plz/action@v0.5
id: release-plz
with:
command: release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
# Read the tag out via jq rather than fromJSON: `releases` is an empty
# string on the runs where nothing was released, and fromJSON chokes on it.
- name: Resolve tag
id: tag
if: steps.release-plz.outputs.releases_created == 'true'
env:
RELEASES: ${{ steps.release-plz.outputs.releases }}
run: echo "tag=$(jq -r '.[0].tag' <<< "$RELEASES")" >> "$GITHUB_OUTPUT"
# Attaches downloadable binaries to the release GitHub just created.
binaries:
name: ${{ matrix.target }}
needs: release
if: needs.release.outputs.created == 'true'
runs-on: ${{ matrix.os }}
strategy:
# One flaky cross-linker must not hold back the five targets that built.
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
- target: aarch64-unknown-linux-musl
os: ubuntu-24.04-arm
- target: x86_64-apple-darwin
os: macos-latest
- target: aarch64-apple-darwin
os: macos-latest
- target: x86_64-pc-windows-msvc
os: windows-latest
- target: aarch64-pc-windows-msvc
os: windows-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.release.outputs.tag }}
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Cache cargo registry
uses: actions/cache@v4
- name: Install musl toolchain
if: endsWith(matrix.target, '-musl')
run: sudo apt-get update && sudo apt-get install -y musl-tools
# Builds, strips, archives (.tar.gz / .zip on Windows), writes a .sha256
# and uploads the lot to the release.
- uses: taiki-e/upload-rust-binary-action@v1
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
uses: actions/cache@v4
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-${{ matrix.target }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
- name: Build release binary
run: cargo build --release --target ${{ matrix.target }}
- name: Strip binary (Linux)
if: matrix.os == 'ubuntu-latest'
run: strip target/${{ matrix.target }}/release/${{ matrix.binary_name }}
- name: Strip binary (macOS)
if: matrix.os == 'macos-latest'
run: strip target/${{ matrix.target }}/release/${{ matrix.binary_name }}
- name: Create tarball (Unix)
if: matrix.os != 'windows-latest'
shell: bash
run: |
cd target/${{ matrix.target }}/release
tar -czf ../../../${{ matrix.tarball_name }} ${{ matrix.binary_name }}
- name: Create zip (Windows)
if: matrix.os == 'windows-latest'
shell: bash
run: |
cd target/${{ matrix.target }}/release
7z a -tzip ../../../${{ matrix.tarball_name }} ${{ matrix.binary_name }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.tarball_name }}
path: ${{ matrix.tarball_name }}
create-release:
name: Create Release
needs: [check-release, build-binaries]
if: needs.check-release.outputs.should_release == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: true
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Install semantic-release
run: |
npm install -g semantic-release@23 \
@semantic-release/git@10 \
@semantic-release/changelog@6 \
@semantic-release/exec@6 \
@semantic-release/github@11
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Display artifact structure
run: ls -R artifacts
- name: Prepare artifacts for release
run: |
mkdir -p release-assets
find artifacts -type f -name "*.tar.gz" -exec cp {} release-assets/ \;
find artifacts -type f -name "*.zip" -exec cp {} release-assets/ \;
ls -lh release-assets/
- name: Create semantic-release config
run: |
# Check if we need to force a specific version for manual releases
if [ "${{ github.event.inputs.release_type }}" != "auto" ] && [ -n "${{ github.event.inputs.release_type }}" ]; then
# For manual releases, we'll bypass semantic-release version detection
cat > .releaserc.json << 'EOF'
{
"branches": ["master"],
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"releaseRules": [
{"breaking": true, "release": "${{ github.event.inputs.release_type }}"},
{"revert": true, "release": "patch"},
{"type": "feat", "release": "${{ github.event.inputs.release_type }}"},
{"type": "fix", "release": "${{ github.event.inputs.release_type }}"},
{"type": "perf", "release": "${{ github.event.inputs.release_type }}"},
{"scope": "no-release", "release": false}
]
}
],
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
[
"@semantic-release/exec",
{
"prepareCmd": "sed -i 's/^version = \".*\"/version = \"${{ needs.check-release.outputs.version }}\"/' Cargo.toml"
}
],
[
"@semantic-release/git",
{
"assets": ["CHANGELOG.md", "Cargo.toml"],
"message": "chore(release): ${{ needs.check-release.outputs.version }} [skip ci]\n\n${nextRelease.notes}"
}
],
[
"@semantic-release/github",
{
"assets": [
{"path": "release-assets/*.tar.gz"},
{"path": "release-assets/*.zip"}
]
}
]
]
}
EOF
else
# For automatic releases, use standard semantic-release
cat > .releaserc.json << 'EOF'
{
"branches": ["master"],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
[
"@semantic-release/exec",
{
"prepareCmd": "sed -i 's/^version = \".*\"/version = \"${nextRelease.version}\"/' Cargo.toml"
}
],
[
"@semantic-release/git",
{
"assets": ["CHANGELOG.md", "Cargo.toml"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
],
[
"@semantic-release/github",
{
"assets": [
{"path": "release-assets/*.tar.gz"},
{"path": "release-assets/*.zip"}
]
}
]
]
}
EOF
fi
- name: Run semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ "${{ github.event.inputs.release_type }}" != "auto" ] && [ -n "${{ github.event.inputs.release_type }}" ]; then
# For manual releases, we need to create the release directly
echo "Creating manual release version ${{ needs.check-release.outputs.version }}"
npx semantic-release --no-ci
else
npx semantic-release
fi
bin: soulseek-rs
target: ${{ matrix.target }}
archive: $bin-$tag-$target
include: README.md,LICENSE
checksum: sha256
ref: refs/tags/${{ needs.release.outputs.tag }}
token: ${{ secrets.GITHUB_TOKEN }}
+45 -102
View File
@@ -1,122 +1,65 @@
# Release Guide
# Releasing
This guide explains how to publish the `soulseek-rs-lib` and `soulseek-rs` crates to crates.io.
Releases are automated by [release-plz](https://release-plz.dev). You do not run
`cargo publish`, edit versions, write changelog entries, or push tags by hand.
## Prerequisites
## How it works
1. Create an account on [crates.io](https://crates.io/)
2. Generate an API token: https://crates.io/me
3. Login to cargo: `cargo login <your-token>`
1. Merge a PR to `master` using [conventional commits](https://www.conventionalcommits.org)
(`feat:`, `fix:`, `perf:`, `refactor:`, `docs:`, `chore:`, or a `!` / `BREAKING CHANGE:`
footer for a major bump).
2. `.github/workflows/release.yml` opens or updates a **`chore: release vX.Y.Z`** PR. It
contains the semver bump in `[workspace.package]`, the refreshed `Cargo.lock`, and the
generated `CHANGELOG.md` entry. Review it like any other PR.
3. Merging that PR releases:
- tags `vX.Y.Z` and creates the GitHub release with the changelog as its notes,
- publishes `soulseek-rs-lib` then `soulseek-rs` to crates.io (dependency order, waiting
for the index between them),
- builds and attaches binaries with `.sha256` checksums for six targets:
## Publishing Process
| Archive | Notes |
| ------------------------------------------------ | ------------------------------ |
| `soulseek-rs-vX.Y.Z-x86_64-unknown-linux-musl.tar.gz` | static, any glibc |
| `soulseek-rs-vX.Y.Z-aarch64-unknown-linux-musl.tar.gz` | static, any glibc |
| `soulseek-rs-vX.Y.Z-x86_64-apple-darwin.tar.gz` | Intel Mac |
| `soulseek-rs-vX.Y.Z-aarch64-apple-darwin.tar.gz` | Apple Silicon |
| `soulseek-rs-vX.Y.Z-x86_64-pc-windows-msvc.zip` | |
| `soulseek-rs-vX.Y.Z-aarch64-pc-windows-msvc.zip` | |
### Step 1: Publish the Library First
Both crates always share one version, one tag, and one changelog — see `release-plz.toml`.
The client depends on the library, so the library must be published first.
## One-time repository setup
```bash
cd soulseek-rs-lib
cargo publish --dry-run # Test the publish
cargo publish # Actually publish
```
- **Settings → Actions → General → Workflow permissions**: enable *Allow GitHub Actions to
create and approve pull requests*. Without it the release PR is never opened.
- **Settings → Secrets and variables → Actions**: add `CARGO_REGISTRY_TOKEN`, a
[crates.io token](https://crates.io/settings/tokens) scoped to `publish-new` and
`publish-update`. Without it the tag and GitHub release still happen, but the crates.io
publish fails.
### Step 2: Wait for crates.io to Index
## Notes
After publishing the library, wait 1-2 minutes for crates.io to index it before publishing the client.
- A merge with no releasable commits (only `docs:`/`chore:`/`test:`) opens no release PR.
That is intended.
- The release PR does not run CI: GitHub does not trigger workflows for PRs opened with
`GITHUB_TOKEN`. It only touches `Cargo.toml`, `Cargo.lock`, and `CHANGELOG.md`; CI already
ran on the commits it is releasing.
- If one target's build fails, the other five still upload (`fail-fast: false`). Re-run just
that matrix job from the Actions UI.
- To skip a release for a commit that would otherwise trigger one, use a `chore:` type.
### Step 3: Update Client Dependency (for releases)
When publishing to crates.io, the client's `Cargo.toml` should reference the published library version, not the local path.
Edit `soulseek-rs/Cargo.toml`:
```toml
[dependencies]
# For local development:
soulseek-rs-lib = { version = "0.1.0", path = "../soulseek-rs-lib" }
# For publishing (comment out path):
# soulseek-rs-lib = "0.1.0"
```
Or use both with optional features:
```toml
[dependencies]
soulseek-rs-lib = "0.1.0"
[dev-dependencies]
soulseek-rs-lib = { version = "0.1.0", path = "../soulseek-rs-lib" }
```
### Step 4: Publish the Client
```bash
cd ../soulseek-rs
cargo publish --dry-run # Test the publish
cargo publish # Actually publish
```
## Version Management
When bumping versions:
1. Update the version in root `Cargo.toml` under `[workspace.package]`
2. Both crates will inherit this version automatically
3. Publish library first, then client
## Automated Releases (Optional)
You can automate this with GitHub Actions. Create `.github/workflows/release.yml`:
```yaml
name: Release
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
- name: Publish library
run: |
cd soulseek-rs-lib
cargo publish --token ${{ secrets.CARGO_TOKEN }}
- name: Wait for crates.io
run: sleep 60
- name: Publish client
run: |
cd soulseek-rs
cargo publish --token ${{ secrets.CARGO_TOKEN }}
```
## Testing Installation
After publishing, test the installation:
## Installing a release
```bash
cargo install soulseek-rs
soulseek-rs --help # Should work
```
## For Developers Building Custom Clients
Or download an archive from the [releases page](https://github.com/michel/soulseek-rs/releases),
verify it against the adjacent `.sha256`, and extract the binary.
Developers can use the library by adding to their `Cargo.toml`:
## Using the library
```toml
[dependencies]
soulseek-rs-lib = "0.1.0"
soulseek-rs-lib = "5"
```
They'll get access to all the types and functionality exported in `lib.rs`.
+22
View File
@@ -0,0 +1,22 @@
# Both crates inherit `version.workspace`, so they always move together. This
# config makes release-plz treat them as one release: a single `vX.Y.Z` tag, one
# GitHub release, one root CHANGELOG.md covering both. release-plz still
# publishes them to crates.io in dependency order (lib first) and waits for the
# index, which is what RELEASE.md used to ask you to do by hand.
[workspace]
git_tag_name = "v{{ version }}"
git_release_name = "v{{ version }}"
[[package]]
name = "soulseek-rs"
version_group = "workspace"
changelog_path = "./CHANGELOG.md"
changelog_include = ["soulseek-rs-lib"]
[[package]]
name = "soulseek-rs-lib"
version_group = "workspace"
changelog_update = false
git_tag_enable = false
git_release_enable = false