ci: enforce coverage (#2) #3
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PR - Test & Validate (Local) | |
| on: | |
| push: | |
| branches: [main, staging, dev, develop] | |
| pull_request_target: | |
| branches: [main, staging, dev, develop] | |
| types: [opened, reopened, synchronize] | |
| workflow_dispatch: | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| NODE_VERSION: '20.x' | |
| jobs: | |
| dependency-review: | |
| name: π Dependency Review | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'pull_request_target' || github.event_name == 'pull_request' | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: ${{ github.event.pull_request.head.sha }} | |
| - name: Dependency Review | |
| uses: actions/dependency-review-action@v4 | |
| with: | |
| fail-on-severity: high | |
| vulnerability-check: true | |
| license-check: false | |
| setup: | |
| name: π¦ Setup Dependencies | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| permissions: | |
| contents: read | |
| outputs: | |
| cache-key: ${{ steps.setup-node.outputs.cache-key }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: true | |
| ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v6 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| - name: Check package-lock.json consistency | |
| continue-on-error: true | |
| run: | | |
| if [ -f package-lock.json ]; then | |
| npm install --package-lock-only --no-audit --no-fund | |
| git diff --exit-code package-lock.json || ( | |
| echo "::error::package-lock.json is out of sync with package.json. Run 'npm install' and commit the updated lockfile." | |
| exit 1 | |
| ) | |
| fi | |
| - name: Setup Node.js with cache | |
| id: setup-node | |
| uses: ./.github/actions/node-setup-cache | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| supply-chain: | |
| name: π Supply Chain Security | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 6 | |
| needs: setup | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: true | |
| ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} | |
| - name: Restore Node.js and Cache | |
| uses: ./.github/actions/node-restore-cache | |
| with: | |
| cache-key: ${{ needs.setup.outputs.cache-key }} | |
| - name: Verify package integrity (npm audit signatures) | |
| run: | | |
| echo "π Verifying npm package signatures..." | |
| npm audit signatures 2>&1 || echo "::warning::Some packages may not have verified signatures (npm 10.7+ for full support)" | |
| continue-on-error: true | |
| - name: Check for known vulnerabilities (npm audit) | |
| run: | | |
| echo "π‘οΈ Checking for known vulnerabilities..." | |
| npm audit --audit-level=high 2>&1 || echo "::warning::High severity vulnerabilities detected. Consider 'npm audit fix' or updating dependencies." | |
| continue-on-error: true | |
| lint: | |
| name: β¨ Lint Code | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| needs: setup | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: true | |
| ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} | |
| - name: Restore Node.js and Cache | |
| uses: ./.github/actions/node-restore-cache | |
| with: | |
| cache-key: ${{ needs.setup.outputs.cache-key }} | |
| - name: Run linting | |
| continue-on-error: true | |
| run: | | |
| echo "Running linting..." | |
| npm run lint --if-present || echo "::warning::Linting failed but continuing workflow" | |
| - name: Run typecheck (optional) | |
| continue-on-error: true | |
| run: | | |
| npm run typecheck --if-present || true | |
| test-and-sonarqube: | |
| name: π§ͺ Test Coverage & SonarQube Analysis | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| needs: setup | |
| permissions: | |
| contents: read | |
| outputs: | |
| coverage_result: ${{ steps.coverage-summary.outputs.coverage_result }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: true | |
| fetch-depth: 0 | |
| ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }} | |
| - name: Restore Node.js and Cache | |
| uses: ./.github/actions/node-restore-cache | |
| with: | |
| cache-key: ${{ needs.setup.outputs.cache-key }} | |
| - name: Setup project configuration | |
| run: | | |
| echo "Setting up project configuration..." | |
| if [ -f ./setup-config.sh ]; then | |
| chmod +x ./setup-config.sh | |
| ./setup-config.sh | |
| fi | |
| - name: Run tests with coverage | |
| id: run-tests | |
| run: | | |
| echo "Running tests with coverage..." | |
| npm run test:coverage --if-present || npm test --if-present || echo "::warning::No test:coverage or test script; skipping" | |
| - name: Coverage (threshold + summary) | |
| id: coverage-summary | |
| if: always() | |
| run: | | |
| MIN=80 | |
| # Vitest: detect by config (handles its own thresholds); brittle uses Istanbul + nyc check. | |
| if [ -f vitest.config.js ] || [ -f vitest.config.ts ]; then | |
| if [ -f coverage/coverage-final.json ]; then | |
| if [ "${{ steps.run-tests.outcome }}" = "success" ]; then | |
| STATUS_CELL="β PASS" | |
| else | |
| STATUS_CELL="β FAIL (coverage below threshold in vitest.config.js)" | |
| fi | |
| echo "coverage_result=$STATUS_CELL Vitest (thresholds in vitest.config.js)" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "coverage_result=β οΈ No coverage report" >> "$GITHUB_OUTPUT" | |
| fi | |
| elif [ -f coverage/coverage-final.json ]; then | |
| mkdir -p .nyc_output | |
| cp coverage/coverage-final.json .nyc_output/out.json | |
| set +e | |
| echo "Running nyc check-coverage (threshold ${MIN}%)..." | |
| npx --yes nyc check-coverage --lines=$MIN --statements=$MIN --functions=$MIN --branches=$MIN | |
| EXIT=$? | |
| set -e | |
| echo "Running nyc report (text-summary)..." | |
| npx --yes nyc report --reporter=text-summary 2>&1 | tee report-summary.txt | |
| PCT=$(sed -n 's/^Lines[^:]*: *\([0-9.]*\)%.*/\1/p' report-summary.txt) | |
| [ -z "$PCT" ] && PCT="β" | |
| [ $EXIT -eq 0 ] && ICON="β " || ICON="β" | |
| echo "coverage_result=$ICON Coverage ${PCT}% (min ${MIN}%)" >> "$GITHUB_OUTPUT" | |
| exit $EXIT | |
| else | |
| echo "coverage_result=β οΈ No coverage report" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Run build (optional) | |
| continue-on-error: true | |
| run: npm run build --if-present || true | |
| - name: Tailscale | |
| id: tailscale | |
| if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' | |
| uses: tailscale/github-action@v4 | |
| with: | |
| oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} | |
| oauth-secret: ${{ secrets.TS_OAUTH_CLIENT_SECRET }} | |
| tags: tag:ci-sast | |
| version: latest | |
| ping: dev-sonarcube-0.tail8a2a3f.ts.net | |
| - name: Generate ESLint JSON report | |
| if: steps.tailscale.outcome == 'success' | |
| continue-on-error: true | |
| run: | | |
| if [ -f eslint.config.cjs ]; then | |
| npx eslint . --format json --output-file eslint-report.json | |
| fi | |
| - name: Prepare SonarQube Configuration | |
| if: steps.tailscale.outcome == 'success' | |
| run: | | |
| echo "Creating sonar-project.properties..." | |
| # Base configuration | |
| cat > sonar-project.properties << 'SONAR_EOF' | |
| sonar.projectKey=${{ github.event.repository.name }} | |
| sonar.projectName=${{ github.event.repository.name }} | |
| sonar.projectVersion=${{ github.sha }} | |
| sonar.sources=. | |
| sonar.language=js | |
| sonar.sourceEncoding=UTF-8 | |
| sonar.javascript.lcov.reportPaths=coverage/lcov.info | |
| sonar.javascript.node.maxspace=4096 | |
| sonar.javascript.environments=node | |
| sonar.qualitygate.wait=true | |
| sonar.test.inclusions=**/*.test.js,**/*.spec.js | |
| SONAR_EOF | |
| # Add ESLint report if it exists | |
| if [ -f eslint-report.json ]; then | |
| echo "sonar.eslint.reportPaths=eslint-report.json" >> sonar-project.properties | |
| fi | |
| # Base exclusions | |
| BASE_EXCLUSIONS="**/node_modules/**,**/coverage/**,**/dist/**,**/build/**,.github/**,**/*.test.js,**/*.spec.js,**/*.json,**/*.md,**/*.yml,**/*.yaml" | |
| # Add tests configuration if folder exists | |
| if [ -d "tests" ]; then | |
| echo "Tests folder found - including test configuration" | |
| echo "sonar.tests=tests" >> sonar-project.properties | |
| echo "sonar.exclusions=${BASE_EXCLUSIONS},**/tests/**" >> sonar-project.properties | |
| echo "sonar.cpd.exclusions=**/tests/**,**/node_modules/**" >> sonar-project.properties | |
| else | |
| echo "No tests folder found - skipping test configuration" | |
| echo "sonar.exclusions=${BASE_EXCLUSIONS}" >> sonar-project.properties | |
| fi | |
| echo "Configuration file created" | |
| - name: SonarQube Scan | |
| if: steps.tailscale.outcome == 'success' | |
| continue-on-error: true | |
| uses: SonarSource/sonarqube-scan-action@v7.0.0 | |
| env: | |
| SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} | |
| SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} | |
| summary: | |
| name: π CI Summary | |
| runs-on: ubuntu-latest | |
| needs: [setup, supply-chain, lint, test-and-sonarqube] | |
| if: always() && !cancelled() | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: π₯ Checkout for summary | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 1 | |
| - name: π Print summary | |
| env: | |
| R_SETUP: ${{ needs.setup.result || 'skipped' }} | |
| R_SUPPLY: ${{ needs.supply-chain.result || 'skipped' }} | |
| R_LINT: ${{ needs.lint.result || 'skipped' }} | |
| R_TEST: ${{ needs.test-and-sonarqube.result || 'skipped' }} | |
| COVERAGE: ${{ needs.test-and-sonarqube.outputs.coverage_result || 'β' }} | |
| run: | | |
| { | |
| echo "## π CI Pipeline Summary" | |
| echo "" | |
| echo "### π Commit information" | |
| echo "- **Commit:** $(git rev-parse HEAD)" | |
| echo "- **Message:** $(git log -1 --pretty=format:'%s')" | |
| echo "- **Author:** $(git log -1 --pretty=format:'%an <%ae>')" | |
| echo "- **Date:** $(git log -1 --pretty=format:'%ad' --date=short)" | |
| echo "" | |
| echo "### π― Job results" | |
| [ "${R_SETUP}" != "skipped" ] && echo "- π¦ Setup: ${R_SETUP:-?}" | |
| [ "${R_SUPPLY}" != "skipped" ] && echo "- π Supply Chain: ${R_SUPPLY:-?}" | |
| [ "${R_LINT}" != "skipped" ] && echo "- β¨ Lint: ${R_LINT:-?}" | |
| [ "${R_TEST}" != "skipped" ] && echo "- π§ͺ Test & SonarQube: ${R_TEST:-?}" | |
| echo "- π Coverage: ${COVERAGE}" | |
| echo "" | |
| echo "### π§ Pipeline" | |
| echo "- β Dependency review (PR only)" | |
| echo "- β Lockfile check, npm audit, lint, tests" | |
| echo "- β SonarQube (push to main or manual run)" | |
| } | tee -a "$GITHUB_STEP_SUMMARY" |