|
| 1 | +name: final-check |
| 2 | + |
| 3 | +# A maintainer adds the `final-check` label to an important PR. The check job |
| 4 | +# runs what TechMachine's CI checks (tests, repos.json, syntax, pinned actions) |
| 5 | +# and TechEngineBot leaves ONE comment with the verdict; adding the label again |
| 6 | +# edits that comment. Same design as TechAPI's final-check. |
| 7 | +# |
| 8 | +# The check job runs PR code, so it gets no secrets and a read-only token, and |
| 9 | +# nothing in it writes anywhere or reaches another repository. Only the comment |
| 10 | +# job sees TECHENGINEBOT_TOKEN, which must be a fine-grained token of |
| 11 | +# TechEngineBot with Pull requests: read and write; the job refuses any other |
| 12 | +# kind of token or account. Fork PRs never get a comment. |
| 13 | +on: |
| 14 | + pull_request: |
| 15 | + types: [labeled] |
| 16 | + branches: [develop, main] |
| 17 | + |
| 18 | +permissions: {} |
| 19 | + |
| 20 | +jobs: |
| 21 | + check: |
| 22 | + if: github.event.label.name == 'final-check' |
| 23 | + runs-on: ubuntu-24.04 |
| 24 | + timeout-minutes: 15 |
| 25 | + permissions: |
| 26 | + contents: read |
| 27 | + concurrency: |
| 28 | + group: final-check-${{ github.event.pull_request.number }} |
| 29 | + cancel-in-progress: true |
| 30 | + outputs: |
| 31 | + body: ${{ steps.report.outputs.body }} |
| 32 | + env: |
| 33 | + PYTHONIOENCODING: utf-8 |
| 34 | + steps: |
| 35 | + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| 36 | + with: |
| 37 | + persist-credentials: false |
| 38 | + |
| 39 | + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 |
| 40 | + with: |
| 41 | + python-version: "3.12" |
| 42 | + |
| 43 | + - name: Install pytest |
| 44 | + id: install |
| 45 | + continue-on-error: true |
| 46 | + run: | |
| 47 | + status=0 |
| 48 | + python -m pip install --disable-pip-version-check pytest > "$RUNNER_TEMP/install.log" 2>&1 || status=$? |
| 49 | + tail -n 20 "$RUNNER_TEMP/install.log" |
| 50 | + exit "$status" |
| 51 | +
|
| 52 | + # Includes generating a satellite repository and running its own tests |
| 53 | + # and validator, so a template change that breaks new repos fails here. |
| 54 | + - name: Tests |
| 55 | + id: tests |
| 56 | + if: steps.install.outcome == 'success' |
| 57 | + continue-on-error: true |
| 58 | + run: | |
| 59 | + status=0 |
| 60 | + python -m pytest -q -p no:cacheprovider > "$RUNNER_TEMP/tests.log" 2>&1 || status=$? |
| 61 | + cat "$RUNNER_TEMP/tests.log" |
| 62 | + exit "$status" |
| 63 | +
|
| 64 | + - name: Validate repos.json |
| 65 | + id: config |
| 66 | + continue-on-error: true |
| 67 | + run: | |
| 68 | + status=0 |
| 69 | + python - > "$RUNNER_TEMP/config.log" 2>&1 <<'EOF' || status=$? |
| 70 | + import json, sys |
| 71 | + cfg = json.load(open("machine/repos.json", encoding="utf-8")) |
| 72 | + errors = [] |
| 73 | + for repo in cfg.get("repos", []): |
| 74 | + name = repo.get("name", "") |
| 75 | + if not name.startswith("GetTechAPI/"): |
| 76 | + errors.append(f"repo {name!r} is not in GetTechAPI") |
| 77 | + if not repo.get("branches"): |
| 78 | + errors.append(f"{name}: no branches") |
| 79 | + if not all(isinstance(s, str) for s in repo.get("allowed_secrets", [])): |
| 80 | + errors.append(f"{name}: allowed_secrets must be strings") |
| 81 | + for endpoint in cfg.get("endpoints", []): |
| 82 | + if not endpoint.get("url", "").startswith("https://"): |
| 83 | + errors.append(f"{endpoint.get('name')}: url must be https") |
| 84 | + if not endpoint.get("expect"): |
| 85 | + errors.append(f"{endpoint.get('name')}: no expected field") |
| 86 | + if not cfg.get("repos"): |
| 87 | + errors.append("no repos") |
| 88 | + print("\n".join(errors) or f"{len(cfg['repos'])} repos, {len(cfg.get('endpoints', []))} endpoints") |
| 89 | + sys.exit(1 if errors else 0) |
| 90 | + EOF |
| 91 | + cat "$RUNNER_TEMP/config.log" |
| 92 | + exit "$status" |
| 93 | +
|
| 94 | + # The repository only allows GitHub-owned actions pinned by full SHA. |
| 95 | + - name: Syntax and pinned actions |
| 96 | + id: lint |
| 97 | + continue-on-error: true |
| 98 | + run: | |
| 99 | + status=0 |
| 100 | + { |
| 101 | + python -m compileall -q machine tests || status=1 |
| 102 | + unpinned=$(grep -nE '^\s*-?\s*uses:' .github/workflows/*.yml | grep -vE 'uses: actions/[a-z-]+@[0-9a-f]{40}( |$)' || true) |
| 103 | + if [ -n "$unpinned" ]; then |
| 104 | + echo "Not a GitHub-owned action pinned by SHA:" |
| 105 | + echo "$unpinned" |
| 106 | + status=1 |
| 107 | + fi |
| 108 | + } > "$RUNNER_TEMP/lint.log" 2>&1 |
| 109 | + cat "$RUNNER_TEMP/lint.log" |
| 110 | + exit "$status" |
| 111 | +
|
| 112 | + # The comment quotes tool output, which can carry text from the PR: logs |
| 113 | + # stay inside code fences with backticks and @ removed, so they can |
| 114 | + # neither break out into markdown nor mention anyone. |
| 115 | + - name: Report |
| 116 | + id: report |
| 117 | + if: always() |
| 118 | + env: |
| 119 | + INSTALL: ${{ steps.install.outcome }} |
| 120 | + TESTS: ${{ steps.tests.outcome }} |
| 121 | + CONFIG: ${{ steps.config.outcome }} |
| 122 | + LINT: ${{ steps.lint.outcome }} |
| 123 | + HEAD_SHA: ${{ github.event.pull_request.head.sha }} |
| 124 | + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} |
| 125 | + run: | |
| 126 | + clean() { tr -d '`@' | cut -c1-300; } |
| 127 | + fence() { # fence <log> <lines> |
| 128 | + echo '```text' |
| 129 | + tail -n "$2" "$1" 2>/dev/null | clean |
| 130 | + echo '```' |
| 131 | + } |
| 132 | + verdict='✅ Final check passed' |
| 133 | + [ "$INSTALL/$TESTS/$CONFIG/$LINT" = success/success/success/success ] || verdict='❌ Final check failed' |
| 134 | + { |
| 135 | + echo '<!-- techmachine-final-check -->' |
| 136 | + echo "### $verdict" |
| 137 | + echo |
| 138 | + echo "Head \`${HEAD_SHA::7}\`, checked as merged into the base · [run]($RUN_URL)" |
| 139 | + echo |
| 140 | + echo '| Step | Result |' |
| 141 | + echo '| --- | --- |' |
| 142 | + echo "| Install pytest | ${INSTALL:-skipped} |" |
| 143 | + echo "| Tests (incl. a generated satellite repo) | ${TESTS:-skipped} |" |
| 144 | + echo "| repos.json | ${CONFIG:-skipped} |" |
| 145 | + echo "| Syntax and pinned actions | ${LINT:-skipped} |" |
| 146 | + [ "$INSTALL" = failure ] && { echo; echo '<details><summary>Install log</summary>'; echo; fence "$RUNNER_TEMP/install.log" 20; echo '</details>'; } |
| 147 | + [ "$TESTS" = failure ] && { echo; echo '<details><summary>Test log</summary>'; echo; fence "$RUNNER_TEMP/tests.log" 40; echo '</details>'; } |
| 148 | + [ "$CONFIG" = failure ] && { echo; echo '<details><summary>repos.json log</summary>'; echo; fence "$RUNNER_TEMP/config.log" 20; echo '</details>'; } |
| 149 | + [ "$LINT" = failure ] && { echo; echo '<details><summary>Syntax and pinned actions log</summary>'; echo; fence "$RUNNER_TEMP/lint.log" 20; 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 = '<!-- techmachine-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