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: warnContracts (Hardhat/Solidity)·Reusable workflow·on: workflow_call
Contracts Test
Contracts - Test & Coverage
.github/workflows/contracts-test.yml