github-actions-maintainer 0.7.4

General-purpose GitHub Actions maintenance toolkit with secure workflow pinning
Documentation
name: Release

permissions:
  contents: read

on:
  push:
    tags:
      - "v*.*.*"
  workflow_dispatch:
    inputs:
      version:
        description: Version to release (e.g. 1.0.0)
        required: true
        type: string
      source_ref:
        description: Git ref to build from
        required: false
        type: string
      prerelease:
        description: Is this a pre-release?
        required: false
        type: boolean
        default: false

env:
  CARGO_TERM_COLOR: always
  RUST_BACKTRACE: 1
  RUST_VERSION: 1.97.1
  BINARY_NAME: ${{ vars.RUST_TEMPLATE_BINARY_NAME || 'github-actions-maintainer' }}
  BINARY_PACKAGE: ${{ vars.RUST_TEMPLATE_BINARY_PACKAGE || '' }}
  SBOM_MANIFEST_PATH: ${{ vars.RUST_TEMPLATE_SBOM_MANIFEST_PATH || 'Cargo.toml' }}
  PUBLISH_PACKAGES: ${{ vars.RUST_TEMPLATE_PUBLISH_PACKAGES || '' }}
  SEMVER_PATTERN: '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*)|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.((0|[1-9][0-9]*)|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?$'

jobs:
  prepare:
    name: Prepare Release
    runs-on: ${{ vars.RUST_TEMPLATE_RUNNER_UBUNTU || 'ubuntu-latest' }}
    permissions:
      contents: write
    outputs:
      version: ${{ steps.version.outputs.version }}
      source_ref: ${{ steps.release_ref.outputs.source_ref }}
    steps:
      - name: Determine source ref
        id: source_ref
        env:
          EVENT_NAME: ${{ github.event_name }}
          REQUESTED_SOURCE_REF: ${{ github.event.inputs.source_ref }}
        run: |
          if [[ "${EVENT_NAME}" == "workflow_dispatch" && -n "${REQUESTED_SOURCE_REF}" ]]; then
            SOURCE_REF="${REQUESTED_SOURCE_REF}"
          else
            SOURCE_REF="${GITHUB_REF}"
          fi
          if ! git check-ref-format --branch "${SOURCE_REF}" >/dev/null; then
            echo "Invalid source ref: ${SOURCE_REF}" >&2
            exit 1
          fi
          echo "source_ref=${SOURCE_REF}" >> "$GITHUB_OUTPUT"

      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0
          ref: ${{ steps.source_ref.outputs.source_ref }}

      - name: Determine version
        id: version
        env:
          EVENT_NAME: ${{ github.event_name }}
          REQUESTED_VERSION: ${{ github.event.inputs.version }}
        run: |
          if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
            VERSION="${REQUESTED_VERSION}"
          else
            VERSION=${GITHUB_REF#refs/tags/v}
          fi
          if ! [[ "${VERSION}" =~ ${SEMVER_PATTERN} ]]; then
            echo "Invalid version format: ${VERSION}" >&2
            exit 1
          fi
          echo "version=$VERSION" >> "$GITHUB_OUTPUT"

      - name: Validate version format
        run: |
          VERSION="${{ steps.version.outputs.version }}"
          if ! [[ "$VERSION" =~ ${SEMVER_PATTERN} ]]; then
            echo "Invalid version format: $VERSION"
            exit 1
          fi

      - name: Validate manifest version
        run: |
          VERSION="${{ steps.version.outputs.version }}"
          CURRENT_VERSION="$(python3 scripts/release_version.py current)"
          if [[ "${CURRENT_VERSION}" != "${VERSION}" ]]; then
            echo "Cargo manifest version ${CURRENT_VERSION} does not match release version ${VERSION}"
            exit 1
          fi

      - name: Ensure release tag
        id: release_ref
        run: |
          set -euo pipefail
          VERSION="${{ steps.version.outputs.version }}"
          TAG="v${VERSION}"
          TARGET_REF="${{ steps.source_ref.outputs.source_ref }}"
          TARGET_SHA="$(git rev-parse "${TARGET_REF}^{commit}")"

          if git rev-parse -q --verify "refs/tags/${TAG}^{commit}" >/dev/null; then
            TAG_SHA="$(git rev-parse "${TAG}^{commit}")"
            if [[ "${TAG_SHA}" != "${TARGET_SHA}" ]]; then
              echo "Existing tag ${TAG} points to ${TAG_SHA}, expected ${TARGET_SHA}"
              exit 1
            fi
          elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
            git tag -a "${TAG}" "${TARGET_SHA}" -m "Release ${TAG}"
            git push origin "refs/tags/${TAG}"
          else
            echo "Missing expected release tag ${TAG}"
            exit 1
          fi

          echo "source_ref=refs/tags/${TAG}" >> "$GITHUB_OUTPUT"

  build:
    name: Build (${{ matrix.target }})
    needs: prepare
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: ${{ vars.RUST_TEMPLATE_RUNNER_UBUNTU || 'ubuntu-latest' }}
            target: x86_64-unknown-linux-gnu
            artifact: linux-amd64
          - os: ${{ vars.RUST_TEMPLATE_RUNNER_UBUNTU || 'ubuntu-latest' }}
            target: x86_64-unknown-linux-musl
            artifact: linux-musl-amd64
            use_cross: true
          - os: ${{ vars.RUST_TEMPLATE_RUNNER_UBUNTU || 'ubuntu-latest' }}
            target: aarch64-unknown-linux-gnu
            artifact: linux-arm64
            use_cross: true
          - os: ${{ vars.RUST_TEMPLATE_RUNNER_MACOS_ARM64 || 'macos-15' }}
            target: aarch64-apple-darwin
            artifact: macos-arm64
          - os: ${{ vars.RUST_TEMPLATE_RUNNER_MACOS_X64 || 'macos-15-intel' }}
            target: x86_64-apple-darwin
            artifact: macos-amd64
          - os: ${{ vars.RUST_TEMPLATE_RUNNER_WINDOWS || 'windows-latest' }}
            target: x86_64-pc-windows-msvc
            artifact: windows-amd64
    runs-on: ${{ matrix.os }}
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ needs.prepare.outputs.source_ref }}
          persist-credentials: false

      - name: Install Rust
        uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
        with:
          toolchain: ${{ env.RUST_VERSION }}
          targets: ${{ matrix.target }}

      - name: Install dependencies (Ubuntu)
        if: contains(matrix.os, 'ubuntu')
        run: |
          sudo apt-get update
          sudo apt-get install -y libssl-dev pkg-config

      - name: Install cross
        if: matrix.use_cross
        run: cargo install cross

      - name: Build (cross)
        if: matrix.use_cross
        run: |
          if [[ -n "${BINARY_PACKAGE}" ]]; then
            cross build --release --target ${{ matrix.target }} -p "${BINARY_PACKAGE}" --bin "${BINARY_NAME}" --all-features
          else
            cross build --release --target ${{ matrix.target }} --bin "${BINARY_NAME}" --all-features || cross build --release --target ${{ matrix.target }} --all-features
          fi

      - name: Build (native)
        if: "!matrix.use_cross"
        # Windows runners default to PowerShell; this step is bash syntax.
        shell: bash
        run: |
          if [[ -n "${BINARY_PACKAGE}" ]]; then
            cargo build --release --target ${{ matrix.target }} -p "${BINARY_PACKAGE}" --bin "${BINARY_NAME}" --all-features
          else
            cargo build --release --target ${{ matrix.target }} --bin "${BINARY_NAME}" --all-features || cargo build --release --target ${{ matrix.target }} --all-features
          fi

      - name: Package (Unix)
        if: runner.os != 'Windows'
        run: |
          mkdir -p dist
          cp target/${{ matrix.target }}/release/${BINARY_NAME} dist/
          cd dist
          tar czf ../${BINARY_NAME}-${{ matrix.artifact }}.tar.gz *
          cd ..
          shasum -a 256 ${BINARY_NAME}-${{ matrix.artifact }}.tar.gz > ${BINARY_NAME}-${{ matrix.artifact }}.tar.gz.sha256

      - name: Package (Windows)
        if: runner.os == 'Windows'
        shell: pwsh
        run: |
          mkdir dist
          Copy-Item "target/${{ matrix.target }}/release/${env:BINARY_NAME}.exe" dist/
          $archive = "${env:BINARY_NAME}-${{ matrix.artifact }}.zip"
          Compress-Archive -Path dist/* -DestinationPath $archive
          $hash = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLower()
          "$hash  $archive" | Out-File -Encoding ascii "$archive.sha256"

      - name: Upload artifacts (Unix)
        if: runner.os != 'Windows'
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: release-${{ matrix.artifact }}
          path: "*.tar.gz*"
          retention-days: 5

      - name: Upload artifacts (Windows)
        if: runner.os == 'Windows'
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: release-${{ matrix.artifact }}
          path: "*.zip*"
          retention-days: 5

  sbom:
    name: Generate Release SBOMs
    needs: prepare
    runs-on: ${{ vars.RUST_TEMPLATE_RUNNER_UBUNTU || 'ubuntu-latest' }}
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ needs.prepare.outputs.source_ref }}
          persist-credentials: false

      - name: Install Rust
        uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
        with:
          toolchain: ${{ env.RUST_VERSION }}

      - name: Install cargo-cyclonedx
        uses: taiki-e/install-action@6c6fd71fe4fb72c3697d269963d0e15df8adedad # v2.85.10
        with:
          tool: cargo-cyclonedx@0.5.8

      - name: Generate SBOM files
        run: |
          make sbom BINARY_NAME="${BINARY_NAME}" SBOM_MANIFEST_PATH="${SBOM_MANIFEST_PATH}"
          VERSION="${{ needs.prepare.outputs.version }}"
          mv "sbom/${BINARY_NAME}-sbom.json" "sbom/${BINARY_NAME}-v${VERSION}.cdx.json"

      - name: Upload SBOM artifacts
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: release-sbom
          path: sbom/*.json
          retention-days: 5

  upload-assets:
    name: Upload Release Assets
    needs: [prepare, build, sbom]
    runs-on: ${{ vars.RUST_TEMPLATE_RUNNER_UBUNTU || 'ubuntu-latest' }}
    permissions:
      contents: write
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ needs.prepare.outputs.source_ref }}
          persist-credentials: false

      - name: Generate release notes
        run: |
          VERSION="${{ needs.prepare.outputs.version }}"
          echo "## Release v$VERSION" > release_notes.md
          echo "" >> release_notes.md
          if [ -f CHANGELOG.md ]; then
            awk "/## \[$VERSION\]/,/## \[/" CHANGELOG.md | sed '1d;$d' >> release_notes.md 2>/dev/null || echo "See CHANGELOG.md for details" >> release_notes.md
          fi

      - name: Create or update GitHub Release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          VERSION="${{ needs.prepare.outputs.version }}"
          TAG="v${VERSION}"
          TITLE="Release v${VERSION}"
          IS_PRERELEASE="false"
          if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.prerelease && 'true' || 'false' }}" == "true" ]]; then
            IS_PRERELEASE="true"
          fi
          if [[ "${VERSION}" == *-* ]]; then
            IS_PRERELEASE="true"
          fi

          if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
            gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --title "$TITLE" --notes-file release_notes.md
          else
            if [[ "${IS_PRERELEASE}" == "true" ]]; then
              gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --title "$TITLE" --notes-file release_notes.md --prerelease
            else
              gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --title "$TITLE" --notes-file release_notes.md
            fi
          fi

      - name: Download all artifacts
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          path: artifacts
          pattern: release-*

      - name: Upload to release
        run: |
          for file in artifacts/**/*; do
            if [ -f "$file" ]; then
              gh release upload "v${{ needs.prepare.outputs.version }}" "$file" --clobber
            fi
          done
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  publish:
    name: Publish to crates.io
    needs: [prepare, build]
    runs-on: ${{ vars.RUST_TEMPLATE_RUNNER_UBUNTU || 'ubuntu-latest' }}
    if: "!contains(needs.prepare.outputs.version, '-')"
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          ref: ${{ needs.prepare.outputs.source_ref }}
          persist-credentials: false

      - name: Install Rust
        uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
        with:
          toolchain: ${{ env.RUST_VERSION }}

      - name: Check crates.io token
        id: check_token
        run: |
          TOKEN="${{ secrets.CRATES_IO_TOKEN || secrets.CARGO_REGISTRY_TOKEN }}"
          if [ -n "$TOKEN" ]; then
            echo "has_token=true" >> "$GITHUB_OUTPUT"
          else
            echo "has_token=false" >> "$GITHUB_OUTPUT"
            echo "Skipping crates.io publish - token not configured"
          fi

      - name: Publish to crates.io
        if: steps.check_token.outputs.has_token == 'true'
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN || secrets.CARGO_REGISTRY_TOKEN }}
        run: |
          set -euo pipefail
          VERSION="${{ needs.prepare.outputs.version }}"
          # Wait until crates.io serves the published version so dependent
          # packages in PUBLISH_PACKAGES can resolve it.
          version_visible() {
            curl -fsSL "https://crates.io/api/v1/crates/$1" \
              | python3 -c 'import json, sys; payload = json.load(sys.stdin); raise SystemExit(0 if any(v["num"] == sys.argv[1] for v in payload.get("versions", [])) else 1)' "$VERSION"
          }
          if [[ -n "${PUBLISH_PACKAGES}" ]]; then
            for package in ${PUBLISH_PACKAGES}; do
              cargo publish -p "${package}" --locked
              for _ in $(seq 1 20); do
                if version_visible "${package}"; then
                  break
                fi
                sleep 15
              done
            done
          else
            cargo publish --locked
          fi