release-plz owns the workspace version and the changelog, and writes both on master. Nothing carried either back, so develop still declared 14.1.2 five releases after v14.1.2 shipped. Nightly builds the tip of develop, which meant every nightly binary reported --version 14.1.2 against a released 18.0.0, and the changelog on the branch the work happens on stopped at v14.1.2 too. The job merges master whole rather than cherry-picking the bump, so the changelog travels with it, and hangs off `release` rather than `binaries` so the sync does not wait on six binary builds. develop never edits those lines itself, which is what keeps the merge clean; a conflict fails the job rather than resolving itself quietly. Nothing in the repository triggers on a push to develop, so this neither loops back into the workflow nor spends a CI run.
269 lines
11 KiB
YAML
269 lines
11 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.RELEASE_PLZ_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:
|
|
tag: ${{ steps.tag.outputs.tag }}
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
persist-credentials: false
|
|
|
|
- uses: dtolnay/rust-toolchain@stable
|
|
|
|
# Skipped when a tag was dispatched by hand: that run exists to rebuild
|
|
# binaries for a tag that already shipped. Letting release-plz also
|
|
# publish here produced exactly one bad outcome in practice - it cut
|
|
# v9.1.0 while `Resolve tag` went on to honour the dispatched v9.0.0, so
|
|
# the new release got no binaries at all and the tap, which tracks the
|
|
# latest release, had nothing to generate a formula from.
|
|
- uses: release-plz/action@v0.5
|
|
id: release-plz
|
|
if: inputs.tag == ''
|
|
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).
|
|
#
|
|
# Gated on the resolved tag, which `Resolve tag` has already folded both paths
|
|
# into and validated, so this is the single place deciding whether there is
|
|
# anything to build. `!cancelled()` is load-bearing: on a manual dispatch
|
|
# ci-gate is skipped, and that skip propagates down the whole needs graph —
|
|
# past `release`, which only survives it via its own always() — so without a
|
|
# status function here every manual rebuild was skipped no matter what the
|
|
# rest of the condition said.
|
|
binaries:
|
|
name: ${{ matrix.target }}
|
|
needs: release
|
|
if: ${{ !cancelled() && needs.release.outputs.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 }}
|
|
|
|
# The tap regenerates its formula from whatever `gh release view` calls latest,
|
|
# so it only needs a nudge once every archive and .sha256 is on the release —
|
|
# hence `needs: binaries` rather than firing alongside it. The tap also polls
|
|
# hourly on its own; that cron stays as the recovery path for when this
|
|
# dispatch fails (expired token, tap outage) rather than as the mechanism.
|
|
tap:
|
|
name: Update Homebrew tap
|
|
needs: binaries
|
|
if: ${{ !cancelled() && needs.binaries.result == 'success' }}
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Dispatch the tap's formula workflow
|
|
env:
|
|
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
|
run: |
|
|
# gh answers a missing token with generic "set GH_TOKEN" advice, which
|
|
# reads like a workflow bug rather than a secret that needs filling in.
|
|
[ -n "$GH_TOKEN" ] || {
|
|
echo "::error::HOMEBREW_TAP_TOKEN is empty - set it to a PAT with Actions: write on michel/homebrew-tap. The tap's hourly cron will pick this release up in the meantime."
|
|
exit 1
|
|
}
|
|
gh workflow run formula.yml --repo michel/homebrew-tap
|
|
|
|
# release-plz writes the version bump and the changelog on master, and until
|
|
# now nothing carried them back: develop was still declaring 14.1.2 five
|
|
# releases after v14.1.2 shipped. Nightly builds the tip of develop, so every
|
|
# nightly binary reported a version that had not been current for weeks, and
|
|
# the changelog on the branch the work happens on stopped at v14.1.2.
|
|
#
|
|
# Hangs off `release` rather than `binaries` so the sync does not wait on six
|
|
# binary builds, and merges master whole rather than cherry-picking the bump
|
|
# so that the changelog comes along with it. develop never edits those lines
|
|
# itself, which is what keeps the merge clean; on the day it does conflict
|
|
# this job fails loudly and the merge wants doing by hand.
|
|
#
|
|
# Nothing in this repository triggers on a push to develop, so pushing here
|
|
# neither loops back into this workflow nor spends a CI run.
|
|
backmerge:
|
|
name: Back-merge into develop
|
|
needs: release
|
|
if: ${{ !cancelled() && needs.release.outputs.tag != '' }}
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
ref: develop
|
|
token: ${{ secrets.RELEASE_PLZ_TOKEN || secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Merge master into develop
|
|
env:
|
|
TAG: ${{ needs.release.outputs.tag }}
|
|
run: |
|
|
# Spelled out rather than read from master's tip: this history carries
|
|
# one person's attribution, and whoever happened to click merge on the
|
|
# release PR is not the right source for it.
|
|
git config user.name "Michel de Graaf"
|
|
git config user.email "michel@re-invention.nl"
|
|
# checkout was pointed at develop, and which other tracking refs it
|
|
# leaves behind is its own business — ask for master by name.
|
|
git fetch origin master
|
|
# A manual binary rebuild reaches this job with develop already
|
|
# containing master; git says "Already up to date" and the push is a
|
|
# no-op, so the dispatch path needs no guard of its own.
|
|
git merge --no-ff origin/master -m "merge: back-merge $TAG into develop"
|
|
git push origin develop
|