From 9b0f99ecba53f84945541c9cf083595629dae58b Mon Sep 17 00:00:00 2001 From: Elena Gantner Date: Mon, 1 Jun 2026 10:17:17 +0200 Subject: [PATCH] feat: Add cargo-crap workflows (C)hange (R)isk (A)nti-(P)attern analysis for rust. The workflow needs the coverage workflow and runs in a similar fashion, by producing a comment artifact that is either directly posted on the PR or can be posted via the comment workflow. Signed-off-by: Elena Gantner --- .github/workflows/crap-baseline.yml | 133 +++++++++++++++++++ .github/workflows/crap.yml | 177 +++++++++++++++++++++++++ .github/workflows/post-pr-comments.yml | 2 +- 3 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/crap-baseline.yml create mode 100644 .github/workflows/crap.yml diff --git a/.github/workflows/crap-baseline.yml b/.github/workflows/crap-baseline.yml new file mode 100644 index 0000000..09e9736 --- /dev/null +++ b/.github/workflows/crap-baseline.yml @@ -0,0 +1,133 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 + +# Update the CRAP baseline after a successful CI run on the main branch. +# +# This workflow is intended to be called from a workflow_run trigger in the +# consuming repository. It downloads the coverage LCOV artifact from the +# triggering run, generates a new CRAP baseline JSON, and pushes it to a +# dedicated branch. +# +# Example caller (in the consuming repo): +# +# name: Update CRAP baseline +# on: +# workflow_run: +# workflows: ["Rust CI"] +# types: [completed] +# branches: [main] +# jobs: +# update: +# if: github.event.workflow_run.conclusion == 'success' +# uses: eclipse-opensovd/cicd-workflows/.github/workflows/crap-baseline.yml@main +# with: +# run-id: ${{ github.event.workflow_run.id }} +# permissions: +# contents: write +# actions: read + +name: Update CRAP Baseline + +on: + workflow_call: + inputs: + run-id: + description: "Workflow run ID to download the coverage artifact from" + required: true + type: string + rust-version: + description: "Rust toolchain version" + type: string + default: "1.88.0" + cargo-crap-version: + description: "crates.io version of cargo-crap (ignored if cargo-crap-git-url is set)" + type: string + default: "0.2.0" + cargo-crap-git-url: + description: "Install cargo-crap from this git URL instead of crates.io" + type: string + default: "" + cargo-crap-git-rev: + description: "Git revision to install (used with cargo-crap-git-url)" + type: string + default: "" + coverage-artifact: + description: "Name of the coverage LCOV artifact to download" + type: string + default: "coverage-lcov" + lcov-file: + description: "Name of the LCOV file inside the coverage artifact" + type: string + default: "lcov.info" + baseline-branch: + description: "Branch to push the baseline JSON to" + type: string + default: "crap-baseline" + baseline-file: + description: "File path for the baseline JSON within the branch" + type: string + default: "baseline.json" + +permissions: + contents: write # needed to push to the baseline branch + actions: read # needed to download artifacts from the triggering run + +jobs: + update-baseline: + name: Update CRAP Baseline + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: false + + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ inputs.rust-version }} + + - name: Install cargo-crap + shell: bash + run: | + if [ -n "${{ inputs.cargo-crap-git-url }}" ]; then + cargo install --git "${{ inputs.cargo-crap-git-url }}" --rev "${{ inputs.cargo-crap-git-rev }}" --locked + else + cargo install --locked --version "${{ inputs.cargo-crap-version }}" cargo-crap + fi + + - name: Download coverage artifact + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.coverage-artifact }} + run-id: ${{ inputs.run-id }} + github-token: ${{ github.token }} + + - name: Generate baseline + run: | + cargo crap \ + --lcov "${{ inputs.lcov-file }}" \ + --workspace \ + --format json \ + --output /tmp/baseline.json + + - name: Push baseline to branch + shell: bash + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git fetch origin "${{ inputs.baseline-branch }}" + git checkout "${{ inputs.baseline-branch }}" + cp /tmp/baseline.json "${{ inputs.baseline-file }}" + git add "${{ inputs.baseline-file }}" + if ! git diff --cached --quiet; then + git commit -m "chore: update crap baseline" + git push origin "${{ inputs.baseline-branch }}" + fi diff --git a/.github/workflows/crap.yml b/.github/workflows/crap.yml new file mode 100644 index 0000000..960bd29 --- /dev/null +++ b/.github/workflows/crap.yml @@ -0,0 +1,177 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2026 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS) +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 + +# CRAP (Change Risk Anti-Patterns) analysis for pull requests. +# +# Runs cargo-crap against a baseline to detect CRAP score regressions. +# Requires the coverage-lcov artifact from a preceding coverage job +# (produced by coverage.yml) and a baseline JSON on a dedicated branch. +# +# When post-pr-comment is enabled, the comment body is uploaded as a +# "pr-comment-crap" artifact following the convention described in +# post-pr-comments.yml so that fork PRs can also receive comments via +# a workflow_run trigger. + +name: CRAP + +on: + workflow_call: + inputs: + rust-version: + description: "Rust toolchain version" + type: string + default: "1.88.0" + cargo-crap-version: + description: "crates.io version of cargo-crap (ignored if cargo-crap-git-url is set)" + type: string + default: "0.2.0" + cargo-crap-git-url: + description: "Install cargo-crap from this git URL instead of crates.io" + type: string + default: "" + cargo-crap-git-rev: + description: "Git revision to install (used with cargo-crap-git-url)" + type: string + default: "" + coverage-artifact: + description: "Name of the coverage LCOV artifact to download" + type: string + default: "coverage-lcov" + lcov-file: + description: "Name of the LCOV file inside the coverage artifact" + type: string + default: "lcov.info" + baseline-branch: + description: "Git branch containing the CRAP baseline JSON" + type: string + default: "crap-baseline" + baseline-file: + description: "Path to the baseline JSON file within the baseline branch" + type: string + default: "baseline.json" + post-pr-comment: + description: >- + Generate a CRAP PR comment and upload it as the 'pr-comment-crap' artifact. For non-fork PRs the comment is also posted directly (requires pull-requests: write in the caller). Fork PRs can be handled by a workflow_run workflow that calls post-pr-comments.yml. + type: boolean + default: false + +permissions: + contents: read + pull-requests: write + +jobs: + crap: + name: CRAP Analysis + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: false + + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: ${{ inputs.rust-version }} + + - name: Install cargo-crap + shell: bash + run: | + if [ -n "${{ inputs.cargo-crap-git-url }}" ]; then + cargo install --git "${{ inputs.cargo-crap-git-url }}" --rev "${{ inputs.cargo-crap-git-rev }}" --locked + else + cargo install --locked --version "${{ inputs.cargo-crap-version }}" cargo-crap + fi + + - name: Download coverage artifact + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.coverage-artifact }} + + - name: Fetch baseline + shell: bash + run: | + git fetch origin "${{ inputs.baseline-branch }}" + git show "origin/${{ inputs.baseline-branch }}:${{ inputs.baseline-file }}" > baseline.json + + - name: Regression check + run: | + cargo crap \ + --lcov "${{ inputs.lcov-file }}" \ + --workspace \ + --baseline baseline.json \ + --fail-regression + + - name: Generate PR comment + if: ${{ always() && inputs.post-pr-comment && github.event_name == 'pull_request' }} + run: | + cargo crap \ + --lcov "${{ inputs.lcov-file }}" \ + --workspace \ + --baseline baseline.json \ + --format pr-comment \ + --commit-ref "${{ github.event.pull_request.head.sha }}" \ + --output crap-comment.md || true + + - name: Prepare CRAP comment artifact + if: ${{ always() && inputs.post-pr-comment && github.event_name == 'pull_request' }} + shell: bash + run: | + if [ ! -f crap-comment.md ]; then + echo "No CRAP comment generated, skipping artifact." + exit 0 + fi + mkdir -p pr-comment-crap + echo "${{ github.event.number }}" > pr-comment-crap/pr_number + cp crap-comment.md pr-comment-crap/body.md + + - name: Upload CRAP comment artifact + if: ${{ always() && inputs.post-pr-comment && github.event_name == 'pull_request' }} + uses: actions/upload-artifact@v4 + with: + name: pr-comment-crap + path: pr-comment-crap/ + if-no-files-found: ignore + + # Post directly for non-fork PRs (instant feedback). + # Fork PRs lack write permissions; they are handled by a separate + # workflow_run workflow that calls post-pr-comments.yml. + - name: Post CRAP comment on PR + if: >- + always() && inputs.post-pr-comment && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + if (!fs.existsSync('pr-comment-crap/body.md')) return; + const body = fs.readFileSync('pr-comment-crap/body.md', 'utf8'); + const marker = ''; + + 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.includes(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, + }); + } diff --git a/.github/workflows/post-pr-comments.yml b/.github/workflows/post-pr-comments.yml index 3c00116..48cdf86 100644 --- a/.github/workflows/post-pr-comments.yml +++ b/.github/workflows/post-pr-comments.yml @@ -51,7 +51,7 @@ on: comment-artifacts: description: "Space-separated list of artifact names to look for and post as PR comments" type: string - default: "pr-comment-coverage" + default: "pr-comment-coverage pr-comment-crap" permissions: actions: read # needed for gh run download (artifact access)