name: CI
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
jobs:
changes:
name: Detect changes
runs-on: ubuntu-latest
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
code:
- '**/*.rs'
- 'Cargo.toml'
- 'Cargo.lock'
- '.cargo/**'
- '.github/workflows/**'
- 'tests/fixtures/**'
- 'justfile'
- 'rust-toolchain.toml'
- 'deny.toml'
test:
name: Test (${{ matrix.os }})
needs: [changes]
if: needs.changes.outputs.code == 'true'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@cargo-nextest
- name: Test
run: cargo nextest run --all-targets
- name: Doc tests
run: cargo test --doc
lints:
name: Lints
needs: [changes]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: rustfmt
run: cargo fmt --all -- --check
- name: clippy
run: cargo clippy --all-targets -- -D warnings
msrv:
name: MSRV (Rust 1.88)
needs: [changes]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@1.88
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@cargo-nextest
- name: Test (MSRV)
run: cargo nextest run --all-targets
- name: Doc tests (MSRV)
run: cargo test --doc
audit:
name: Security audit
needs: [changes]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install cargo-audit
uses: taiki-e/install-action@cargo-audit
- name: Run security audit
run: cargo audit
mutants:
name: Mutation tests
needs: [changes]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@v2
with:
tool: cargo-mutants,cargo-nextest
- name: Run mutation tests (changed files only, PRs)
if: github.event_name == 'pull_request'
run: |
files=$(git diff --name-only origin/${{ github.base_ref }}...HEAD -- 'src/*.rs' 2>/dev/null || true)
if [ -z "$files" ]; then
echo "No changed src/*.rs files — skipping mutants."
exit 0
fi
echo "Running mutants on: $files"
cargo mutants $(echo "$files" | sed 's/^/--file /' | tr '\n' ' ')
- name: Run mutation tests (full, main)
if: github.event_name == 'push'
run: cargo mutants
- name: Upload mutants output
if: always()
uses: actions/upload-artifact@v4
with:
name: mutants-out
path: mutants.out/
self_score:
name: Self-score
needs: [changes]
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
permissions:
actions: read
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- uses: taiki-e/install-action@v2
with:
tool: cargo-llvm-cov
- uses: Swatinem/rust-cache@v2
- name: Generate coverage
run: cargo llvm-cov --lcov --output-path lcov.info --workspace
- name: Generate badge JSON (main only)
if: github.ref == 'refs/heads/main'
run: |
cargo run --release -- \
--lcov lcov.info \
--workspace \
--exclude 'tests/fixtures/**' \
--threshold 15 \
--format shields \
--output crap-badge.json
- name: Upload badge artifact (main only)
if: github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v4
with:
name: crap-badge
path: crap-badge.json
- name: Score (threshold 15, exclude fixtures)
run: |
cargo run --release -- \
--lcov lcov.info \
--workspace \
--exclude 'tests/fixtures/**' \
--threshold 15 \
--fail-above \
--format json \
--output crap-current.json
- name: Upload baseline (main only)
if: github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v4
with:
name: crap-baseline
path: crap-current.json
retention-days: 90
- name: Resolve baseline run-id (PRs only)
if: github.event_name == 'pull_request'
id: baseline_run
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RUN_ID=""
for id in $(gh run list \
--repo "${{ github.repository }}" \
--workflow ci.yml \
--branch main \
--status success \
--limit 20 \
--json databaseId \
--jq '.[].databaseId'); do
count=$(gh api "repos/${{ github.repository }}/actions/runs/$id/artifacts" \
--jq '[.artifacts[] | select(.name == "crap-baseline" and .expired == false)] | length')
if [ "$count" -gt 0 ]; then
RUN_ID="$id"
break
fi
done
if [ -z "$RUN_ID" ]; then
echo "No main run with a usable crap-baseline artifact — baseline will be skipped."
else
echo "Found main run with baseline: $RUN_ID"
fi
echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT"
- name: Download baseline (PRs only)
if: github.event_name == 'pull_request' && steps.baseline_run.outputs.run_id != ''
uses: actions/download-artifact@v4
with:
name: crap-baseline
path: baseline
run-id: ${{ steps.baseline_run.outputs.run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
continue-on-error: true
- name: Regression check (PRs only)
if: github.event_name == 'pull_request'
run: |
if [ -f baseline/crap-current.json ] && grep -q '"version"' baseline/crap-current.json; then
cargo run --release -- \
--lcov lcov.info \
--workspace \
--exclude 'tests/fixtures/**' \
--baseline baseline/crap-current.json \
--fail-regression
else
echo "No usable baseline available — skipping regression check."
fi
- name: Generate PR comment
if: always() && github.event_name == 'pull_request'
env:
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
COMMIT_REF: ${{ github.event.pull_request.head.sha }}
run: |
if [ -f baseline/crap-current.json ] && grep -q '"version"' baseline/crap-current.json; then
cargo run --release -- \
--lcov lcov.info \
--workspace \
--exclude 'tests/fixtures/**' \
--threshold 15 \
--baseline baseline/crap-current.json \
--format pr-comment \
--repo-url "$REPO_URL" \
--commit-ref "$COMMIT_REF" \
--output crap-comment.md || true
else
cargo run --release -- \
--lcov lcov.info \
--workspace \
--exclude 'tests/fixtures/**' \
--threshold 15 \
--format pr-comment \
--repo-url "$REPO_URL" \
--commit-ref "$COMMIT_REF" \
--output crap-comment.md || true
printf '\n_No baseline available — showing absolute scores only._\n' >> crap-comment.md
fi
- name: Upload PR comment artifact
if: always() && github.event_name == 'pull_request'
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: echo "$PR_NUMBER" > pr-number.txt
- name: Upload PR comment artifact files
if: always() && github.event_name == 'pull_request'
uses: actions/upload-artifact@v4
with:
name: crap-pr-comment
path: |
crap-comment.md
pr-number.txt
if-no-files-found: ignore
badge:
name: Publish CRAP badge
needs: [self_score]
if: >-
always() && github.event_name == 'push' && github.ref == 'refs/heads/main'
&& contains(fromJSON('["success", "failure"]'), needs.self_score.result)
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- name: Download badge artifact
id: download
uses: actions/download-artifact@v4
with:
name: crap-badge
path: .
continue-on-error: true
- name: Push badge to the badges branch
run: |
if [ ! -f crap-badge.json ]; then
echo "No badge artifact — self_score failed before generating it; skipping."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
blob=$(git hash-object -w crap-badge.json)
tree=$(printf '100644 blob %s\tcrap-badge.json\n' "$blob" | git mktree)
commit=$(git commit-tree "$tree" -m "chore: update CRAP badge")
git push --force origin "$commit:refs/heads/badges"