Saltar al contenido
mypipelines
Contracts (Hardhat/Solidity)·Reusable workflow·on: workflow_call

Contracts Test

Contracts - Test & Coverage

.github/workflows/contracts-test.yml

.github/workflows/contracts-test.yml
name: Contracts - Test & Coverage
on:
workflow_call:
inputs:
runner:
description: 'Runner type'
required: false
type: string
default: 'ubuntu-latest'
container_image:
description: >-
Run the job inside this image (e.g.
ghcr.io/codehunters/ci-base-images:v1.0.0-node). Empty = plain runner
with actions/setup-node. A container already carrying the toolchain
skips the per-run Node download and pins the version to the tag.
required: false
type: string
default: ''
node_version:
description: 'Node.js version'
required: false
type: string
default: '20'
package_manager:
description: 'Package manager (npm, yarn, or pnpm)'
required: false
type: string
default: 'pnpm'
pnpm_version:
description: 'pnpm version (used when package_manager: pnpm)'
required: false
type: string
default: '10'
artifact_name:
description: 'Name of build artifact to download (compiled contracts). Empty = recompile.'
required: false
type: string
default: ''
test_command:
description: >-
Command that runs the contract tests. Empty = `hardhat test` through
the selected package manager. Point this at a repository script when
the suite needs more than hardhat's defaults.
required: false
type: string
default: ''
run_coverage:
description: 'Run solidity-coverage'
required: false
type: boolean
default: false
coverage_command:
description: >-
Command that produces (and may gate) the coverage report. Empty =
`hardhat coverage`. A repository whose real gate is a script — a
branch threshold on one directory, say — passes it here: unlike the
built-in line check, a non-zero exit from this command fails the job.
required: false
type: string
default: ''
coverage_threshold:
description: 'Minimum line coverage percentage (0-100). 0 = disabled.'
required: false
type: number
default: 0
run_gas_reporter:
description: 'Enable hardhat-gas-reporter (REPORT_GAS=true)'
required: false
type: boolean
default: false
upload_reports:
description: 'Upload coverage and gas reports as artifacts'
required: false
type: boolean
default: false
outputs:
result:
description: 'Test result (success/failure)'
value: ${{ jobs.test.outputs.result }}
coverage_percentage:
description: 'Line coverage percentage'
value: ${{ jobs.test.outputs.coverage }}
tests_passed:
description: 'Number of tests passed'
value: ${{ jobs.test.outputs.passed }}
tests_failed:
description: 'Number of tests failed'
value: ${{ jobs.test.outputs.failed }}
# Least privilege for what this workflow actually does. Without it the
# job inherits whatever the calling repository's default happens to be,
# which on older repositories is write-all.
permissions:
contents: read
packages: read
jobs:
test:
name: Test & Coverage
runs-on: ${{ inputs.runner }}
# `container:` takes an object or nothing at all, and there is no way to
# write "no container" as a string. fromJSON('null') is how you say it.
container: ${{ inputs.container_image != '' && fromJSON(format('{{"image":"{0}"}}', inputs.container_image)) || fromJSON('null') }}
timeout-minutes: 30
outputs:
result: ${{ steps.test-run.outcome }}
coverage: ${{ steps.coverage.outputs.percentage }}
passed: ${{ steps.test-summary.outputs.passed }}
failed: ${{ steps.test-summary.outputs.failed }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup pnpm
if: inputs.package_manager == 'pnpm' && inputs.container_image == ''
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- name: Setup Node.js ${{ inputs.node_version }}
if: inputs.container_image == ''
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ inputs.node_version }}
cache: ${{ inputs.package_manager }}
- name: Report toolchain (container)
if: inputs.container_image != ''
run: |
echo "Container: ${{ inputs.container_image }}"
node --version
npm --version
- name: Install dependencies
run: |
case "${{ inputs.package_manager }}" in
pnpm) pnpm install --frozen-lockfile ;;
yarn) yarn install --frozen-lockfile ;;
*) npm ci ;;
esac
- name: Download compiled artifacts
if: inputs.artifact_name != ''
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ inputs.artifact_name }}
path: .
# ============================================
# RUN TESTS
# ============================================
- name: Run contract tests
id: test-run
env:
REPORT_GAS: ${{ inputs.run_gas_reporter }}
TEST_COMMAND: ${{ inputs.test_command }}
run: |
if [ -n "${TEST_COMMAND}" ]; then
eval "${TEST_COMMAND}" 2>&1 | tee test-output.log || true
exit 0
fi
case "${{ inputs.package_manager }}" in
pnpm) pnpm hardhat test 2>&1 | tee test-output.log || true ;;
yarn) yarn hardhat test 2>&1 | tee test-output.log || true ;;
*) npx hardhat test 2>&1 | tee test-output.log || true ;;
esac
- name: Parse test results
id: test-summary
if: always()
run: |
PASSED=0
FAILED=0
if [ -f "test-output.log" ]; then
# Mocha output: "X passing" and "Y failing"
PASSED=$(grep -oE '[0-9]+ passing' test-output.log | grep -oE '[0-9]+' | tail -1 || echo "0")
FAILED=$(grep -oE '[0-9]+ failing' test-output.log | grep -oE '[0-9]+' | tail -1 || echo "0")
if [ -z "$PASSED" ]; then PASSED=0; fi
if [ -z "$FAILED" ]; then FAILED=0; fi
fi
echo "passed=$PASSED" >> $GITHUB_OUTPUT
echo "failed=$FAILED" >> $GITHUB_OUTPUT
TOTAL=$((PASSED + FAILED))
echo "### Test Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Status | Count |" >> $GITHUB_STEP_SUMMARY
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Passed | $PASSED |" >> $GITHUB_STEP_SUMMARY
echo "| Failed | $FAILED |" >> $GITHUB_STEP_SUMMARY
echo "| **Total** | **$TOTAL** |" >> $GITHUB_STEP_SUMMARY
# ============================================
# COVERAGE (solidity-coverage)
# ============================================
- name: Run solidity-coverage
if: inputs.run_coverage
env:
COVERAGE_COMMAND: ${{ inputs.coverage_command }}
run: |
# A custom coverage command may carry the repository's real gate, so
# its exit code decides the job. The built-in path keeps `|| true`
# and lets the threshold step below do the failing.
if [ -n "${COVERAGE_COMMAND}" ]; then
set -o pipefail
eval "${COVERAGE_COMMAND}" 2>&1 | tee coverage-output.log
exit $?
fi
case "${{ inputs.package_manager }}" in
pnpm) pnpm hardhat coverage 2>&1 | tee coverage-output.log || true ;;
yarn) yarn hardhat coverage 2>&1 | tee coverage-output.log || true ;;
*) npx hardhat coverage 2>&1 | tee coverage-output.log || true ;;
esac
- name: Parse coverage report
id: coverage
if: inputs.run_coverage && always()
run: |
COVERAGE=0
if [ -f "coverage/coverage-summary.json" ]; then
COVERAGE=$(node -e "
const report = require('./coverage/coverage-summary.json');
const pct = report.total.lines.pct;
console.log(typeof pct === 'number' ? pct.toFixed(1) : '0');
" 2>/dev/null || echo "0")
fi
echo "percentage=$COVERAGE" >> $GITHUB_OUTPUT
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Code Coverage (Solidity)" >> $GITHUB_STEP_SUMMARY
echo "**Line Coverage: ${COVERAGE}%**" >> $GITHUB_STEP_SUMMARY
- name: Check coverage threshold
if: inputs.run_coverage && inputs.coverage_threshold > 0 && always()
run: |
COVERAGE="${{ steps.coverage.outputs.percentage }}"
THRESHOLD="${{ inputs.coverage_threshold }}"
if [ "$(echo "$COVERAGE < $THRESHOLD" | bc -l)" -eq 1 ]; then
echo "::error::Coverage ${COVERAGE}% is below threshold of ${THRESHOLD}%"
exit 1
fi
echo "Coverage ${COVERAGE}% meets threshold of ${THRESHOLD}%"
# ============================================
# FAIL JOB ON TEST FAILURES
# ============================================
- name: Check test results
if: always()
run: |
FAILED="${{ steps.test-summary.outputs.failed }}"
if [ -n "$FAILED" ] && [ "$FAILED" -gt 0 ]; then
echo "::error::$FAILED test(s) failed"
exit 1
fi
# ============================================
# UPLOAD REPORTS
# ============================================
- name: Upload coverage reports
if: inputs.upload_reports && inputs.run_coverage && always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: contracts-coverage-${{ github.run_id }}
path: coverage/
retention-days: 7
if-no-files-found: warn
- name: Upload gas report
if: inputs.upload_reports && inputs.run_gas_reporter && always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: contracts-gas-report-${{ github.run_id }}
path: |
gas-report.txt
test-output.log
retention-days: 7
if-no-files-found: warn