The `tag` dispatch input exists so binaries and the homebrew formula can be rebuilt for a tag that already shipped. It never worked: the job-level conditions read `inputs.tag`, which is empty in a job `if` on workflow_dispatch, and `null != ''` coerces to false, so both jobs silently skipped. That is why v7.0.0 reached crates.io with an empty homebrew tap. The step-level condition used the same expression and did fire, which is what made the two disagree. `github.event.inputs.tag` is populated in both places.
267 lines
9.7 KiB
YAML
267 lines
9.7 KiB
YAML
name: Release
|
|
|
|
on:
|
|
push:
|
|
branches: [master]
|
|
paths-ignore: ['website/**']
|
|
workflow_dispatch:
|
|
inputs:
|
|
tag:
|
|
description: 'Existing tag to rebuild binaries for (e.g. v6.0.0). Leave empty for a normal release run.'
|
|
required: false
|
|
type: string
|
|
|
|
permissions:
|
|
contents: write
|
|
pull-requests: write
|
|
|
|
env:
|
|
CARGO_TERM_COLOR: always
|
|
|
|
jobs:
|
|
# CI runs on this same push. Nothing may reach crates.io until it has passed,
|
|
# because a published version cannot be withdrawn, only yanked. Deliberately
|
|
# not a dependency of release-pr: opening the release PR is harmless and
|
|
# should not wait.
|
|
ci-gate:
|
|
name: Wait for CI
|
|
runs-on: ubuntu-latest
|
|
if: github.event_name == 'push'
|
|
permissions:
|
|
actions: read
|
|
contents: read
|
|
steps:
|
|
- name: Wait for CI to pass on this commit
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
# jq reduces the run to a single word so there is no shell word
|
|
# splitting to get wrong. `last` picks the newest run for the commit,
|
|
# so re-running a flaky CI job flips this to success and re-running
|
|
# the Release workflow then goes through.
|
|
for _ in $(seq 1 90); do
|
|
verdict=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$GITHUB_SHA" \
|
|
--jq '[.workflow_runs[] | select(.path==".github/workflows/ci.yml")]
|
|
| sort_by(.run_number) | last
|
|
| if . == null then "absent"
|
|
elif .status != "completed" then "running"
|
|
else (.conclusion // "unknown") end' \
|
|
2>/dev/null || echo "unreachable")
|
|
case "$verdict" in
|
|
success) echo "CI passed for $GITHUB_SHA"; exit 0 ;;
|
|
absent|running|unreachable|cancelled) ;;
|
|
*) echo "::error::CI concluded '$verdict' for $GITHUB_SHA - refusing to release"; exit 1 ;;
|
|
esac
|
|
sleep 20
|
|
done
|
|
echo "::error::timed out waiting for CI on $GITHUB_SHA - refusing to release"
|
|
exit 1
|
|
|
|
# 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
|
|
concurrency:
|
|
group: release-pr-${{ github.ref }}
|
|
cancel-in-progress: false
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
|
|
- uses: dtolnay/rust-toolchain@stable
|
|
|
|
- uses: release-plz/action@v0.5
|
|
with:
|
|
command: release-pr
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
|
|
|
# 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
|
|
needs: ci-gate
|
|
# ci-gate is skipped on workflow_dispatch, which would skip this job too
|
|
# without always(). A ci-gate failure leaves both clauses false, so a red
|
|
# CI blocks the publish.
|
|
if: always() && (github.event_name == 'workflow_dispatch' || needs.ci-gate.result == 'success')
|
|
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
|
|
|
|
- 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' || inputs.tag != ''
|
|
env:
|
|
RELEASES: ${{ steps.release-plz.outputs.releases }}
|
|
INPUT_TAG: ${{ inputs.tag }}
|
|
run: |
|
|
tag="${INPUT_TAG:-$(jq -r '.[0].tag' <<< "$RELEASES")}"
|
|
# This value is handed to actions/checkout as `ref:` and INPUT_TAG is
|
|
# attacker-shaped (anyone who can dispatch the workflow types it), so
|
|
# refuse anything that is not a literal version tag.
|
|
[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo "::error::unexpected tag: $tag"; exit 1; }
|
|
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
|
|
|
# Attaches downloadable binaries to the release GitHub just created, or to an
|
|
# existing tag when dispatched manually with `tag` (recovering a target that
|
|
# failed to upload needs a re-run of *this* file, not the old one).
|
|
binaries:
|
|
name: ${{ matrix.target }}
|
|
needs: release
|
|
# `github.event.inputs`, not `inputs`: the latter is empty in a job-level
|
|
# condition on workflow_dispatch, and `null != ''` coerces to false, which
|
|
# silently skipped this job on every manual recovery run.
|
|
if: needs.release.outputs.created == 'true' || github.event.inputs.tag != ''
|
|
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: 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.
|
|
#
|
|
# build-tool is pinned to cargo because the action otherwise reaches for
|
|
# `cross` on any *-musl target. Every entry in the matrix builds on a
|
|
# runner of its own architecture — only the libc differs — so cross adds
|
|
# nothing, and on the native arm64 runner it hard-fails trying to install
|
|
# an x86_64 toolchain.
|
|
- uses: taiki-e/upload-rust-binary-action@v1
|
|
with:
|
|
bin: soulseek-rs
|
|
target: ${{ matrix.target }}
|
|
build-tool: cargo
|
|
archive: $bin-$tag-$target
|
|
include: README.md,LICENSE
|
|
checksum: sha256
|
|
ref: refs/tags/${{ needs.release.outputs.tag }}
|
|
token: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
# Rewrites Formula/soulseek-rs.rb in the tap to point at the archives the
|
|
# matrix just uploaded. The formula is generated whole rather than patched, so
|
|
# the workflow is the only place its contents live.
|
|
homebrew:
|
|
name: Homebrew tap
|
|
needs: [release, binaries]
|
|
if: needs.release.outputs.created == 'true' || github.event.inputs.tag != ''
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
repository: michel/homebrew-tap
|
|
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
|
|
|
- name: Write formula
|
|
env:
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
TAG: ${{ needs.release.outputs.tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}"
|
|
sha() {
|
|
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
|
|
--pattern "soulseek-rs-$TAG-$1.sha256" --output - | cut -d' ' -f1
|
|
}
|
|
mkdir -p Formula
|
|
cat > Formula/soulseek-rs.rb <<EOF
|
|
# Generated by the soulseek-rs release workflow. Do not edit by hand.
|
|
class SoulseekRs < Formula
|
|
desc "Soulseek client in Rust"
|
|
homepage "https://re-invention.nl/soulseek-rs/"
|
|
version "${TAG#v}"
|
|
license "MIT"
|
|
|
|
on_macos do
|
|
on_arm do
|
|
url "$base/soulseek-rs-$TAG-aarch64-apple-darwin.tar.gz"
|
|
sha256 "$(sha aarch64-apple-darwin)"
|
|
end
|
|
on_intel do
|
|
url "$base/soulseek-rs-$TAG-x86_64-apple-darwin.tar.gz"
|
|
sha256 "$(sha x86_64-apple-darwin)"
|
|
end
|
|
end
|
|
|
|
on_linux do
|
|
on_arm do
|
|
url "$base/soulseek-rs-$TAG-aarch64-unknown-linux-musl.tar.gz"
|
|
sha256 "$(sha aarch64-unknown-linux-musl)"
|
|
end
|
|
on_intel do
|
|
url "$base/soulseek-rs-$TAG-x86_64-unknown-linux-musl.tar.gz"
|
|
sha256 "$(sha x86_64-unknown-linux-musl)"
|
|
end
|
|
end
|
|
|
|
def install
|
|
bin.install "soulseek-rs"
|
|
end
|
|
|
|
test do
|
|
assert_match version.to_s, shell_output("#{bin}/soulseek-rs --version")
|
|
end
|
|
end
|
|
EOF
|
|
|
|
- name: Commit
|
|
env:
|
|
TAG: ${{ needs.release.outputs.tag }}
|
|
run: |
|
|
git config user.name "github-actions[bot]"
|
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
git add Formula/soulseek-rs.rb
|
|
git diff --staged --quiet || git commit -m "soulseek-rs $TAG"
|
|
git push
|