reconcile 0.3.0

A reconciliation storage service to sync a key-value map over multiple instances
name: mutants

on:
  pull_request:
  merge_group:
  schedule:
    - cron: "0 3 * * *"
  workflow_dispatch:

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

env:
  CARGO_TERM_COLOR: always
  # Hermeticity: proptest picks a random seed per run unless pinned, which makes
  # mutation outcomes non-deterministic. See scripts/check-mutation-gate.sh.
  PROPTEST_RNG_SEED: "20260817"

jobs:
  # Same rationale as main.yml's `changes` job: a doc-only or workflow-only PR
  # cannot change cargo-mutants' verdict, and a full baseline build is the
  # expensive part of this workflow, so skip it rather than pay for nothing.
  # `schedule`/`workflow_dispatch` have no PR to diff against, so `nightly` below
  # is never gated on this output. `merge_group` gives `paths-filter` no base to
  # diff against either (same as main.yml's `changes` job) -- the filter step is
  # skipped there and `pr-diff` below runs unconditionally instead.
  changes:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
    outputs:
      rust: ${{ steps.filter.outputs.rust }}
    steps:
      - uses: actions/checkout@v3
      - uses: dorny/paths-filter@v3
        id: filter
        if: github.event_name != 'merge_group'
        with:
          filters: |
            rust:
              - '**/*.rs'
              - '**/Cargo.toml'
              - 'Cargo.lock'
              - '.cargo/mutants.toml'
              - '.github/workflows/mutants.yml'

  # Gates every PR on the mutants introduced by that PR only. Typical PR: 5-20
  # mutants, 2-8 min. Measured on this repo, a 492-line diff produced 73 mutants.
  #
  # Runs on `merge_group` too (main.yml `changes` job's rationale: the queue validates a
  # *combination* no single pull request tested, so it never filters). `github.base_ref` is only
  # populated for `pull_request`/`pull_request_target` events, so the base ref is resolved per
  # event instead: `merge_group.base_sha` pins to the queue entry's actual base commit rather than
  # whatever `origin/main`'s tip has moved to by the time this job runs.
  pr-diff:
    needs: changes
    if: github.event_name == 'merge_group' || needs.changes.outputs.rust == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 45
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0
      - uses: Swatinem/rust-cache@v2
      - uses: taiki-e/install-action@v2
        with:
          # Pin: mutants.out/ file formats are explicitly documented as unstable.
          tool: cargo-mutants@27.1.0
      - name: Resolve mutation-gate base ref
        id: base
        run: |
          if [ "${{ github.event_name }}" = "merge_group" ]; then
            echo "ref=${{ github.event.merge_group.base_sha }}" >> "$GITHUB_OUTPUT"
          else
            echo "ref=origin/${{ github.base_ref }}" >> "$GITHUB_OUTPUT"
          fi
      # scripts/check-mutation-gate.sh is the single source of truth for this command (AGENTS.md
      # §10: a rule enforced by eye, or reimplemented ad hoc in a workflow, belongs in a script
      # both this job and a contributor's local run call) -- it also carries the `--workspace`
      # rationale (in-diff otherwise scopes to the root `reconcile` package only) and the
      # PROPTEST_RNG_SEED hermeticity note.
      - run: ./scripts/check-mutation-gate.sh "${{ steps.base.outputs.ref }}"
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: mutants-incremental
          path: mutants.out

  # Mirrors main.yml's `ci-success`, for the same reason: branch protection pins a required
  # check by job name, so this workflow needs its own stable aggregate rather than pointing
  # protection straight at `pr-diff`. Pointing at `pr-diff` directly is doubly wrong here --
  # `changes` skips it on a doc-only or workflow-only PR (header comment above), and a required
  # check that a skip leaves pending forever blocks the merge instead of passing it. `if:
  # always()` and the skip/failure treatment are exactly main.yml's `ci-success` reasoning;
  # see that job's comment for the detail.
  #
  # Also gated on `merge_group`, not just `pull_request`: without it, this job -- and therefore
  # the required check -- never runs on a merge-queue entry, and branch protection blocks the
  # queue on a check that can't report either way.
  mutants-success:
    name: mutants-success
    if: always() && (github.event_name == 'pull_request' || github.event_name == 'merge_group')
    needs:
      - changes
      - pr-diff
    runs-on: ubuntu-latest
    steps:
    - name: Fail if any job failed or was cancelled
      if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
      run: |
        echo "::error::one or more mutation-gate jobs failed or were cancelled"
        exit 1

  # Full workspace sweep (~1400 mutants), sharded 8 ways so each job lands at
  # ~20-35 min. Reported as a trend, NOT gated: a whole-repo mutation score is a
  # number to watch, not a wall — hence `continue-on-error` and no `mutants-success`
  # dependency (contrast `pr-diff` above, which `mutants-success` does require).
  #
  # `!= 'merge_group'` is load-bearing now that `merge_group` is a trigger (added for `pr-diff`
  # above): without it, this condition -- originally just "not a PR" -- would also match every
  # merge-queue entry and run the full ~60 min 8-shard sweep on each one, which `pr-diff` (already
  # gating the queue on the incremental diff) makes redundant.
  nightly:
    if: github.event_name != 'pull_request' && github.event_name != 'merge_group'
    runs-on: ubuntu-latest
    timeout-minutes: 60
    continue-on-error: true
    strategy:
      fail-fast: false
      matrix:
        shard: [0, 1, 2, 3, 4, 5, 6, 7]
    steps:
      - uses: actions/checkout@v3
      - uses: Swatinem/rust-cache@v2
      - uses: taiki-e/install-action@v2
        with:
          tool: cargo-mutants@27.1.0
      # round-robin spreads cheap/expensive mutants evenly across shards;
      # the default `slice` groups them, so one shard gets all the slow ones.
      #
      # No `--iterate`: it skips mutants recorded as already-caught in
      # mutants.out/previously_caught.txt, but this job checks out fresh and never restores
      # that file from a prior run's artifact, so the file is always empty and the flag is
      # presently a no-op. Don't add it back without also restoring the artifact -- and if
      # that restoration is ever added, keep a periodic run with no `--iterate` alongside it
      # (upstream cargo-mutants recommends this), since an always-iterating job never re-checks
      # a mutant it once caught.
      - run: >
          cargo mutants --workspace
          --shard ${{ matrix.shard }}/8 --sharding round-robin
          --baseline=skip --timeout 300
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: mutants-nightly-${{ matrix.shard }}
          path: mutants.out