skyauth 0.3.4

High-assurance, formally verified OAuth 2.1 and RFC 9449 DPoP authentication engine for the AT Protocol (Bluesky)
Documentation
name: Mutation Testing

on:
  # Weekly sweep only. Full cargo-mutants coverage is ~864 mutants (~8 CPU-hours);
  # sharding across 4 parallel runners keeps each job ~1.5-2h, within GitHub's 6h
  # job cap without monopolizing runner capacity.
  schedule:
    - cron: "0 4 * * 1" # Monday 04:00 UTC
  workflow_dispatch:

permissions:
  contents: read

env:
  KILL_RATE_FLOOR: "0.70"

jobs:
  sweep:
    name: Mutation Sweep (${{ matrix.shard.name }})
    runs-on: ubuntu-latest
    timeout-minutes: 200
    permissions:
      contents: read
    strategy:
      fail-fast: false
      matrix:
        shard:
          - name: primitives
            files: --file src/crypto.rs --file src/pkce.rs --file src/session.rs
            features: ""
          - name: dpop
            files: --file src/dpop.rs
            features: ""
          - name: ssrf-identity
            files: --file src/ssrf.rs --file src/identity.rs
            features: ""
          - name: flows
            files: --file src/client.rs --file src/par.rs --file src/discovery.rs --file src/store.rs
            features: ""
          - name: integrations
            # The 2026-09-07 first scheduled sweep found ZERO mutants here:
            # `--file src/integrations` is a directory, and cargo-mutants
            # `--file` matches whole paths/globs, not directories (measured:
            # "No mutants found under the active filters"). Use the recursive
            # glob. The framework modules (axum/actix/tower) are feature-gated,
            # so the shard must also activate all integration features —
            # without them, mutations to gated code are unviable-by-build and
            # mutations to mod.rs/validator.rs lose their killers (the
            # framework test binaries never compile).
            files: --file 'src/integrations/*.rs'
            features: "--features axum,actix,tower"
    steps:
      - name: Checkout repository
        uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
        with:
          persist-credentials: false

      - name: Setup Rust toolchain
        uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable

      - name: Cargo Cache
        uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
        with:
          path: |
            ~/.cargo/bin/
            ~/.cargo/registry/index/
            ~/.cargo/registry/cache/
            ~/.cargo/git/db/
            target/
          key: ${{ runner.os }}-cargo-mutants-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: |
            ${{ runner.os }}-cargo-mutants-

      - name: Install cargo-mutants
        uses: taiki-e/install-action@1ed6d7be6168f6c9046541087ff549b6bc581fdf # v2
        with:
          tool: cargo-mutants

      - name: Run mutation sweep (${{ matrix.shard.name }})
        # cargo-mutants exit codes: 0 = all caught, 2 = missed mutants, 3 = test
        # timeouts. Codes 2 and 3 are expected sweep outcomes that the kill-rate
        # gate below must still evaluate, so only those two are tolerated; every
        # other exit code (1 = usage error, 4 = failing baseline, 70 = internal
        # error) fails the step and skips the gate.
        run: >
          cargo mutants -j2 --timeout 300 --no-shuffle
          ${{ matrix.shard.files }}
          ${{ matrix.shard.features }}
          || code=$?; [ "${code:-0}" -eq 0 ] || [ "${code:-0}" -eq 2 ] || [ "${code:-0}" -eq 3 ] || exit "${code}"

      - name: Enforce kill-rate floor
        # The sweep step exits 0 for tolerated outcomes (all caught, missed
        # mutants, timeouts), so success() runs the gate in exactly those cases;
        # a genuine sweep failure (usage error, failing baseline) fails the
        # workflow before this step. always() would re-run the gate even after
        # cancellation, which is unnecessary noise.
        if: success()
        run: |
          python3 -c "
          import json, sys, os
          name = '${{ matrix.shard.name }}'
          floor = float(os.environ['KILL_RATE_FLOOR'])
          report = json.load(open('mutants.out/outcomes.json'))
          outcomes = report['outcomes']
          # Per-outcome classification lives in the serialized 'summary' field;
          # aggregate counts sit on the top-level report object. Baseline builds
          # are excluded from the mutant population.
          def classify(o):
              summary = o.get('summary', '')
              if isinstance(summary, dict):
                  return summary.get('type', '')
              return summary
          caught = sum(1 for o in outcomes if classify(o) == 'CaughtMutant')
          timeout = sum(1 for o in outcomes if classify(o) == 'Timeout')
          unviable = sum(1 for o in outcomes if classify(o) == 'Unviable')
          mutants = [o for o in outcomes if o.get('scenario') != 'Baseline']
          viable = len(mutants) - unviable
          rate = caught / viable if viable else 0.0
          print(f\"Shard '{name}' kill rate: {caught}/{viable} = {rate:.1%} (timeouts: {timeout})\")
          survivors = [o for o in mutants if classify(o) == 'MissedMutant']
          for s in survivors:
              # Scenario::Mutant(Mutant) serializes as an externally-tagged enum;
              # line/column live under .span.start (the 2026-09-07 first sweep
              # printed ':None' — the Mutant object has no top-level 'line').
              mut = s.get('scenario', {}).get('Mutant', {})
              line = mut.get('span', {}).get('start', {}).get('line', '?')
              print(f\"  survivor: {mut.get('file')}:{line} - {mut.get('replacement')}\")
          if rate < floor:
              print('::error::shard kill rate below floor')
              sys.exit(1)
          "

      - name: Upload shard report
        if: always()
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
        with:
          name: mutation-report-${{ matrix.shard.name }}
          path: mutants.out/
          retention-days: 30