fix(ci): a crashed emulator fails in seconds instead of hanging 30 minutes #185
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
| # KeepKey python-keepkey CI | |
| # | |
| # Pulls the published emulator image (kktech/kkemu) from DockerHub | |
| # and runs the full python integration test suite against it. | |
| # | |
| # Stage 1: GATE (seconds) | |
| # └─ lint Python syntax + deterministic protocol contract tests | |
| # | |
| # Stage 2: TEST (gated by Stage 1) | |
| # └─ integration full pytest suite against emulator | |
| name: CI | |
| on: | |
| push: | |
| branches: [master, develop, reconcile/upstream-sync, 'feature/**', 'fix/**', 'hotfix/**'] | |
| pull_request: | |
| branches: [master, develop, reconcile/upstream-sync] | |
| # One run per ref: a new push supersedes the old instead of both burning a | |
| # runner to completion. | |
| concurrency: | |
| group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| # ═══════════════════════════════════════════════════════════ | |
| # STAGE 1: GATE | |
| # ═══════════════════════════════════════════════════════════ | |
| lint: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.11' | |
| - name: Syntax check | |
| run: python -m py_compile keepkeylib/*.py | |
| - name: Install contract-test dependencies | |
| run: | | |
| pip install "protobuf>=3.20,<4" mnemonic ecdsa pytest | |
| - name: Run deterministic Zcash PCZT contract tests | |
| env: | |
| PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION: python | |
| run: | | |
| python -m pytest -q \ | |
| tests/test_msg_zcash_sign_pczt.py \ | |
| tests/test_zcash_seed_fingerprint_helper.py | |
| - name: Lint summary | |
| run: | | |
| echo "## 🔑 KeepKey python-keepkey — Lint" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "| Check | Status |" >> "$GITHUB_STEP_SUMMARY" | |
| echo "|-------|--------|" >> "$GITHUB_STEP_SUMMARY" | |
| echo "| Syntax | ✅ PASS |" >> "$GITHUB_STEP_SUMMARY" | |
| echo "| Zcash PCZT contract | ✅ PASS |" >> "$GITHUB_STEP_SUMMARY" | |
| # ═══════════════════════════════════════════════════════════ | |
| # STAGE 2: TEST — pull published emulator, run pytest | |
| # ═══════════════════════════════════════════════════════════ | |
| integration: | |
| needs: [lint] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| services: | |
| kkemu: | |
| # kktech/kkemu:latest on Docker Hub is firmware 7.10.0, built | |
| # 2026-03-12 -- five months and six minor versions behind the suite | |
| # that runs against it. Pin a digest once a current image is published; | |
| # until then the version gate below is what fails closed. | |
| image: kktech/kkemu:latest | |
| ports: | |
| - 11044:11044/udp | |
| - 11045:11045/udp | |
| - 5000:5000 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| submodules: recursive | |
| - uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.11' | |
| - name: Install dependencies | |
| run: | | |
| pip install --upgrade pip | |
| pip install "protobuf>=3.20,<4" | |
| pip install -e . | |
| pip install pytest semver rlp requests eth-keys pycryptodome | |
| - name: Wait for emulator | |
| run: | | |
| echo "Waiting for emulator bridge on port 5000..." | |
| for i in $(seq 1 30); do | |
| if curl -sf -X POST http://localhost:5000/exchange/main \ | |
| -H 'Content-Type: application/json' \ | |
| -d '{"data":""}' > /dev/null 2>&1; then | |
| echo "Emulator ready after ${i}s" | |
| break | |
| fi | |
| sleep 1 | |
| done | |
| # "The emulator answered a ping" is not "the emulator is the right | |
| # firmware". CI ran a 7.16-era suite against a 7.10.0 image for five | |
| # months: 80 tests gate on requires_firmware("7.15.0") and silently | |
| # SKIPPED, while one unskipped test drove a code path that segfaults in | |
| # 7.10.0 and is already fixed in 7.15 -- which reads as a product failure | |
| # but is only a stale image. A floating tag cannot tell you that. This | |
| # can, and it fails closed. | |
| - name: Assert the emulator is not older than the suite | |
| timeout-minutes: 2 | |
| env: | |
| KK_TRANSPORT_MAIN: "127.0.0.1:11044" | |
| KK_TRANSPORT_DEBUG: "127.0.0.1:11045" | |
| KK_MIN_FW: "7.15.0" | |
| KK_UDP_TIMEOUT: "20" | |
| working-directory: tests | |
| run: | | |
| python - <<'PY' | |
| import os, sys | |
| sys.path.insert(0, '..') | |
| import config | |
| from keepkeylib.client import KeepKeyDebuglinkClient | |
| c = KeepKeyDebuglinkClient(config.TRANSPORT(*config.TRANSPORT_ARGS, | |
| **config.TRANSPORT_KWARGS)) | |
| c.set_debuglink(config.DEBUG_TRANSPORT(*config.DEBUG_TRANSPORT_ARGS, | |
| **config.DEBUG_TRANSPORT_KWARGS)) | |
| c.init_device() | |
| f = c.features | |
| got = (f.major_version, f.minor_version, f.patch_version) | |
| floor = tuple(int(x) for x in os.environ['KK_MIN_FW'].split('.')) | |
| print('emulator firmware %d.%d.%d, floor %s' % | |
| (got + (os.environ['KK_MIN_FW'],))) | |
| if got < floor: | |
| sys.exit('FATAL: the emulator image predates the tests that run ' | |
| 'against it. Republish kktech/kkemu from current ' | |
| 'firmware and pin the new digest above.') | |
| PY | |
| # Step-level timeout, deliberately: a JOB-level timeout ends the job as | |
| # "cancelled", which reads as an infra blip. A step timeout is a FAILURE. | |
| - name: Run integration tests | |
| timeout-minutes: 8 | |
| env: | |
| KK_TRANSPORT_MAIN: "127.0.0.1:11044" | |
| KK_TRANSPORT_DEBUG: "127.0.0.1:11045" | |
| PYTHONPATH: "${{ github.workspace }}/keepkeylib:${{ github.workspace }}" | |
| # A crashed emulator now raises instead of blocking in recv() forever. | |
| KK_UDP_TIMEOUT: "45" | |
| run: | | |
| cd tests | |
| pytest -v --junitxml=junit.xml 2>&1 | tee pytest-output.txt | |
| echo "${PIPESTATUS[0]}" > status | |
| - name: Test summary | |
| if: always() | |
| run: | | |
| XML="tests/junit.xml" | |
| echo "## 🔑 KeepKey python-keepkey — Integration Tests" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| if [ ! -f "$XML" ]; then | |
| echo "❌ **No test results found** — suite may have crashed before completion." >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| TOTAL=$(grep -oP 'tests="\K[0-9]+' "$XML" | head -1) | |
| FAILED=$(grep -oP 'failures="\K[0-9]+' "$XML" | head -1) | |
| ERRORS=$(grep -oP 'errors="\K[0-9]+' "$XML" | head -1) | |
| SKIPPED=$(grep -oP 'skipped="\K[0-9]+' "$XML" | head -1) | |
| TIME=$(grep -oP 'time="\K[0-9.]+' "$XML" | head -1) | |
| TOTAL=${TOTAL:-0}; FAILED=${FAILED:-0}; ERRORS=${ERRORS:-0}; SKIPPED=${SKIPPED:-0} | |
| PASSED=$((TOTAL - FAILED - ERRORS - SKIPPED)) | |
| if [ "$FAILED" -eq 0 ] && [ "$ERRORS" -eq 0 ]; then | |
| echo "✅ **$PASSED of $TOTAL TESTS PASSED** in ${TIME}s" >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "❌ **$((FAILED + ERRORS)) of $TOTAL TESTS FAILED**" >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "| Metric | Count |" >> "$GITHUB_STEP_SUMMARY" | |
| echo "|--------|-------|" >> "$GITHUB_STEP_SUMMARY" | |
| echo "| Total | $TOTAL |" >> "$GITHUB_STEP_SUMMARY" | |
| echo "| ✅ Passed | $PASSED |" >> "$GITHUB_STEP_SUMMARY" | |
| echo "| ⏭️ Skipped | $SKIPPED |" >> "$GITHUB_STEP_SUMMARY" | |
| echo "| ❌ Failed | $FAILED |" >> "$GITHUB_STEP_SUMMARY" | |
| echo "| 💥 Errors | $ERRORS |" >> "$GITHUB_STEP_SUMMARY" | |
| # Itemize skipped with reasons | |
| python3 -c "import xml.etree.ElementTree as ET,sys;tree=ET.parse(sys.argv[1]);[print(f'| \`{tc.get(\"classname\",\"\")}.{tc.get(\"name\",\"\")}\` | {tc.find(\"skipped\").get(\"message\",tc.find(\"skipped\").text or \"No reason given\")} |') for tc in tree.iter('testcase') if tc.find('skipped') is not None]" "$XML" > /tmp/skip_rows.txt 2>/dev/null || true | |
| if [ -s /tmp/skip_rows.txt ]; then | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "### Skipped Tests" >> "$GITHUB_STEP_SUMMARY" | |
| echo "| Test | Reason |" >> "$GITHUB_STEP_SUMMARY" | |
| echo "|------|--------|" >> "$GITHUB_STEP_SUMMARY" | |
| cat /tmp/skip_rows.txt >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| fi | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "---" >> "$GITHUB_STEP_SUMMARY" | |
| echo "*KeepKey python-keepkey CI*" >> "$GITHUB_STEP_SUMMARY" | |
| # NO check_name. With one, this action publishes a SEPARATE check run | |
| # via the Checks API, and its require_tests default of 'false' means an | |
| # absent junit.xml -- which is exactly what a killed pytest leaves behind | |
| # -- reports conclusion:success with zero duration. That green check sat | |
| # on top of a job timing out at 30 minutes for at least six merges. | |
| # annotate_only keeps the inline annotations without minting a check. | |
| - name: Annotate test results | |
| uses: mikepenz/action-junit-report@v4 | |
| if: always() | |
| with: | |
| report_paths: tests/junit.xml | |
| annotate_only: true | |
| require_tests: true | |
| fail_on_failure: true | |
| - name: Fail on test failure | |
| if: always() | |
| run: | | |
| STATUS=$(cat tests/status 2>/dev/null || echo "1") | |
| [ "$STATUS" = "0" ] || exit 1 |