|
| 1 | +name: final-check |
| 2 | + |
| 3 | +# A maintainer adds the `final-check` label to an important PR. The check job |
| 4 | +# runs what test.yml runs (ruff, mypy, the seed-data validator, pytest with |
| 5 | +# coverage) on the PR, and TechEngineBot leaves ONE comment with the verdict; |
| 6 | +# adding the label again edits that comment. This is the only place the |
| 7 | +# TechEngineBot account is used in this repo, and it replaces Copilot reviews. |
| 8 | +# |
| 9 | +# The check job runs PR code, so it gets no secrets and a read-only token. |
| 10 | +# Only the comment job sees TECHENGINEBOT_TOKEN, which must be a fine-grained |
| 11 | +# token of TechEngineBot with Pull requests: read and write; the job refuses any |
| 12 | +# other kind of token or account. Fork PRs never get a comment. |
| 13 | +on: |
| 14 | + pull_request: |
| 15 | + types: [labeled] |
| 16 | + branches: [main] |
| 17 | + |
| 18 | +permissions: {} |
| 19 | + |
| 20 | +jobs: |
| 21 | + check: |
| 22 | + if: github.event.label.name == 'final-check' |
| 23 | + runs-on: ubuntu-24.04 |
| 24 | + # test.yml takes about 10-15 minutes. |
| 25 | + timeout-minutes: 45 |
| 26 | + permissions: |
| 27 | + contents: read |
| 28 | + concurrency: |
| 29 | + group: final-check-${{ github.event.pull_request.number }} |
| 30 | + cancel-in-progress: true |
| 31 | + outputs: |
| 32 | + body: ${{ steps.report.outputs.body }} |
| 33 | + env: |
| 34 | + PYTHONIOENCODING: utf-8 |
| 35 | + TECHAPI_DATA_DIR: ${{ github.workspace }}/TechAPI/data |
| 36 | + steps: |
| 37 | + # The merge commit, as main would look after merging. |
| 38 | + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| 39 | + with: |
| 40 | + persist-credentials: false |
| 41 | + |
| 42 | + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| 43 | + with: |
| 44 | + repository: GetTechAPI/TechAPI |
| 45 | + path: TechAPI |
| 46 | + persist-credentials: false |
| 47 | + |
| 48 | + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 |
| 49 | + with: |
| 50 | + python-version: "3.12" |
| 51 | + |
| 52 | + - name: Install dependencies |
| 53 | + id: install |
| 54 | + continue-on-error: true |
| 55 | + run: | |
| 56 | + status=0 |
| 57 | + python -m pip install --disable-pip-version-check -e ".[dev]" > "$RUNNER_TEMP/install.log" 2>&1 || status=$? |
| 58 | + tail -n 40 "$RUNNER_TEMP/install.log" |
| 59 | + exit "$status" |
| 60 | +
|
| 61 | + - name: Lint (ruff) |
| 62 | + id: ruff |
| 63 | + if: steps.install.outcome == 'success' |
| 64 | + continue-on-error: true |
| 65 | + run: | |
| 66 | + status=0 |
| 67 | + ruff check app tests > "$RUNNER_TEMP/ruff.log" 2>&1 || status=$? |
| 68 | + cat "$RUNNER_TEMP/ruff.log" |
| 69 | + exit "$status" |
| 70 | +
|
| 71 | + - name: Type check (mypy) |
| 72 | + id: mypy |
| 73 | + if: steps.install.outcome == 'success' |
| 74 | + continue-on-error: true |
| 75 | + run: | |
| 76 | + status=0 |
| 77 | + mypy app > "$RUNNER_TEMP/mypy.log" 2>&1 || status=$? |
| 78 | + cat "$RUNNER_TEMP/mypy.log" |
| 79 | + exit "$status" |
| 80 | +
|
| 81 | + - name: Validate seed data |
| 82 | + id: validate |
| 83 | + if: steps.install.outcome == 'success' |
| 84 | + continue-on-error: true |
| 85 | + run: | |
| 86 | + status=0 |
| 87 | + python -m app.validate > "$RUNNER_TEMP/validate.log" 2>&1 || status=$? |
| 88 | + tail -n 40 "$RUNNER_TEMP/validate.log" |
| 89 | + exit "$status" |
| 90 | +
|
| 91 | + - name: Tests + coverage (fail under 60%) |
| 92 | + id: pytest |
| 93 | + if: steps.install.outcome == 'success' |
| 94 | + continue-on-error: true |
| 95 | + run: | |
| 96 | + status=0 |
| 97 | + pytest --cov=app --cov-report=term --cov-fail-under=60 > "$RUNNER_TEMP/pytest.log" 2>&1 || status=$? |
| 98 | + tail -n 60 "$RUNNER_TEMP/pytest.log" |
| 99 | + exit "$status" |
| 100 | +
|
| 101 | + # The comment quotes tool output, which can carry text from the PR: logs |
| 102 | + # stay inside code fences with backticks and @ removed, so they can |
| 103 | + # neither break out into markdown nor mention anyone. |
| 104 | + - name: Report |
| 105 | + id: report |
| 106 | + if: always() |
| 107 | + env: |
| 108 | + INSTALL: ${{ steps.install.outcome }} |
| 109 | + RUFF: ${{ steps.ruff.outcome }} |
| 110 | + MYPY: ${{ steps.mypy.outcome }} |
| 111 | + VALIDATE: ${{ steps.validate.outcome }} |
| 112 | + PYTEST: ${{ steps.pytest.outcome }} |
| 113 | + HEAD_SHA: ${{ github.event.pull_request.head.sha }} |
| 114 | + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} |
| 115 | + run: | |
| 116 | + clean() { tr -d '`@' | cut -c1-300; } |
| 117 | + fence() { # fence <log> <lines> |
| 118 | + echo '```text' |
| 119 | + tail -n "$2" "$1" 2>/dev/null | clean |
| 120 | + echo '```' |
| 121 | + } |
| 122 | + verdict='✅ Final check passed' |
| 123 | + [ "$INSTALL/$RUFF/$MYPY/$VALIDATE/$PYTEST" = success/success/success/success/success ] \ |
| 124 | + || verdict='❌ Final check failed' |
| 125 | + { |
| 126 | + echo '<!-- techengine-final-check -->' |
| 127 | + echo "### $verdict" |
| 128 | + echo |
| 129 | + echo "Head \`${HEAD_SHA::7}\`, checked as merged into the base · [run]($RUN_URL)" |
| 130 | + echo |
| 131 | + echo '| Step | Result |' |
| 132 | + echo '| --- | --- |' |
| 133 | + echo "| Install | ${INSTALL:-skipped} |" |
| 134 | + echo "| Lint (ruff) | ${RUFF:-skipped} |" |
| 135 | + echo "| Type check (mypy) | ${MYPY:-skipped} |" |
| 136 | + echo "| Validate seed data | ${VALIDATE:-skipped} |" |
| 137 | + echo "| Tests + coverage (60%) | ${PYTEST:-skipped} |" |
| 138 | + if [ -s "$RUNNER_TEMP/pytest.log" ]; then |
| 139 | + echo |
| 140 | + echo '<details><summary>pytest summary</summary>' |
| 141 | + echo |
| 142 | + fence "$RUNNER_TEMP/pytest.log" 5 |
| 143 | + echo '</details>' |
| 144 | + fi |
| 145 | + [ "$INSTALL" = failure ] && { echo; echo '<details><summary>Install log</summary>'; echo; fence "$RUNNER_TEMP/install.log" 20; echo '</details>'; } |
| 146 | + [ "$RUFF" = failure ] && { echo; echo '<details><summary>ruff log</summary>'; echo; fence "$RUNNER_TEMP/ruff.log" 30; echo '</details>'; } |
| 147 | + [ "$MYPY" = failure ] && { echo; echo '<details><summary>mypy log</summary>'; echo; fence "$RUNNER_TEMP/mypy.log" 30; echo '</details>'; } |
| 148 | + [ "$VALIDATE" = failure ] && { echo; echo '<details><summary>Validation log</summary>'; echo; fence "$RUNNER_TEMP/validate.log" 30; echo '</details>'; } |
| 149 | + [ "$PYTEST" = failure ] && { echo; echo '<details><summary>pytest log</summary>'; echo; fence "$RUNNER_TEMP/pytest.log" 40; echo '</details>'; } |
| 150 | + true |
| 151 | + } > "$RUNNER_TEMP/body.md" |
| 152 | + cat "$RUNNER_TEMP/body.md" >> "$GITHUB_STEP_SUMMARY" |
| 153 | + delim="BODY_$(openssl rand -hex 16)" |
| 154 | + { echo "body<<$delim"; cat "$RUNNER_TEMP/body.md"; echo "$delim"; } >> "$GITHUB_OUTPUT" |
| 155 | + [ "$verdict" = '✅ Final check passed' ] |
| 156 | +
|
| 157 | + comment: |
| 158 | + needs: check |
| 159 | + if: >- |
| 160 | + always() && github.event.label.name == 'final-check' && |
| 161 | + (needs.check.result == 'success' || needs.check.result == 'failure') && |
| 162 | + github.event.pull_request.head.repo.full_name == github.repository |
| 163 | + runs-on: ubuntu-24.04 |
| 164 | + timeout-minutes: 5 |
| 165 | + permissions: {} |
| 166 | + steps: |
| 167 | + - name: Check the token |
| 168 | + id: token |
| 169 | + env: |
| 170 | + TOKEN: ${{ secrets.TECHENGINEBOT_TOKEN }} |
| 171 | + run: | |
| 172 | + if [ -z "$TOKEN" ]; then |
| 173 | + echo "::warning::TECHENGINEBOT_TOKEN is not set; the verdict is in the check job's summary only." |
| 174 | + echo "ok=false" >> "$GITHUB_OUTPUT" |
| 175 | + exit 0 |
| 176 | + fi |
| 177 | + case "$TOKEN" in |
| 178 | + github_pat_*) echo "ok=true" >> "$GITHUB_OUTPUT" ;; |
| 179 | + *) echo "::error::TECHENGINEBOT_TOKEN must be a fine-grained token (github_pat_...) of TechEngineBot with Pull requests: read and write." |
| 180 | + exit 1 ;; |
| 181 | + esac |
| 182 | +
|
| 183 | + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 |
| 184 | + if: steps.token.outputs.ok == 'true' |
| 185 | + env: |
| 186 | + BODY: ${{ needs.check.outputs.body }} |
| 187 | + PR: ${{ github.event.pull_request.number }} |
| 188 | + with: |
| 189 | + github-token: ${{ secrets.TECHENGINEBOT_TOKEN }} |
| 190 | + script: | |
| 191 | + const marker = '<!-- techengine-final-check -->'; |
| 192 | + const body = process.env.BODY || ''; |
| 193 | + if (!body.startsWith(marker)) { |
| 194 | + core.setFailed('The check job produced no report; nothing posted.'); |
| 195 | + return; |
| 196 | + } |
| 197 | + const { data: me } = await github.rest.users.getAuthenticated(); |
| 198 | + if (me.login !== 'TechEngineBot') { |
| 199 | + core.setFailed(`TECHENGINEBOT_TOKEN belongs to ${me.login}, not TechEngineBot; nothing posted.`); |
| 200 | + return; |
| 201 | + } |
| 202 | + const { owner, repo } = context.repo; |
| 203 | + const issue_number = Number(process.env.PR); |
| 204 | + const comments = await github.paginate(github.rest.issues.listComments, { |
| 205 | + owner, repo, issue_number, per_page: 100, |
| 206 | + }); |
| 207 | + const mine = comments.find((c) => c.user?.login === me.login && c.body?.startsWith(marker)); |
| 208 | + if (!mine) { |
| 209 | + const { data } = await github.rest.issues.createComment({ owner, repo, issue_number, body }); |
| 210 | + core.info(`Posted ${data.html_url}`); |
| 211 | + } else if (mine.body !== body) { |
| 212 | + await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body }); |
| 213 | + core.info(`Updated ${mine.html_url}`); |
| 214 | + } else { |
| 215 | + core.info(`Unchanged: ${mine.html_url}`); |
| 216 | + } |
0 commit comments