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 }} - name: Build and upload .deb and .rpm if: endsWith(matrix.target, '-musl') && hashFiles('scripts/package-linux.sh') != '' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ needs.release.outputs.tag }} TARGET: ${{ matrix.target }} run: | sh scripts/package-linux.sh "target/$TARGET/release/soulseek-rs" "${TAG#v}" "$TARGET" dist gh release upload "$TAG" dist/* --clobber # 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