reconcile 0.3.0

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

on:
  push:
    branches: [ "main" ]
  pull_request:
  merge_group:

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

env:
  CARGO_TERM_COLOR: always
  RUSTFLAGS: -Dwarnings
  RUSTDOCFLAGS: -Dwarnings

jobs:
  # Which expensive jobs a change can possibly affect.
  #
  # `merge_group` is the one event that never filters. The queue validates a *combination* no single
  # pull request tested, so skipping there could let a broken pair through -- and `paths-filter` has
  # no base to diff against on that event anyway, so it is not even asked. Pull requests and pushes
  # both diff cleanly and are both filtered.
  #
  # The filter never lives in `on.*.paths`: a job skipped by `if:` reports **success** to branch
  # protection, whereas a workflow filtered out by `on.*.paths` leaves its check pending forever and
  # blocks the merge. That distinction is why `ci-success` below can be the single required check.
  changes:
    runs-on: ubuntu-latest
    outputs:
      rust: ${{ steps.filter.outputs.rust }}
      deps: ${{ steps.filter.outputs.deps }}
    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'
            - 'rust-toolchain*'
            - '.github/workflows/main.yml'
          deps:
            - '**/Cargo.toml'
            - 'Cargo.lock'
            - 'deny.toml'
            - '.github/workflows/main.yml'

  # `cargo fmt --check` only ever inspects `**/*.rs`, so it can't produce a different verdict on
  # a change the `rust` filter doesn't cover -- same reasoning as the compile jobs below, gated
  # the same way for the first time here (`changes` job's header comment).
  fmt:
    needs: changes
    if: github.event_name == 'merge_group' || needs.changes.outputs.rust == 'true'
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - run: cargo fmt --check

  # This repository's own invariants, none of which compiles anything (~2 s all told) -- hence no
  # path filter: gating a free check can only ever skip it wrongly. Two of them are not
  # documentation-only anyway. `check-domain-purity.sh` reads the manifests and `lww-register/src`;
  # `check-doc-structure.sh` greps rustdoc for `SOTA.md` citations. A `**/*.md` filter would have
  # been wrong on its own terms.
  #
  # `check-mutant-count.sh` needs `cargo-mutants` on `PATH` (unlike the four scripts above, which
  # only need plain `cargo`), but the check itself is still sub-second -- `--list` is syntactic
  # mutant-site discovery, no build (script's own header comment has the measurement).
  repo-gates:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - uses: taiki-e/install-action@v2
      with:
        # Pin matches mutants.yml -- keep both in sync.
        tool: cargo-mutants@27.1.0
    - name: Doc budget (AGENTS.md + CLAUDE.md, and SOTA.md, line caps)
      run: ./scripts/check-doc-budget.sh
    - name: Domain purity (hexagonal boundary + ARCHITECTURE.md §2's graph vs the manifests)
      run: ./scripts/check-domain-purity.sh
    - name: Doc structure (link/anchor/path resolution, SOTA.md §4.2 entry format)
      run: ./scripts/check-doc-structure.sh
    - name: Test-file naming (split #[cfg(test)] modules are named tests.rs, not <name>_tests.rs)
      run: ./scripts/check-test-file-naming.sh
    - name: File size (prod/test line-count budgets, warning + hard-fail tiers)
      run: ./scripts/check-file-size.sh
    - name: Mutant count (.cargo/mutants.toml's header comment vs cargo-mutants --list)
      run: ./scripts/check-mutant-count.sh

  # Both feature axes in one job: they are 20 s and 11 s, and the second reuses the first's
  # dependency build. Splitting them would add a runner start to save nothing.
  clippy:
    needs: changes
    if: github.event_name == 'merge_group' || needs.changes.outputs.rust == 'true'
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - uses: Swatinem/rust-cache@v2
    # `--features internal-testing` exposes the `reconcile::testing` seam so the external
    # integration oracles (`tests/diff.rs`, `tests/proptest_fingerprint_tree_map.rs`) and the
    # benchmarks, which reach now-`pub(crate)` reconciliation internals, still compile.
    - run: cargo clippy --workspace --features internal-testing --all-targets
    - run: cargo clippy --workspace --all-features --all-targets

  test:
    needs: changes
    if: github.event_name == 'merge_group' || needs.changes.outputs.rust == 'true'
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - uses: Swatinem/rust-cache@v2
    - uses: taiki-e/install-action@cargo-nextest
    - run: cargo build --workspace --verbose
    - run: cargo nextest run --workspace --features internal-testing --retries 4 --flaky-result fail
    - run: cargo test --doc --workspace --features internal-testing --verbose

  # AGENTS.md §6 gates the two feature variants separately on purpose: feature interactions hide
  # bugs. A different feature set is a different `target/` fingerprint, so this shares no compiled
  # artifact with `test` and loses nothing by running beside it.
  test-all-features:
    needs: changes
    if: github.event_name == 'merge_group' || needs.changes.outputs.rust == 'true'
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - uses: Swatinem/rust-cache@v2
    - uses: taiki-e/install-action@cargo-nextest
    - run: cargo nextest run --workspace --all-features --retries 4 --flaky-result fail

  # #203: the default feature set and `--all-features` both resolve to `mac-blake3` (it takes
  # precedence when both MAC backends are enabled, per Cargo.toml), so `mac-hmac` — a published,
  # user-selectable crypto backend — was compiled by no CI job at all. This is the one job that
  # selects it explicitly.
  test-mac-hmac:
    needs: changes
    if: github.event_name == 'merge_group' || needs.changes.outputs.rust == 'true'
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - uses: Swatinem/rust-cache@v2
    - uses: taiki-e/install-action@cargo-nextest
    - run: cargo clippy --workspace --no-default-features --features mac-hmac,internal-testing --all-targets
    - run: cargo build --workspace --no-default-features --features mac-hmac --verbose
    - run: cargo nextest run --workspace --no-default-features --features mac-hmac,internal-testing --retries 4 --flaky-result fail

  # Both variants matter: an intra-doc link to a feature-gated item dangles in only one of them.
  docs:
    needs: changes
    if: github.event_name == 'merge_group' || needs.changes.outputs.rust == 'true'
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - uses: Swatinem/rust-cache@v2
    - run: cargo doc --workspace --verbose
    - run: cargo doc --workspace --all-features --verbose

  # Its own job because it is the only **release**-profile build in the pipeline, so it shared
  # nothing with the debug steps it used to sit among and was simply 70 s added to their critical
  # path -- the largest single step in the pre-split job.
  bench:
    needs: changes
    if: github.event_name == 'merge_group' || needs.changes.outputs.rust == 'true'
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - uses: Swatinem/rust-cache@v2
    - run: cargo bench --no-run --features internal-testing

  # Packaging check for the whole workspace -- all five members, the release path end to end (#204,
  # AGENTS.md §11). `--workspace` is what makes that possible: cargo strips `path` at publish time
  # and resolves each intra-workspace dependency from crates.io, so a *per-crate* `cargo package -p
  # rbsr` still fails with "no matching package named `rsos`" until `rsos` is actually published --
  # only `rsos`, `lww-register` and `reconcile-gossip` (the members with no intra-workspace
  # dependency) can be checked one at a time today. `--workspace` packages them together and
  # resolves the `path` + `version` pairs against a local temporary registry instead, which covers
  # `rbsr` and `reconcile` too. Keep it as one invocation for that reason; splitting it back into
  # per-crate steps silently drops two crates from the check.
  #
  # `cargo package` (not `publish --dry-run`): it packages *and* compiles the packaged crate, which
  # is the breakage this check exists to catch, without contacting the registry to upload.
  #
  # The one job with no `target/` cache, deliberately. CONTRIBUTING.md documents a measured incident
  # where a stale sibling rlib in `target/` made exactly this command fail on an unresolved import
  # that did not exist in the tree -- a false negative on the release gate is worth more than the
  # ~14 s a cache would save. Registry only.
  package:
    needs: changes
    if: github.event_name == 'merge_group' || needs.changes.outputs.rust == 'true'
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - name: Enable caching (registry only -- see above)
      uses: actions/cache@v3
      with:
        path: |
          ~/.cargo/registry
          ~/.cargo/git
        key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
    # The same staleness class as the target/ incident above, one layer over: this job's own
    # `cargo package --workspace` extracts each just-packaged workspace crate into
    # ~/.cargo/registry/src/.../<name>-<version>/ to verify it, and cargo treats registry content
    # as immutable per name@version -- so a restored cache from an earlier run (same Cargo.lock
    # hash, since these are path deps with no lockfile-visible content change) keeps serving that
    # earlier run's source for the four unpublished, version-frozen workspace crates
    # (rsos/rbsr/lww-register/reconcile-gossip stay at 0.1.0 across ordinary commits), so a later
    # commit's edits verify against stale registry sources instead of themselves. Purge just those
    # four names before packaging; everything else in the restored cache -- real crates.io deps --
    # is exactly what the cache exists to keep.
    - run: rm -rf ~/.cargo/registry/src/*/{rsos,rbsr,lww-register,reconcile-gossip}-* ~/.cargo/registry/cache/*/{rsos,rbsr,lww-register,reconcile-gossip}-*
    - run: cargo package --workspace --allow-dirty

  # `pr-issue-references` used to live here. It reads the PR *description*, which changes without a
  # push, so it needs the `edited` event type -- and `on:` is per workflow, not per job, so carrying
  # it here would re-run every build job on a typo fix, cancelling any in flight. It is now
  # `.github/workflows/pr-issue-references.yml`; that file's header has the full reasoning.

  # #312: advisories (RustSec), license compliance, and a dependency-source allowlist. Config in
  # `deny.toml`. `checks: advisories` alone would silently skip a license/bans/sources regression;
  # the action's default `all` is deliberate here.
  #
  # Gated on `deps` rather than `rust`: this reads the dependency graph and `deny.toml`, so a pure
  # `.rs` edit cannot change its verdict, while a manifest edit touching no `.rs` file can.
  deny:
    needs: changes
    if: github.event_name == 'merge_group' || needs.changes.outputs.deps == 'true'
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - uses: EmbarkStudios/cargo-deny-action@v2

  # #311 rule 2/3: a committed public-API snapshot per crate (`public-api/*.txt`), diffed against
  # `cargo public-api`'s live render, plus a check that `reconcile`'s public API names no `rbsr`
  # symbol (AGENTS.md §11, #308 — `rbsr` is deliberately held at 0.x). Rule 1
  # (`cargo-semver-checks` against a published baseline) is sequenced separately: it needs a
  # registry baseline, and 4 of the 5 crates have never published one (AGENTS.md §11).
  #
  # CI-only tier (AGENTS.md §3): a full-workspace rustdoc-json build per crate is well over
  # `pre-push`'s ~20 s budget. Needs a `nightly` toolchain (rustdoc's unstable
  # `--output-format json`, which the stable channel does not expose) in addition to the `stable`
  # default every other job uses, so it is the one job that installs a second toolchain.
  #
  # `cargo install` rather than `taiki-e/install-action`: that action has no `cargo-public-api`
  # entry in its tool manifest (unlike `cargo-llvm-cov` below), so `@cargo-public-api` fails to
  # resolve as a ref.
  public-api:
    needs: changes
    if: github.event_name == 'merge_group' || needs.changes.outputs.rust == 'true'
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - uses: dtolnay/rust-toolchain@nightly
    - uses: Swatinem/rust-cache@v2
    - run: cargo install cargo-public-api --locked
    - run: ./scripts/check-public-api.sh

  coverage:
    needs: changes
    if: github.event_name == 'merge_group' || needs.changes.outputs.rust == 'true'
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - name: Install llvm-tools-preview
      run: rustup component add llvm-tools-preview
    - uses: taiki-e/install-action@cargo-llvm-cov
    - uses: Swatinem/rust-cache@v2
    # `--all-features`: `internal-testing`-gated seams (e.g. `just_insert`) are what the
    # integration-test oracles build against; without it this job fails to compile.
    - name: Generate code coverage
      run: cargo llvm-cov --workspace --all-features --lcov --output-path lcov.info
    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v4
      with:
        token: ${{ secrets.CODECOV_TOKEN }}
        files: lcov.info
        fail_ci_if_error: false

  # **The single required status check.** Branch protection pins required checks by job name, so a
  # list of a dozen names has to be re-edited in repository settings every time a job is added,
  # renamed or split -- and nothing fails loudly when someone forgets, because a check that is not
  # in the list simply is not enforced. Requiring one aggregate instead makes that class of mistake
  # impossible; it is the convention `hyper`, `tokio` and `clap` all converged on.
  #
  # `if: always()` is load-bearing. Without it this job inherits the default `needs` behaviour and
  # is *skipped* whenever any dependency is skipped -- which, with the path filters above, is most
  # pull requests. A skipped required check passes, so the gate would silently stop gating.
  #
  # The condition treats `skipped` as acceptable (that is the filter working) and `failure` or
  # `cancelled` as fatal. `success` is not tested for, because a job that neither succeeded, failed,
  # nor was cancelled cannot exist.
  ci-success:
    name: ci-success
    if: always()
    needs:
      - changes
      - fmt
      - repo-gates
      - clippy
      - test
      - test-all-features
      - test-mac-hmac
      - docs
      - bench
      - package
      - deny
      - public-api
      - coverage
    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 CI jobs failed or were cancelled"
        exit 1