Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions .github/workflows/module_coverage.yml
Original file line number Diff line number Diff line change
@@ -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 = [
'<!-- coverage-gate -->',
`### ${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('<!-- coverage-gate -->'));
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
Loading