Three context spellings were tried in these job conditions and all three left the jobs skipped: `inputs.tag`, `github.event.inputs.tag`, and `needs.release.outputs.tag`. The run logs show `Resolve tag` succeeding with INPUT_TAG=v7.0.0 and writing the output, so the value exists in the step and is gone by the time a downstream `if` reads it. `github.event_name` is the one context this file already proves works in a job condition, because ci-gate skips correctly on dispatch. Gate on that, and echo both outputs at the end of the release job so the next failure names itself instead of costing four runs.
278 lines
10 KiB
YAML
278 lines
10 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"
|
|
echo "resolved tag: '$tag'"
|
|
|
|
# The downstream jobs gate on these, and a silent empty value here cost
|
|
# four release runs, so say them out loud.
|
|
- name: Report what the downstream jobs will see
|
|
run: |
|
|
echo "created = '${{ steps.release-plz.outputs.releases_created }}'"
|
|
echo "tag = '${{ steps.tag.outputs.tag }}'"
|
|
|
|
# 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_name` is the one context proven to evaluate here: ci-gate
|
|
# uses it and skips correctly on dispatch. `inputs.tag`,
|
|
# `github.event.inputs.tag` and `needs.release.outputs.tag` were all tried
|
|
# and all left this job skipped, which is how v7.0.0 shipped to crates.io
|
|
# with an empty homebrew tap. A dispatch without a tag now fails loudly in
|
|
# the checkout below rather than skipping in silence.
|
|
if: needs.release.outputs.created == 'true' || github.event_name == 'workflow_dispatch'
|
|
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_name == 'workflow_dispatch'
|
|
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
|