diff --git a/.github/workflows/module_coverage.yml b/.github/workflows/module_coverage.yml new file mode 100644 index 0000000..c29ee94 --- /dev/null +++ b/.github/workflows/module_coverage.yml @@ -0,0 +1,155 @@ +# This is a generic workflow for enforcing Ruby unit-test coverage on a +# Puppet module using SimpleCov. It runs `bundle exec rake spec` with +# coverage enabled, uploads the LCOV report as a build artifact, and gates +# the build in one of two modes: +# +# * absolute - fail if line coverage is below `threshold`. +# * baseline_mode - read `.coverage_baseline` from the repo root and fail +# if line coverage drops below it. When coverage rises, +# the build stays green and suggests a new baseline. +# +# The caller must grant `pull-requests: write` so the coverage summary can be +# posted back as a PR comment. +name: "Module Coverage" + +on: + workflow_call: + inputs: + threshold: + description: "Minimum line coverage percentage required in absolute mode." + required: false + default: 80 + type: "number" + baseline_mode: + description: "When true, gate against `.coverage_baseline` instead of `threshold`." + required: false + default: false + type: "boolean" + ruby_version: + description: "The ruby version used to run the coverage build." + required: false + default: "3.1" + type: "string" + runs_on: + description: "The operating system used for the runner." + required: false + default: "ubuntu-latest" + type: "string" + +jobs: + coverage: + name: "Coverage" + runs-on: ${{ inputs.runs_on }} + permissions: + contents: "read" + pull-requests: "write" + + env: + BUNDLE_WITHOUT: release_prep:system_tests + COVERAGE: "yes" + + steps: + + - name: "Checkout" + uses: "actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10" # v6.0.3 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: "Setup ruby" + uses: "ruby/setup-ruby@afeafc3d1ab54a631816aba4c914a0081c12ff2f" # v1.310.0 + with: + ruby-version: ${{ inputs.ruby_version }} + bundler-cache: true + + - name: "Run tests with coverage" + run: | + bundle exec rake spec + + - name: "Evaluate coverage gate" + id: "gate" + run: | + ruby <<'RUBY' >> "$GITHUB_OUTPUT" + require 'json' + + last_run = File.join('coverage', '.last_run.json') + unless File.exist?(last_run) + warn "No SimpleCov result found at #{last_run}. Is the SimpleCov hook enabled in spec_helper.rb (simplecov: true in .sync.yml)?" + exit 1 + end + + covered = JSON.parse(File.read(last_run)).dig('result', 'line').to_f + baseline_mode = %w[true 1 yes].include?(ENV['BASELINE_MODE'].to_s.downcase) + threshold = ENV['THRESHOLD'].to_f + + puts "covered=#{format('%.2f', covered)}" + + if baseline_mode + baseline_file = '.coverage_baseline' + baseline = File.exist?(baseline_file) ? File.read(baseline_file).strip.to_f : 0.0 + puts "mode=baseline" + puts "target=#{format('%.2f', baseline)}" + delta = covered - baseline + puts "delta=#{format('%+.2f', delta)}" + if covered + 0.005 < baseline + puts "passed=false" + else + puts "passed=true" + puts "raise_baseline=true" if delta >= 0.01 + end + else + puts "mode=absolute" + puts "target=#{format('%.2f', threshold)}" + puts "delta=#{format('%+.2f', covered - threshold)}" + puts "passed=#{covered + 0.005 >= threshold}" + end + RUBY + env: + THRESHOLD: ${{ inputs.threshold }} + BASELINE_MODE: ${{ inputs.baseline_mode }} + + - name: "Upload LCOV report" + if: ${{ always() }} + uses: "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02" # v4.6.2 + with: + name: "coverage-lcov" + path: "coverage/lcov/*.lcov" + if-no-files-found: "warn" + + - name: "Post coverage comment" + if: ${{ always() && (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') }} + uses: "actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b" # v7.0.1 + with: + script: | + const o = ${{ toJSON(steps.gate.outputs) }}; + if (!o.covered) { return; } + const passed = o.passed === 'true'; + const icon = passed ? ':white_check_mark:' : ':x:'; + const label = o.mode === 'baseline' ? 'baseline' : 'threshold'; + const lines = [ + '', + `### ${icon} Coverage: **${o.covered}%**`, + '', + `| ${label} | delta | result |`, + '| --- | --- | --- |', + `| ${o.target}% | ${o.delta}% | ${passed ? 'pass' : 'fail'} |`, + ]; + if (o.raise_baseline === 'true') { + lines.push('', `> :tada: Coverage rose above baseline. Update \`.coverage_baseline\` to \`${o.covered}\` to raise the floor.`); + } + const body = lines.join('\n'); + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 }); + const prev = comments.find((c) => c.body && c.body.includes('')); + if (prev) { + await github.rest.issues.updateComment({ owner, repo, comment_id: prev.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } + + - name: "Enforce coverage gate" + if: ${{ steps.gate.outputs.passed != 'true' }} + run: | + echo "::error::Coverage ${{ steps.gate.outputs.covered }}% is below the required ${{ steps.gate.outputs.target }}% (${{ steps.gate.outputs.mode }} mode)." + exit 1