name: Release
on:
push:
branches:
- main
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
jobs:
resolve-tag:
name: Resolve Release Tag
runs-on: ubuntu-latest
outputs:
release_tag: ${{ steps.resolve.outputs.release_tag }}
clean_version: ${{ steps.resolve.outputs.clean_version }}
base_version: ${{ steps.resolve.outputs.base_version }}
is_prerelease: ${{ steps.resolve.outputs.is_prerelease }}
tag_exists: ${{ steps.resolve.outputs.tag_exists }}
release_exists: ${{ steps.resolve.outputs.release_exists }}
release_draft: ${{ steps.resolve.outputs.release_draft }}
should_release: ${{ steps.resolve.outputs.should_release }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: resolve
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
CLEAN_VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n 1)"
if [[ -z "${CLEAN_VERSION}" ]]; then
echo "Failed to read Cargo.toml version."
exit 1
fi
BASE_VERSION="${CLEAN_VERSION%%-*}"
if [[ "${CLEAN_VERSION}" == *"-"* ]]; then
IS_PRERELEASE="true"
else
IS_PRERELEASE="false"
fi
TAG="v${CLEAN_VERSION}"
if git rev-parse --verify "refs/tags/${TAG}" >/dev/null 2>&1; then
TAG_EXISTS="true"
else
TAG_EXISTS="false"
fi
RELEASE_EXISTS="false"
RELEASE_DRAFT="false"
RELEASE_URL="https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}"
RELEASE_JSON="$(mktemp)"
if curl --fail --silent \
--header "Authorization: Bearer ${GITHUB_TOKEN}" \
--header "Accept: application/vnd.github+json" \
"${RELEASE_URL}" > "${RELEASE_JSON}"; then
RELEASE_EXISTS="true"
if grep -q '"draft":[[:space:]]*true' "${RELEASE_JSON}"; then
RELEASE_DRAFT="true"
fi
fi
rm -f "${RELEASE_JSON}"
if [[ "${RELEASE_EXISTS}" == "true" && "${RELEASE_DRAFT}" != "true" ]]; then
SHOULD_RELEASE="false"
else
SHOULD_RELEASE="true"
fi
echo "release_tag=${TAG}" >> "${GITHUB_OUTPUT}"
echo "clean_version=${CLEAN_VERSION}" >> "${GITHUB_OUTPUT}"
echo "base_version=${BASE_VERSION}" >> "${GITHUB_OUTPUT}"
echo "is_prerelease=${IS_PRERELEASE}" >> "${GITHUB_OUTPUT}"
echo "tag_exists=${TAG_EXISTS}" >> "${GITHUB_OUTPUT}"
echo "release_exists=${RELEASE_EXISTS}" >> "${GITHUB_OUTPUT}"
echo "release_draft=${RELEASE_DRAFT}" >> "${GITHUB_OUTPUT}"
echo "should_release=${SHOULD_RELEASE}" >> "${GITHUB_OUTPUT}"
{
echo "Detected version: ${CLEAN_VERSION}"
echo "Resolved release tag: ${TAG}"
if [[ "${SHOULD_RELEASE}" == "false" ]]; then
echo "Published release already exists for ${TAG}. Skipping release pipeline."
elif [[ "${RELEASE_EXISTS}" == "true" && "${RELEASE_DRAFT}" == "true" ]]; then
echo "Draft release already exists for ${TAG}. Continuing release pipeline."
elif [[ "${TAG_EXISTS}" == "true" ]]; then
echo "Tag exists but GitHub release is missing. Continuing release pipeline."
fi
} >> "${GITHUB_STEP_SUMMARY}"
prepare-release:
name: Prepare GitHub Release
needs: [resolve-tag, validate]
if: needs.resolve-tag.outputs.should_release == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Ensure Draft Release Exists
uses: ncipollo/release-action@v1
with:
tag: ${{ needs.resolve-tag.outputs.release_tag }}
commit: ${{ github.sha }}
name: Release ${{ needs.resolve-tag.outputs.release_tag }}
draft: true
allowUpdates: true
prerelease: ${{ needs.resolve-tag.outputs.is_prerelease == 'true' }}
validate:
name: Validate Release
needs: resolve-tag
if: needs.resolve-tag.outputs.should_release == 'true'
runs-on: ubuntu-latest
env:
CARGO_NET_OFFLINE: "false"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
- name: Install cargo-make
uses: davidB/rust-cargo-make@v1
- name: Install cargo-nextest
uses: taiki-e/install-action@v2
with:
tool: cargo-nextest
- name: Install cargo-audit
uses: taiki-e/install-action@v2
with:
tool: cargo-audit
- name: Install cargo-deny
uses: taiki-e/install-action@v2
with:
tool: cargo-deny
- name: Verify Changelog Section (if CHANGELOG.md exists)
shell: bash
run: |
BASE_VERSION="${{ needs.resolve-tag.outputs.base_version }}"
if [[ -f CHANGELOG.md ]]; then
if ! grep -qE "^##[[:space:]]+${BASE_VERSION}$" CHANGELOG.md; then
echo "Missing CHANGELOG.md section for ${BASE_VERSION}."
exit 1
fi
else
echo "CHANGELOG.md not found. Skipping changelog section validation."
fi
- name: Release Info
shell: bash
run: |
TAG="${{ needs.resolve-tag.outputs.release_tag }}"
IS_PRERELEASE="${{ needs.resolve-tag.outputs.is_prerelease }}"
if [[ "${IS_PRERELEASE}" == "true" ]]; then
{
echo "Detected pre-release tag: ${TAG}"
echo "Crates.io publish is skipped for prerelease versions."
} >> "${GITHUB_STEP_SUMMARY}"
else
{
echo "Detected release tag: ${TAG}"
echo "Crates.io publish is enabled when CARGO_REGISTRY_TOKEN is available."
} >> "${GITHUB_STEP_SUMMARY}"
fi
- name: Audit Dependencies
run: cargo make audit
- name: Deny Check
run: cargo make deny
- name: Run Tests
run: cargo make test
- name: Lint
run: cargo make clippy
- name: Format Check
run: cargo make format-check
- name: Verify Publish Readiness
run: cargo publish --dry-run --locked
publish-crates:
name: Publish to Crates.io
needs: [resolve-tag, validate, prepare-release]
if: needs.resolve-tag.outputs.should_release == 'true' && needs.resolve-tag.outputs.is_prerelease == 'false'
runs-on: ubuntu-latest
env:
CARGO_NET_OFFLINE: "false"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Check CARGO_REGISTRY_TOKEN
id: token
shell: bash
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: |
if [[ -z "${CARGO_REGISTRY_TOKEN}" ]]; then
echo "present=false" >> "${GITHUB_OUTPUT}"
echo "CARGO_REGISTRY_TOKEN is not configured. Skipping crates.io publish." >> "${GITHUB_STEP_SUMMARY}"
else
echo "present=true" >> "${GITHUB_OUTPUT}"
fi
- name: Check Crates.io Version
id: crate
shell: bash
run: |
VERSION="${{ needs.resolve-tag.outputs.clean_version }}"
CRATE="slotstrike"
META_URL="https://crates.io/api/v1/crates/${CRATE}"
if curl --fail --silent "${META_URL}" | grep -q "\"num\":\"${VERSION}\""; then
echo "exists=true" >> "${GITHUB_OUTPUT}"
echo "${CRATE} ${VERSION} already exists on crates.io. Skipping publish." >> "${GITHUB_STEP_SUMMARY}"
else
echo "exists=false" >> "${GITHUB_OUTPUT}"
fi
- name: Publish to Cargo
if: steps.token.outputs.present == 'true' && steps.crate.outputs.exists != 'true'
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: cargo publish --locked
build-binaries:
name: Build Binaries
needs: [resolve-tag, validate, prepare-release]
if: needs.resolve-tag.outputs.should_release == 'true'
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
suffix: linux-x86_64
archive: tar.gz
- os: windows-latest
target: x86_64-pc-windows-msvc
suffix: windows-x86_64
archive: zip
- os: macos-15-intel
target: x86_64-apple-darwin
suffix: macos-x86_64
archive: tar.gz
- os: macos-14
target: aarch64-apple-darwin
suffix: macos-arm64
archive: tar.gz
runs-on: ${{ matrix.os }}
env:
CARGO_NET_OFFLINE: "false"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
- name: Build Release
run: cargo build --release --target ${{ matrix.target }} --locked
- name: Package Assets
shell: bash
run: |
TAG="${{ needs.resolve-tag.outputs.release_tag }}"
BIN_NAME="slotstrike"
if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
BIN_NAME="slotstrike.exe"
fi
mkdir -p dist
cp "target/${{ matrix.target }}/release/${BIN_NAME}" dist/
cp README.md LICENSE slotstrike.example.toml dist/ 2>/dev/null || :
cd dist
if [[ "${{ matrix.archive }}" == "tar.gz" ]]; then
tar -czf "../slotstrike-${TAG}-${{ matrix.suffix }}.tar.gz" *
else
7z a "../slotstrike-${TAG}-${{ matrix.suffix }}.zip" *
fi
- name: Upload Build Artifact
uses: actions/upload-artifact@v4
with:
name: slotstrike-${{ matrix.suffix }}
path: slotstrike-${{ needs.resolve-tag.outputs.release_tag }}-${{ matrix.suffix }}.${{ matrix.archive }}
if-no-files-found: error
release:
name: Create GitHub Release
needs: [resolve-tag, prepare-release, build-binaries, publish-crates]
if: >
always() &&
needs.resolve-tag.outputs.should_release == 'true' &&
needs.prepare-release.result == 'success' &&
needs.build-binaries.result == 'success' &&
(needs.publish-crates.result == 'success' || needs.publish-crates.result == 'skipped')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
- name: Download Build Artifacts
uses: actions/download-artifact@v4
with:
path: release-artifacts
merge-multiple: true
- name: Build Release Notes
id: notes
shell: bash
run: |
TAG="${{ needs.resolve-tag.outputs.release_tag }}"
BASE_VERSION="${{ needs.resolve-tag.outputs.base_version }}"
NOTES_FILE="${RUNNER_TEMP}/release-notes.md"
{
echo "### What's New"
echo
if [[ -f CHANGELOG.md ]] && grep -qE "^##[[:space:]]+${BASE_VERSION}$" CHANGELOG.md; then
awk -v version="${BASE_VERSION}" '
$0 ~ "^##[[:space:]]+" version "$" { in_section=1; next }
in_section && $0 ~ "^##[[:space:]]+" { exit }
in_section { print }
' CHANGELOG.md
else
echo "- Automated release for ${TAG}."
echo "- See commit history for details."
fi
echo
echo "### Installation"
echo '```bash'
echo 'cargo install slotstrike'
echo '```'
} > "${NOTES_FILE}"
echo "path=${NOTES_FILE}" >> "${GITHUB_OUTPUT}"
- name: Create GitHub Release
uses: ncipollo/release-action@v1
with:
tag: ${{ needs.resolve-tag.outputs.release_tag }}
commit: ${{ github.sha }}
name: Release ${{ needs.resolve-tag.outputs.release_tag }}
bodyFile: ${{ steps.notes.outputs.path }}
artifacts: release-artifacts/*
allowUpdates: true
draft: false
prerelease: ${{ needs.resolve-tag.outputs.is_prerelease == 'true' }}
makeLatest: ${{ needs.resolve-tag.outputs.is_prerelease == 'false' }}