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 if: needs.release.outputs.created == 'true' || 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 }}