sagittarius 0.2.0

A fast, self-hosted DNS sinkhole in a single Rust binary
Documentation
name: Release

# Cutting a release is driven entirely by pushing a semver tag, e.g.:
#
#   git tag v0.1.0 && git push origin v0.1.0
#
# Every job below gates on `verify`, which fails fast if the tag does not match
# the crate version or if the tree is not green. Pre-release tags (e.g.
# v0.1.0-rc1) are accepted and flow through as pre-releases downstream.
on:
  push:
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+*'

# Least privilege by default; individual jobs opt into the scopes they need.
permissions: {}

# Never cancel an in-flight release; serialise per tag instead.
concurrency:
  group: release-${{ github.ref }}
  cancel-in-progress: false

env:
  SQLX_OFFLINE: "true"

jobs:
  verify:
    name: Verify tag + green tree
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      # Fail in seconds if the pushed tag and Cargo.toml disagree, before any
      # toolchain is installed or anything is published.
      - name: Tag matches Cargo.toml version
        run: |
          tag="${GITHUB_REF_NAME#v}"
          crate="$(grep -m1 -E '^version[[:space:]]*=' Cargo.toml \
            | sed -E 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/')"
          echo "tag version:   $tag"
          echo "crate version: $crate"
          if [ "$tag" != "$crate" ]; then
            echo "::error::pushed tag v$tag does not match Cargo.toml version $crate"
            exit 1
          fi

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          components: rustfmt, clippy

      - name: Cache dependencies
        uses: Swatinem/rust-cache@v2

      - name: Check formatting
        run: cargo fmt --all --check

      - name: Clippy
        run: cargo clippy --all-targets -- -D warnings

      - name: Tests
        run: cargo test

  build:
    name: Build ${{ matrix.target }}
    needs: verify
    permissions:
      contents: read
    strategy:
      fail-fast: false
      matrix:
        include:
          - target: x86_64-unknown-linux-gnu
            runner: ubuntu-latest
          - target: aarch64-unknown-linux-gnu
            runner: ubuntu-24.04-arm
    runs-on: ${{ matrix.runner }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}

      - name: Cache dependencies
        uses: Swatinem/rust-cache@v2
        with:
          key: ${{ matrix.target }}

      # Each target is built natively (x86_64 on ubuntu-latest, aarch64 on the
      # ubuntu-24.04-arm runner), so the ring + bundled-SQLite C deps need no
      # cross toolchain.
      - name: Build
        run: cargo build --release --locked --target ${{ matrix.target }}

      - name: Package
        run: |
          version="${GITHUB_REF_NAME#v}"
          pkg="sagittarius-${version}-${{ matrix.target }}"
          stage="dist/${pkg}"
          mkdir -p "${stage}"
          cp "target/${{ matrix.target }}/release/sagittarius" "${stage}/"
          strip "${stage}/sagittarius"
          cp README.md CHANGELOG.md LICENSE-MIT LICENSE-APACHE "${stage}/"
          tar -C dist -czf "dist/${pkg}.tar.gz" "${pkg}"
          (cd dist && sha256sum "${pkg}.tar.gz" > "${pkg}.tar.gz.sha256")

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.target }}
          path: |
            dist/*.tar.gz
            dist/*.sha256

  github-release:
    name: GitHub Release
    needs: build
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0 # full history so git-cliff can build the notes

      - name: Generate release notes
        id: cliff
        uses: orhun/git-cliff-action@v4
        with:
          config: cliff.toml
          args: --latest --strip header

      - name: Download artifacts
        uses: actions/download-artifact@v4
        with:
          path: dist
          merge-multiple: true

      - name: Detect pre-release
        id: prerelease
        run: |
          if [[ "${GITHUB_REF_NAME}" == *-* ]]; then
            echo "value=true" >> "${GITHUB_OUTPUT}"
          else
            echo "value=false" >> "${GITHUB_OUTPUT}"
          fi

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          body: ${{ steps.cliff.outputs.content }}
          prerelease: ${{ steps.prerelease.outputs.value }}
          fail_on_unmatched_files: true
          files: |
            dist/*.tar.gz
            dist/*.sha256

  publish-crate:
    name: Publish to crates.io
    # crates.io is the one irreversible step: a published version can only be
    # yanked, never removed or reused. Gate it on every other publish target so
    # the permanent action fires last, only once the reversible ones succeed.
    needs: [build, github-release, docker]
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable

      - name: Cache dependencies
        uses: Swatinem/rust-cache@v2

      # The verify build (offline-checked queries, embedded assets, migrations,
      # and licenses) is exercised by the `package` job in ci.yml on every PR,
      # so this is a clean publish. Requires the CARGO_REGISTRY_TOKEN secret.
      - name: Publish
        run: cargo publish --locked
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}

  docker:
    name: Docker image
    needs: build
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          path: artifacts

      # Unpack the prebuilt gnu binaries into the per-arch layout the Dockerfile
      # COPYs from (binaries/<TARGETARCH>/sagittarius).
      - name: Stage binaries per architecture
        run: |
          version="${GITHUB_REF_NAME#v}"
          mkdir -p binaries/amd64 binaries/arm64
          tar -xzf "artifacts/x86_64-unknown-linux-gnu/sagittarius-${version}-x86_64-unknown-linux-gnu.tar.gz" -C /tmp
          cp "/tmp/sagittarius-${version}-x86_64-unknown-linux-gnu/sagittarius" binaries/amd64/sagittarius
          tar -xzf "artifacts/aarch64-unknown-linux-gnu/sagittarius-${version}-aarch64-unknown-linux-gnu.tar.gz" -C /tmp
          cp "/tmp/sagittarius-${version}-aarch64-unknown-linux-gnu/sagittarius" binaries/arm64/sagittarius

      - name: Set up Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Image metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/lhelge/sagittarius
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}