yolop 0.12.1

Yolop — a terminal coding agent built on everruns-runtime
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
    types: [opened, reopened, synchronize]
  workflow_dispatch:

concurrency:
  group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
  cancel-in-progress: true

permissions:
  contents: read

env:
  CARGO_TERM_COLOR: always
  RUST_BACKTRACE: 1
  CARGO_INCREMENTAL: 0

jobs:
  # Classify the diff once, up front, so the rest of the pipeline can scope
  # itself to what actually changed. A docs-only PR (e.g. a README badge tweak)
  # must not spin up the whole Rust toolchain, burn a live-provider API budget,
  # or occupy the scarce macOS/Windows runners. GitHub treats a job skipped by
  # `if:` as a passing required check, so skipped legs never block merge.
  changes:
    name: Detect changes
    runs-on: ubuntu-latest
    timeout-minutes: 5
    outputs:
      rust: ${{ steps.filter.outputs.rust }}
      tuika: ${{ steps.filter.outputs.tuika }}
      docs: ${{ steps.filter.outputs.docs }}
      eval-analyzers: ${{ steps.filter.outputs.eval-analyzers }}
    steps:
      - uses: actions/checkout@v7

      - uses: dorny/paths-filter@v4
        id: filter
        with:
          filters: |
            rust:
              - '**/*.rs'
              - '**/Cargo.toml'
              - 'Cargo.lock'
              - 'rust-toolchain.toml'
              - '**/build.rs'
              - '.github/workflows/ci.yml'
              - 'crates/tuika/benches/check_iai.py'
              - '**/iai-baseline.json'
            # The dedicated MSRV check only compiles `-p tuika`, which depends on
            # neither yolop, yolop-yep, nor tuika-codeformatters. Scope it to
            # tuika's own sources plus the workspace-wide inputs that reach it
            # (root manifest, lockfile, this workflow). A yolop-only change still
            # compiles tuika via the workspace `test` job — it just skips this
            # extra 1.88 + gallery-asset leg.
            tuika:
              - 'crates/tuika/**'
              - 'Cargo.toml'
              - 'Cargo.lock'
              - '.github/workflows/ci.yml'
            docs:
              - '**/*.md'
            # The regression-gate analyzers under harness_basic are pure Python
            # and decide whether the nightly Search Efficiency Eval passes. They
            # never touch the Rust toolchain, so give them their own fast leg that
            # runs whenever an analyzer or its tests change.
            eval-analyzers:
              - 'evals/harness_basic/*.py'
              - 'evals/harness_basic/tests/**'
              - '.github/workflows/ci.yml'

  tuika-msrv:
    name: Tuika MSRV
    needs: changes
    if: needs.changes.outputs.tuika == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v7

      - name: Install Rust 1.88
        uses: dtolnay/rust-toolchain@1.88.0

      - name: Cache Rust
        uses: Swatinem/rust-cache@v2
        with:
          shared-key: "tuika-msrv"

      - name: Check Tuika
        run: cargo check --locked -p tuika --all-targets

      - name: Verify component gallery assets
        run: cargo run --locked -p tuika --example demo -- check

  # Cheap guard for the documentation boundary (README/docs must not link into
  # internal knowledge/ or .agents/). Runs whenever Markdown changes — including
  # docs-only PRs, where the Rust `lint` job below is skipped — and needs no
  # Rust toolchain, so it stays fast.
  docs-boundary:
    name: Docs boundary
    needs: changes
    if: needs.changes.outputs.docs == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v7

      - name: Validate release and knowledge metadata
        run: |
          python3 scripts/validate_okf.py knowledge --check-links
          python3 scripts/test_validate_okf.py
          python3 scripts/test_publish_order.py
          test "$(python3 scripts/publish_order.py | tail -1)" = yolop

      - name: Public docs boundary
        run: |
          if grep -R -n -E --include='*.md' '\]\([^)]*(knowledge/|\.agents/)' README.md docs; then
            echo "::error::Public documentation must not link to internal knowledge/ or .agents/ files."
            exit 1
          fi

  eval-analyzers:
    name: Eval analyzers (Python)
    needs: changes
    if: needs.changes.outputs.eval-analyzers == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v7

      - name: Run analyzer unit tests
        # Standard library only — no third-party deps, so the system python is
        # enough and there's nothing to install.
        run: python3 -m unittest discover -s evals/harness_basic/tests -v

  lint:
    name: Format + Clippy
    needs: changes
    if: needs.changes.outputs.rust == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v7

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

      - name: Cache Rust
        uses: Swatinem/rust-cache@v2
        with:
          shared-key: "debug"

      - name: Format check
        run: cargo fmt --all -- --check

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

  test:
    name: Build + Tests + Offline Smoke
    needs: changes
    if: needs.changes.outputs.rust == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v7

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

      - name: Cache Rust
        uses: Swatinem/rust-cache@v2
        with:
          shared-key: "debug"

      - name: Install cargo-llvm-cov
        uses: taiki-e/install-action@v2
        with:
          tool: cargo-llvm-cov

      - name: Build
        run: cargo build --locked --all-targets

      - name: Unit and integration tests with coverage
        # The live provider tests skip themselves here (no API keys and
        # YOLOP_REQUIRE_LIVE_TESTS unset); they run in the `live-smoke` job below.
        run: |
          cargo llvm-cov --no-report --all-features --workspace
          cargo llvm-cov report --lcov --output-path lcov.info
          cargo llvm-cov report --summary-only

      - name: Upload coverage to Codecov
        if: always()
        uses: codecov/codecov-action@v7
        with:
          files: lcov.info
          fail_ci_if_error: ${{ github.event_name == 'push' }}
          token: ${{ secrets.CODECOV_TOKEN }}

      - name: Upload coverage artifact
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: lcov-coverage
          path: lcov.info
          if-no-files-found: error

  live-smoke:
    name: Live Provider Smoke (Doppler)
    runs-on: ubuntu-latest
    timeout-minutes: 15
    # Gate this job out entirely on fork PRs and dependabot PRs where
    # DOPPLER_TOKEN is not exposed. When the job does run, it must actually
    # run the tests — YOLOP_REQUIRE_LIVE_TESTS=1 (below) turns a missing
    # provider key into a hard failure so the live check can't report green
    # without exercising anything.
    if: (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && github.actor != 'dependabot[bot]'
    needs: [lint, test]
    env:
      DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }}
    steps:
      - uses: actions/checkout@v7

      - name: Verify DOPPLER_TOKEN is set
        run: |
          if [ -z "${DOPPLER_TOKEN}" ]; then
            echo "::error::DOPPLER_TOKEN is required for the live smoke job but is not set. Configure the repo secret or update the job's outer 'if:' to skip this environment."
            exit 1
          fi

      - name: Install Doppler CLI
        uses: dopplerhq/cli-action@v4

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

      - name: Cache Rust
        uses: Swatinem/rust-cache@v2
        with:
          shared-key: "debug"

      - name: Build the test binary (offline)
        run: cargo test --no-run --test integration

      - name: Live provider smoke tests
        env:
          YOLOP_REQUIRE_LIVE_TESTS: "1"
        run: doppler run -- cargo test --test integration -- --nocapture

  sandbox-contract:
    name: Sandbox Contract (${{ matrix.os }})
    needs: changes
    if: needs.changes.outputs.rust == 'true'
    runs-on: ${{ matrix.os }}
    timeout-minutes: 20
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest]
    steps:
      - uses: actions/checkout@v7

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

      - name: Cache Rust
        uses: Swatinem/rust-cache@v2
        with:
          shared-key: "sandbox-${{ runner.os }}"

      - name: Run sandbox contract suite
        run: cargo test sandbox -- --nocapture

  benchmarks:
    name: Benchmarks (archive)
    needs: changes
    # Trend archive, not a gate: wall-clock numbers on shared runners are too
    # noisy to fail a build on, so this only runs on main pushes that touch Rust
    # (and on demand) and uploads the Criterion output as an artifact. The
    # benches still *compile* on every PR via the `test` job's --all-targets
    # build, so they can't rot. Compare runs locally with --save-baseline.
    if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && needs.changes.outputs.rust == 'true')
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v7

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

      - name: Cache Rust
        uses: Swatinem/rust-cache@v2
        with:
          shared-key: "debug"

      - name: Run benchmarks
        run: |
          cargo bench --locked -p tuika -p tuika-codeformatters -- \
            --warm-up-time 2 --measurement-time 5 --noplot | tee bench-output.txt

      - name: Upload benchmark results
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: criterion-benchmarks
          path: |
            target/criterion/**
            bench-output.txt
          if-no-files-found: warn

  iai:
    name: iai-callgrind (instruction counts)
    needs: changes
    # Instruction counts are deterministic and machine-independent, so unlike the
    # wall-clock `benchmarks` job this one IS a gate: it fails the build when a
    # change regresses instruction counts past the tolerance in the committed
    # iai-baseline.json. Runs on Rust-affecting PRs and pushes; ubuntu-latest
    # matches the x86_64/glibc the baseline was captured on.
    if: github.event_name == 'workflow_dispatch' || needs.changes.outputs.rust == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 25
    steps:
      - uses: actions/checkout@v7

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

      - name: Cache Rust
        uses: Swatinem/rust-cache@v2
        with:
          shared-key: "debug"

      - name: Install Valgrind
        run: sudo apt-get update && sudo apt-get install -y valgrind

      - name: Install iai-callgrind-runner
        # Must match the iai-callgrind library version in Cargo.toml.
        run: cargo install iai-callgrind-runner --version 0.16.1 --locked

      - name: Run instruction-count benchmarks
        run: |
          rm -rf target/iai
          cargo bench --locked -p tuika --bench markdown_iai --bench scroll_iai \
            -p tuika-codeformatters --bench highlight_iai -- --save-summary=json

      - name: Check against committed baseline
        # On workflow_dispatch, regenerate and upload the baseline instead of
        # gating — the clean way to refresh it from CI's exact environment.
        run: |
          if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
            python3 crates/tuika/benches/check_iai.py --update
          else
            python3 crates/tuika/benches/check_iai.py
          fi

      - name: Upload regenerated baseline
        if: github.event_name == 'workflow_dispatch'
        uses: actions/upload-artifact@v7
        with:
          name: iai-baseline
          path: '**/iai-baseline.json'
          if-no-files-found: error

  # The (comparatively scarce) Windows runner only spins up when the change
  # actually touches Rust — not on doc- or eval-only changes.
  windows:
    name: Windows Build + Shell Smoke
    needs: changes
    if: needs.changes.outputs.rust == 'true'
    runs-on: windows-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v7

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

      - name: Cache Rust
        uses: Swatinem/rust-cache@v2
        with:
          shared-key: "windows"

      - name: Build the yolop binary
        run: cargo build --locked -p yolop

      - name: Sandbox + shell tests
        run: cargo test -p yolop sandbox -- --nocapture

      - name: Offline smoke (PowerShell shell)
        run: cargo run -p yolop -- --provider llmsim -p "hi"