|
1 | | -name: Unit and Coverage |
| 1 | +name: Unit Tests and Coverage |
2 | 2 |
|
3 | 3 | on: |
4 | 4 | pull_request: |
5 | | - branches: [ main ] # Gate PRs into main |
| 5 | + branches: [main] |
6 | 6 | push: |
7 | | - branches: [ main ] # also run on direct pushes |
8 | | - workflow_dispatch: # Manual runs |
| 7 | + branches: [main] # Only on main to avoid duplicate runs with pull_request |
| 8 | + workflow_dispatch: |
9 | 9 | inputs: |
10 | 10 | ref: |
11 | | - description: "Branch or SHA to test (e.g. feature/x or a1b2c3)" |
| 11 | + description: "Branch or SHA to test" |
12 | 12 | required: false |
13 | 13 | cov_threshold: |
14 | | - description: "Override threshold (percent) for this manual run only" |
| 14 | + description: "Override coverage threshold (%)" |
15 | 15 | required: false |
16 | 16 |
|
17 | 17 | concurrency: |
18 | | - group: earthly-tests-${{ github.ref }} |
| 18 | + group: unit-tests-${{ github.ref }} |
19 | 19 | cancel-in-progress: true |
20 | 20 |
|
21 | 21 | permissions: |
22 | | - contents: read |
| 22 | + contents: write # Needed to push threshold updates |
23 | 23 |
|
24 | 24 | jobs: |
25 | | - run-earthly-tests: |
26 | | - name: Run earthly +test (coverage gate) |
| 25 | + test: |
| 26 | + name: Unit Tests & Coverage Gate |
27 | 27 | runs-on: ubuntu-latest |
28 | 28 | timeout-minutes: 30 |
29 | 29 |
|
30 | 30 | steps: |
31 | | - - name: Checkout repository |
| 31 | + - name: Checkout |
32 | 32 | uses: actions/checkout@v4 |
33 | 33 | with: |
34 | 34 | fetch-depth: 0 |
35 | 35 | persist-credentials: false |
36 | | - # Use input ref if provided; else PR head SHA; else current SHA |
37 | | - ref: ${{ inputs.ref != '' && inputs.ref || (github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha) }} |
| 36 | + ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }} |
38 | 37 |
|
39 | 38 | - name: Setup Earthly |
40 | 39 | uses: earthly/actions/setup-earthly@v1 |
41 | 40 | with: |
42 | 41 | version: "latest" |
43 | 42 |
|
44 | | - - name: Show Earthly version |
45 | | - run: earthly --version |
46 | | - |
47 | | - - name: Resolve coverage threshold |
48 | | - id: threshold |
| 43 | + - name: Configure test parameters |
| 44 | + id: config |
49 | 45 | env: |
50 | | - MANUAL_COV: ${{ inputs.cov_threshold }} |
| 46 | + INPUT_COV_THRESHOLD: ${{ inputs.cov_threshold }} |
51 | 47 | run: | |
52 | | - set -euo pipefail |
53 | | - |
54 | | - # Default threshold (matches script default) |
55 | | - DEFAULT="12.3" |
56 | | - |
57 | | - # Use manual override if provided, otherwise use default |
58 | | - if [[ -n "${MANUAL_COV:-}" ]]; then |
59 | | - HEAD_VAL="${MANUAL_COV}" |
60 | | - echo "Using manual threshold override: ${HEAD_VAL}%" |
| 48 | + # Read threshold from file, allow manual override |
| 49 | + FILE_THRESHOLD=$(cat .coverage-threshold 2>/dev/null || echo "65.0") |
| 50 | + COV_THRESHOLD="${INPUT_COV_THRESHOLD:-$FILE_THRESHOLD}" |
| 51 | + echo "cov_threshold=${COV_THRESHOLD}" >> "$GITHUB_OUTPUT" |
| 52 | + echo "build_id=${GITHUB_RUN_ID}" >> "$GITHUB_OUTPUT" |
| 53 | + if [[ -n "${INPUT_COV_THRESHOLD}" ]]; then |
| 54 | + echo "::notice::Coverage threshold: ${COV_THRESHOLD}% (from manual override)" |
61 | 55 | else |
62 | | - HEAD_VAL="$DEFAULT" |
63 | | - echo "Using default threshold: ${HEAD_VAL}%" |
| 56 | + echo "::notice::Coverage threshold: ${COV_THRESHOLD}% (from .coverage-threshold file)" |
64 | 57 | fi |
65 | 58 |
|
66 | | - # Set environment variables for subsequent steps |
67 | | - echo "COV_THRESHOLD=${HEAD_VAL}" >> "$GITHUB_ENV" |
68 | | - echo "PRINT_TS=${GITHUB_RUN_ID}" >> "$GITHUB_ENV" |
69 | | - echo "FAIL_ON_NO_TESTS=false" >> "$GITHUB_ENV" |
70 | | -
|
71 | | - echo "Resolved threshold: ${HEAD_VAL}%" |
72 | | - echo "Build ID: ${GITHUB_RUN_ID}" |
73 | | -
|
74 | | - # Run standard tests with coverage |
75 | | - - name: Run Earthly +test with coverage threshold |
| 59 | + - name: Run tests with coverage |
| 60 | + id: test |
| 61 | + env: |
| 62 | + COV_THRESHOLD: ${{ steps.config.outputs.cov_threshold }} |
| 63 | + BUILD_ID: ${{ steps.config.outputs.build_id }} |
76 | 64 | run: | |
77 | | - earthly +test --COV_THRESHOLD="${COV_THRESHOLD}" --PRINT_TS="${PRINT_TS}" --FAIL_ON_NO_TESTS="${FAIL_ON_NO_TESTS}" |
| 65 | + earthly +test \ |
| 66 | + --COV_THRESHOLD="${COV_THRESHOLD}" \ |
| 67 | + --PRINT_TS="${BUILD_ID}" \ |
| 68 | + --FAIL_ON_NO_TESTS="false" |
78 | 69 |
|
79 | | - # Upload main coverage artifacts (always generated by script) |
80 | | - - name: Upload main coverage artifacts |
| 70 | + - name: Upload coverage artifacts |
81 | 71 | if: always() |
82 | 72 | uses: actions/upload-artifact@v4 |
83 | 73 | with: |
84 | | - name: coverage-reports |
| 74 | + name: coverage-${{ github.run_id }} |
85 | 75 | path: | |
86 | 76 | coverage.out |
87 | | - coverage_total.txt |
88 | | - coverage_packages.txt |
89 | | - test_raw.log |
90 | | -
|
91 | | - # Upload detailed per-directory artifacts for debugging |
92 | | - - name: Upload per-directory coverage artifacts |
93 | | - if: always() |
94 | | - uses: actions/upload-artifact@v4 |
95 | | - with: |
96 | | - name: per-directory-coverage |
97 | | - path: | |
98 | | - *_coverage.out |
99 | | - *_test.log |
100 | | - overall_coverage.out |
101 | | - combined_coverage.out |
102 | | - overall_test.log |
103 | | - overall_test_with_failures.log |
104 | | - if-no-files-found: ignore |
105 | | - |
106 | | - # Legacy individual uploads for backward compatibility |
107 | | - - name: Upload coverage.out (legacy) |
108 | | - if: always() |
109 | | - uses: actions/upload-artifact@v4 |
110 | | - with: |
111 | | - name: coverage.out |
112 | | - path: coverage.out |
113 | | - if-no-files-found: warn |
114 | | - |
115 | | - - name: Upload coverage_total.txt (legacy) |
116 | | - if: always() |
117 | | - uses: actions/upload-artifact@v4 |
118 | | - with: |
119 | | - name: coverage_total.txt |
120 | | - path: coverage_total.txt |
121 | | - if-no-files-found: warn |
| 77 | + coverage_report.txt |
| 78 | + retention-days: 30 |
122 | 79 |
|
123 | | - - name: Upload coverage_packages.txt (legacy) |
124 | | - if: always() |
125 | | - uses: actions/upload-artifact@v4 |
126 | | - with: |
127 | | - name: coverage_packages.txt |
128 | | - path: coverage_packages.txt |
129 | | - if-no-files-found: warn |
130 | | - |
131 | | - - name: Upload test_raw.log (legacy) |
132 | | - if: always() |
133 | | - uses: actions/upload-artifact@v4 |
134 | | - with: |
135 | | - name: test_raw.log |
136 | | - path: test_raw.log |
137 | | - if-no-files-found: warn |
138 | | - |
139 | | - - name: Publish coverage summary |
| 80 | + - name: Generate coverage summary |
140 | 81 | if: always() |
141 | 82 | run: | |
142 | 83 | { |
143 | | - echo "## Coverage Summary" |
144 | | - if [[ -f coverage_total.txt ]]; then |
145 | | - echo "" |
146 | | - echo '```' |
147 | | - cat coverage_total.txt |
148 | | - echo '```' |
149 | | - fi |
150 | | - if [[ -f coverage_packages.txt ]]; then |
151 | | - echo "" |
152 | | - echo "Packages by coverage (lowest first):" |
153 | | - echo '```' |
154 | | - head -n 50 coverage_packages.txt || true |
155 | | - echo '```' |
156 | | - fi |
| 84 | + echo "## 📊 Test Coverage Report" |
157 | 85 | echo "" |
158 | | - echo "**Threshold used:** ${COV_THRESHOLD}%" |
159 | | - echo "**Build ID:** ${PRINT_TS}" |
160 | | - echo "**Test method:** Per-directory coverage with script-based execution" |
161 | 86 | |
162 | | - # Add directory-level summary if available |
163 | | - if [[ -f coverage_total.txt ]] && grep -q "| Directory" coverage_total.txt; then |
| 87 | + # Extract overall coverage |
| 88 | + if [[ -f coverage_report.txt ]]; then |
| 89 | + # Extract numeric values using simpler patterns |
| 90 | + OVERALL=$(grep "Overall Coverage:" coverage_report.txt | sed 's/[^0-9.]*\([0-9.]\+\)%.*/\1/')% |
| 91 | + THRESHOLD=$(grep "Threshold:" coverage_report.txt | sed 's/[^0-9.]*\([0-9.]\+\)%.*/\1/')% |
| 92 | + STATUS=$(grep "Status:" coverage_report.txt | sed 's/[^A-Z]*\([A-Z]\+\).*/\1/') |
| 93 | + |
| 94 | + # Status badge |
| 95 | + if [[ "$STATUS" == "PASSED" ]]; then |
| 96 | + echo "| Metric | Value | Status |" |
| 97 | + echo "|--------|-------|--------|" |
| 98 | + echo "| **Overall Coverage** | $OVERALL | ✅ PASSED |" |
| 99 | + echo "| **Threshold** | $THRESHOLD | - |" |
| 100 | + echo "| **Build** | #${{ github.run_id }} | - |" |
| 101 | + else |
| 102 | + echo "| Metric | Value | Status |" |
| 103 | + echo "|--------|-------|--------|" |
| 104 | + echo "| **Overall Coverage** | $OVERALL | ❌ FAILED |" |
| 105 | + echo "| **Threshold** | $THRESHOLD | - |" |
| 106 | + echo "| **Build** | #${{ github.run_id }} | - |" |
| 107 | + fi |
164 | 108 | echo "" |
165 | | - echo "### Directory Test Results" |
166 | | - echo '```' |
167 | | - grep -A 100 "| Directory" coverage_total.txt | head -n 50 || true |
168 | | - echo '```' |
| 109 | + |
| 110 | + # Failed tests section |
| 111 | + if grep -q "Failed Tests:" coverage_report.txt; then |
| 112 | + echo "### ❌ Failed Tests" |
| 113 | + echo "" |
| 114 | + sed -n '/\*\*Failed Tests:\*\*/,/^\*\*Note/p' coverage_report.txt | grep "•" | head -20 |
| 115 | + echo "" |
| 116 | + fi |
| 117 | + |
| 118 | + # Directory results table |
| 119 | + if grep -q "| Directory" coverage_report.txt; then |
| 120 | + echo "### 📁 Directory Results" |
| 121 | + echo "" |
| 122 | + sed -n '/| Directory/,/^$/p' coverage_report.txt | head -50 |
| 123 | + echo "" |
| 124 | + fi |
| 125 | + else |
| 126 | + echo "⚠️ Coverage report not generated" |
| 127 | + echo "" |
| 128 | + echo "Check the workflow logs for details." |
169 | 129 | fi |
170 | 130 | } >> "$GITHUB_STEP_SUMMARY" |
171 | 131 |
|
172 | | - # Debug job for troubleshooting (runs on manual trigger with failures) |
173 | | - run-earthly-tests-debug: |
174 | | - name: Run earthly +test-debug (enhanced debugging) |
175 | | - runs-on: ubuntu-latest |
176 | | - timeout-minutes: 45 |
177 | | - if: failure() && github.event_name == 'workflow_dispatch' |
178 | | - needs: run-earthly-tests |
179 | | - |
180 | | - steps: |
181 | | - - name: Checkout repository |
182 | | - uses: actions/checkout@v4 |
183 | | - with: |
184 | | - fetch-depth: 0 |
185 | | - persist-credentials: false |
186 | | - ref: ${{ inputs.ref != '' && inputs.ref || (github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha) }} |
187 | | - |
188 | | - - name: Setup Earthly |
189 | | - uses: earthly/actions/setup-earthly@v1 |
190 | | - with: |
191 | | - version: "latest" |
192 | | - |
193 | | - - name: Run Earthly +test-debug with enhanced output |
| 132 | + - name: Auto-update coverage threshold (ratchet) |
| 133 | + if: success() && github.ref != 'refs/heads/main' |
194 | 134 | env: |
195 | | - COV_THRESHOLD: ${{ inputs.cov_threshold || '12.3' }} |
196 | | - PRINT_TS: ${{ github.run_id }} |
197 | | - FAIL_ON_NO_TESTS: "false" |
198 | | - run: | |
199 | | - earthly +test-debug --COV_THRESHOLD="${COV_THRESHOLD}" --PRINT_TS="${PRINT_TS}" --FAIL_ON_NO_TESTS="${FAIL_ON_NO_TESTS}" |
200 | | -
|
201 | | - - name: Upload all debug artifacts |
202 | | - if: always() |
203 | | - uses: actions/upload-artifact@v4 |
204 | | - with: |
205 | | - name: debug-coverage-artifacts |
206 | | - path: | |
207 | | - coverage.out |
208 | | - coverage_total.txt |
209 | | - coverage_packages.txt |
210 | | - test_raw.log |
211 | | - *_coverage.out |
212 | | - *_test.log |
213 | | - overall_coverage.out |
214 | | - combined_coverage.out |
215 | | - overall_test.log |
216 | | - overall_test_with_failures.log |
217 | | - if-no-files-found: ignore |
218 | | - |
219 | | - - name: Show debug summary |
220 | | - if: always() |
| 135 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 136 | + EVENT_NAME: ${{ github.event_name }} |
| 137 | + HEAD_REF: ${{ github.head_ref }} |
| 138 | + REF_NAME: ${{ github.ref_name }} |
221 | 139 | run: | |
222 | | - echo "=== Debug Coverage Analysis ===" |
223 | | - echo "Files generated:" |
224 | | - ls -la *.out *.txt *.log || true |
225 | | - echo "" |
226 | | - echo "Directory structure:" |
227 | | - find . -name "*coverage*" -o -name "*test*.log" | head -20 || true |
| 140 | + set -x # Debug: show commands |
| 141 | + |
| 142 | + # Determine branch name based on event type |
| 143 | + if [[ "${EVENT_NAME}" == "pull_request" ]]; then |
| 144 | + BRANCH="${HEAD_REF}" |
| 145 | + else |
| 146 | + # For workflow_dispatch or other events, use ref_name |
| 147 | + BRANCH="${REF_NAME}" |
| 148 | + fi |
| 149 | + |
| 150 | + echo "Target branch: ${BRANCH}" |
| 151 | + |
| 152 | + # Skip if on main branch (shouldn't happen due to condition, but safety check) |
| 153 | + if [[ "$BRANCH" == "main" ]]; then |
| 154 | + echo "Skipping ratchet on main branch" |
| 155 | + exit 0 |
| 156 | + fi |
| 157 | + |
| 158 | + # Get current coverage from report using simpler pattern |
| 159 | + CURRENT=$(grep "Overall Coverage:" coverage_report.txt | sed 's/[^0-9.]*\([0-9.]\+\)%.*/\1/') |
| 160 | + OLD_THRESHOLD=$(cat .coverage-threshold 2>/dev/null || echo "0") |
| 161 | + |
| 162 | + # Validate CURRENT is a valid number before proceeding |
| 163 | + if [[ -z "$CURRENT" ]] || ! [[ "$CURRENT" =~ ^[0-9]+\.?[0-9]*$ ]]; then |
| 164 | + echo "::error::Failed to parse coverage value from report (got: '$CURRENT')" |
| 165 | + exit 1 |
| 166 | + fi |
| 167 | + |
| 168 | + echo "Current coverage: ${CURRENT}%" |
| 169 | + echo "Current threshold: ${OLD_THRESHOLD}%" |
| 170 | + |
| 171 | + # Calculate new threshold (0.5% buffer below actual coverage) |
| 172 | + # Use printf to ensure consistent one-decimal-place formatting |
| 173 | + NEW_THRESHOLD=$(printf '%.1f' "$(echo "$CURRENT - 0.5" | bc -l)") |
| 174 | + |
| 175 | + echo "Proposed new threshold: ${NEW_THRESHOLD}%" |
| 176 | + |
| 177 | + # Only update if new threshold is higher than old threshold |
| 178 | + if (( $(echo "$NEW_THRESHOLD > $OLD_THRESHOLD" | bc -l) )); then |
| 179 | + echo "Updating threshold: ${OLD_THRESHOLD}% -> ${NEW_THRESHOLD}%" |
| 180 | + echo "${NEW_THRESHOLD}" > .coverage-threshold |
| 181 | + |
| 182 | + # Configure git |
| 183 | + git config user.name "github-actions[bot]" |
| 184 | + git config user.email "github-actions[bot]@users.noreply.github.com" |
| 185 | + |
| 186 | + # Fetch and checkout the branch |
| 187 | + git fetch origin "${BRANCH}" |
| 188 | + git checkout "${BRANCH}" |
| 189 | + # Ensure branch is up to date in case it changed after the workflow started |
| 190 | + git pull --rebase origin "${BRANCH}" |
| 191 | + |
| 192 | + # Stage and commit |
| 193 | + git add .coverage-threshold |
| 194 | + git commit -m "chore: auto-update coverage threshold to ${NEW_THRESHOLD}% (was ${OLD_THRESHOLD}%)" |
| 195 | + git push origin "${BRANCH}" |
| 196 | + |
| 197 | + echo "::notice::Coverage threshold updated to ${NEW_THRESHOLD}% on branch ${BRANCH}" |
| 198 | + else |
| 199 | + echo "New threshold (${NEW_THRESHOLD}%) is not higher than current (${OLD_THRESHOLD}%), no update needed" |
| 200 | + fi |
0 commit comments