name: CI
on:
push:
branches: [ main ]
pull_request:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
jobs:
test:
name: Test (${{ matrix.os }})
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
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)
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
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
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
runs-on: ubuntu-latest
permissions:
pull-requests: write
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: 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=$(gh run list \
--repo "${{ github.repository }}" \
--workflow ci.yml \
--branch main \
--status success \
--limit 1 \
--json databaseId \
--jq '.[0].databaseId // empty')
if [ -z "$RUN_ID" ]; then
echo "No successful main run yet — baseline will be skipped."
else
echo "Found main run: $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/**' \
--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/**' \
--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: Post or update PR comment
if: always() && github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
if (!fs.existsSync('crap-comment.md')) return;
const body = fs.readFileSync('crap-comment.md', 'utf8');
const marker = '<!-- cargo-crap-report -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.startsWith(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}