Skip to content

fix(ci): pin clang-format to v20 stable + format zxappliquid.c #226

fix(ci): pin clang-format to v20 stable + format zxappliquid.c

fix(ci): pin clang-format to v20 stable + format zxappliquid.c #226

Workflow file for this run

# KeepKey Firmware CI Pipeline
#
# FAIL FAST — quick checks gate everything, no wasted compute.
#
# Stage 1: GATE (seconds, no Docker)
# ├─ lint-format clang-format diff check
# ├─ static-analysis cppcheck static analysis
# ├─ secret-scan gitleaks credential detection
# └─ check-submodules verify all deps present
#
# Stage 2: BUILD (parallel, gated by Stage 1)
# ├─ build-emulator Docker image → artifact
# └─ build-arm-firmware cross-compile → .bin/.elf (downloadable)
#
# Stage 3: TEST (parallel, gated by Stage 2)
# ├─ unit-tests GoogleTest (make xunit)
# └─ python-integration full test suite
#
# Stage 4: PUBLISH (manual trigger, all tests must pass)
# └─ publish-emulator DockerHub push (workflow_dispatch only)
name: CI
on:
push:
branches: [master, develop, 'feature/**', 'fix/**', 'release/**', 'hotfix/**']
pull_request:
branches: [master, develop]
workflow_dispatch:
inputs:
publish_emulator:
description: 'Publish emulator image to DockerHub'
required: false
type: boolean
default: false
env:
BASE_IMAGE: kktech/firmware:v15
EMU_IMAGE: kkemu-ci
jobs:
# ═══════════════════════════════════════════════════════════
# STAGE 1: GATE — kill bad PRs in seconds
# ═══════════════════════════════════════════════════════════
lint-format:
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Install clang-format-20 (pinned stable)
run: |
wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo apt-key add -
echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-20 main" | sudo tee /etc/apt/sources.list.d/llvm.list
sudo apt-get update -qq && sudo apt-get install -y -qq clang-format-20
sudo ln -sf /usr/bin/clang-format-20 /usr/bin/clang-format
- name: Check code formatting
run: |
echo "Using: $(clang-format --version)"
FAILED=0
for f in $(find include/keepkey lib/firmware lib/board lib/transport/src \
-name '*.c' -o -name '*.h' 2>/dev/null | grep -v generated | grep -v '.pb.'); do
if ! clang-format --style=file --dry-run --Werror "$f" 2>/dev/null; then
echo "::warning file=$f::Formatting differs from .clang-format"
FAILED=1
fi
done
if [ "$FAILED" = "1" ]; then
echo ""
echo "::error::Code formatting check failed. Run: clang-format -i <file> to fix."
exit 1
fi
secret-scan:
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
- name: Install gitleaks
run: |
GITLEAKS_VERSION=$(curl -sSf https://api.github.com/repos/gitleaks/gitleaks/releases/latest \
| grep -oP '"tag_name":\s*"v\K[^"]+')
curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
gitleaks version
- name: Run gitleaks
run: gitleaks detect --source . --verbose --redact
static-analysis:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: false
- name: Install cppcheck
run: sudo apt-get update && sudo apt-get install -y cppcheck
- name: Run cppcheck
run: |
cppcheck \
--enable=warning,style,performance,portability \
--std=c11 \
--platform=unspecified \
--inconclusive \
--force \
--inline-suppr \
--suppressions-list=.cppcheck-suppressions \
-I include \
-I deps/crypto/trezor-firmware/crypto \
-I deps/device-protocol \
-DSTM32F2=1 \
-DUSE_ETHEREUM=1 \
-DUSE_KECCAK=1 \
-DUSE_NANO=1 \
-DPB_FIELD_16BIT=1 \
-DEMULATOR=1 \
--template='::warning file={file},line={line},col={column}::{severity}: {message} [{id}]' \
--output-file=cppcheck_report.txt \
--error-exitcode=1 \
lib/ include/keepkey/ tools/ 2>&1; CPPCHECK_RC=$?
# Count issues by severity
ERRORS=$(grep -c '\berror:' cppcheck_report.txt 2>/dev/null || true)
WARNINGS=$(grep -c '\bwarning:' cppcheck_report.txt 2>/dev/null || true)
STYLE=$(grep -c '\bstyle:' cppcheck_report.txt 2>/dev/null || true)
PERF=$(grep -c '\bperformance:' cppcheck_report.txt 2>/dev/null || true)
PORT=$(grep -c '\bportability:' cppcheck_report.txt 2>/dev/null || true)
: "${ERRORS:=0}" "${WARNINGS:=0}" "${STYLE:=0}" "${PERF:=0}" "${PORT:=0}"
TOTAL=$((ERRORS + WARNINGS + STYLE + PERF + PORT))
echo "## cppcheck summary" >> "$GITHUB_STEP_SUMMARY"
echo "| Severity | Count |" >> "$GITHUB_STEP_SUMMARY"
echo "|----------|-------|" >> "$GITHUB_STEP_SUMMARY"
echo "| error | $ERRORS |" >> "$GITHUB_STEP_SUMMARY"
echo "| warning | $WARNINGS |" >> "$GITHUB_STEP_SUMMARY"
echo "| style | $STYLE |" >> "$GITHUB_STEP_SUMMARY"
echo "| performance | $PERF |" >> "$GITHUB_STEP_SUMMARY"
echo "| portability | $PORT |" >> "$GITHUB_STEP_SUMMARY"
echo "| **total** | **$TOTAL** |" >> "$GITHUB_STEP_SUMMARY"
# Print findings as GitHub annotations
cat cppcheck_report.txt
if [ "$CPPCHECK_RC" -ne 0 ]; then
echo "::error::cppcheck exited with error code $CPPCHECK_RC"
exit 1
fi
if [ "$TOTAL" -gt 0 ]; then
echo "::error::cppcheck found $TOTAL issue(s) — zero-warning policy enforced"
exit 1
fi
echo "cppcheck: clean — zero findings"
- name: Upload cppcheck report
uses: actions/upload-artifact@v7
if: always()
with:
name: cppcheck-report
path: cppcheck_report.txt
retention-days: 30
check-submodules:
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Verify submodules are declared
run: |
echo "Checking required submodule declarations..."
REQUIRED=(
"deps/crypto/trezor-firmware"
"deps/device-protocol"
"deps/python-keepkey"
"deps/googletest"
"deps/qrenc/QR-Code-generator"
"deps/sca-hardening/SecAESSTM32"
)
FAILED=0
for sub in "${REQUIRED[@]}"; do
if ! grep -q "$sub" .gitmodules; then
echo "::error::Missing required submodule: $sub"
FAILED=1
else
echo " ✓ $sub"
fi
done
[ "$FAILED" = "0" ] || exit 1
# ═══════════════════════════════════════════════════════════
# STAGE 2: BUILD — compile only after gate passes
# ═══════════════════════════════════════════════════════════
build-emulator:
needs: [lint-format, static-analysis, check-submodules, secret-scan]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Init submodules
run: |
git submodule update --init deps/crypto/trezor-firmware
git submodule update --init deps/device-protocol
git submodule update --init --recursive deps/python-keepkey
git submodule update --init deps/googletest
git submodule update --init deps/qrenc/QR-Code-generator
git submodule update --init deps/sca-hardening/SecAESSTM32
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Cache base image
id: cache-base
uses: actions/cache@v5
with:
path: /tmp/base-image.tar
key: base-image-${{ env.BASE_IMAGE }}
- name: Pull and cache base image
if: steps.cache-base.outputs.cache-hit != 'true'
run: |
docker pull ${{ env.BASE_IMAGE }}
docker save ${{ env.BASE_IMAGE }} -o /tmp/base-image.tar
- name: Load base image from cache
if: steps.cache-base.outputs.cache-hit == 'true'
run: docker load -i /tmp/base-image.tar
- name: Build emulator image
run: |
docker build \
-t ${{ env.EMU_IMAGE }} \
-f scripts/emulator/Dockerfile \
.
- name: Save emulator image
run: docker save ${{ env.EMU_IMAGE }} -o /tmp/emu-image.tar
- name: Upload emulator image artifact
uses: actions/upload-artifact@v7
with:
name: emu-image
path: /tmp/emu-image.tar
retention-days: 1
build-arm-firmware:
needs: [lint-format, static-analysis, check-submodules, secret-scan]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Init submodules
run: |
git submodule update --init deps/crypto/trezor-firmware
git submodule update --init deps/device-protocol
git submodule update --init --recursive deps/python-keepkey
git submodule update --init deps/googletest
git submodule update --init deps/qrenc/QR-Code-generator
git submodule update --init deps/sca-hardening/SecAESSTM32
- name: Cache base image
id: cache-base
uses: actions/cache@v5
with:
path: /tmp/base-image.tar
key: base-image-${{ env.BASE_IMAGE }}
- name: Pull and cache base image
if: steps.cache-base.outputs.cache-hit != 'true'
run: |
docker pull ${{ env.BASE_IMAGE }}
docker save ${{ env.BASE_IMAGE }} -o /tmp/base-image.tar
- name: Load base image from cache
if: steps.cache-base.outputs.cache-hit == 'true'
run: docker load -i /tmp/base-image.tar
- name: Extract firmware version
id: version
run: |
FW_VERSION=$(sed -n '/^project/,/)/p' CMakeLists.txt | grep -oP '\d+\.\d+\.\d+')
GIT_SHORT=$(git rev-parse --short HEAD)
echo "fw_version=${FW_VERSION}" >> "$GITHUB_OUTPUT"
echo "git_short=${GIT_SHORT}" >> "$GITHUB_OUTPUT"
echo "Firmware version: ${FW_VERSION} (${GIT_SHORT})"
- name: Cross-compile firmware for ARM
run: |
docker run --rm \
-v ${{ github.workspace }}:/root/keepkey-firmware:z \
${{ env.BASE_IMAGE }} /bin/sh -c "\
mkdir /root/build && cd /root/build && \
cmake -C /root/keepkey-firmware/cmake/caches/device.cmake /root/keepkey-firmware \
-DCMAKE_BUILD_TYPE=MinSizeRel \
-DCMAKE_COLOR_MAKEFILE=ON && \
make && \
mkdir -p /root/keepkey-firmware/bin && \
cp bin/*.bin /root/keepkey-firmware/bin/ && \
cp bin/*.elf /root/keepkey-firmware/bin/ && \
chmod -R a+rw /root/keepkey-firmware/bin"
- name: Rename firmware artifacts
run: |
cd bin
for f in *.bin; do
[ -f "$f" ] || continue
mv "$f" "firmware.keepkey.v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}-${f}"
done
for f in *.elf; do
[ -f "$f" ] || continue
mv "$f" "firmware.keepkey.v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}-${f}"
done
ls -lh
echo "::notice::Firmware v${{ steps.version.outputs.fw_version }} built successfully"
- name: Upload firmware artifacts
uses: actions/upload-artifact@v7
with:
name: firmware-v${{ steps.version.outputs.fw_version }}-${{ steps.version.outputs.git_short }}
path: |
bin/*.bin
bin/*.elf
retention-days: 90
# ═══════════════════════════════════════════════════════════
# STAGE 3: TEST — run only after builds succeed
# ═══════════════════════════════════════════════════════════
unit-tests:
needs: build-emulator
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Download emulator image
uses: actions/download-artifact@v8
with:
name: emu-image
path: /tmp
- name: Load emulator image
run: docker load -i /tmp/emu-image.tar
- name: Run unit tests
run: |
# make xunit returns non-zero if any test fails — capture
# exit code so JUnit XML still gets copied for reporting
docker run --rm \
-v ${{ github.workspace }}/test-reports:/kkemu/test-reports \
--entrypoint /bin/sh \
${{ env.EMU_IMAGE }} \
-c "mkdir -p /kkemu/test-reports/firmware-unit && \
make xunit; RC=\$?; \
cp -r unittests/*.xml /kkemu/test-reports/firmware-unit/ 2>/dev/null; \
exit \$RC"
- name: Upload unit test results
uses: actions/upload-artifact@v7
if: always()
with:
name: unit-test-results
path: test-reports/firmware-unit/
retention-days: 30
python-integration-tests:
needs: [lint-format, static-analysis, check-submodules, secret-scan]
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Init submodules
run: |
git submodule update --init deps/crypto/trezor-firmware
git submodule update --init deps/device-protocol
git submodule update --init --recursive deps/python-keepkey
git submodule update --init deps/googletest
git submodule update --init deps/qrenc/QR-Code-generator
git submodule update --init deps/sca-hardening/SecAESSTM32
- name: Build and run tests (docker compose)
working-directory: scripts/emulator
run: |
# Run each test container — capture exit codes, always extract reports
docker compose up --build --exit-code-from firmware-unit firmware-unit; FW_RC=$?
docker compose up --build --exit-code-from python-keepkey python-keepkey; PY_RC=$?
mkdir -p ${{ github.workspace }}/test-reports
echo "=== Extracting test reports from Docker ==="
PY_CONTAINER=$(docker compose ps -a -q python-keepkey)
FW_CONTAINER=$(docker compose ps -a -q firmware-unit)
docker cp "$FW_CONTAINER":/kkemu/test-reports/. ${{ github.workspace }}/test-reports/ || echo "WARN: firmware-unit docker cp failed"
docker cp "$PY_CONTAINER":/kkemu/test-reports/. ${{ github.workspace }}/test-reports/ || echo "WARN: python-keepkey docker cp failed"
echo "=== Extracted files ==="
find ${{ github.workspace }}/test-reports -type f | head -30
echo "=== Screenshot PNGs ==="
find ${{ github.workspace }}/test-reports/screenshots -name '*.png' 2>/dev/null | wc -l
echo "PNGs on host"
echo "firmware-unit exit code: $FW_RC"
echo "python-keepkey exit code: $PY_RC"
if [ "$FW_RC" -ne 0 ]; then
echo "::error::firmware-unit tests failed (exit code $FW_RC)"
fi
if [ "$PY_RC" -ne 0 ]; then
echo "::error::python-keepkey tests failed (exit code $PY_RC)"
fi
[ "$FW_RC" -eq 0 ] && [ "$PY_RC" -eq 0 ] || exit 1
- name: Upload Python test results
uses: actions/upload-artifact@v7
if: always()
with:
name: python-test-results
path: test-reports/python-keepkey/
retention-days: 30
- name: Upload OLED screenshots
uses: actions/upload-artifact@v4
if: always()
with:
name: oled-screenshots
path: test-reports/screenshots/
retention-days: 90
if-no-files-found: warn
- name: Tear down
if: always()
working-directory: scripts/emulator
run: docker compose down -v || true
# ═══════════════════════════════════════════════════════════
# STAGE 3b: TEST REPORT — generate PDF from test artifacts
# ═══════════════════════════════════════════════════════════
generate-test-report:
needs: [unit-tests, python-integration-tests]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Download unit test results
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: unit-test-results
path: test-reports/firmware-unit/
- name: Download python test results
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: python-test-results
path: test-reports/python-keepkey/
- name: Download OLED screenshots
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: oled-screenshots
path: test-reports/screenshots/
- name: Extract firmware version
id: version
run: |
FW_VERSION=$(sed -n '/^project/,/)/p' CMakeLists.txt | grep -oP '\d+\.\d+\.\d+' || echo "unknown")
echo "fw_version=${FW_VERSION}" >> "$GITHUB_OUTPUT"
- name: Init python-keepkey submodule
run: git submodule update --init deps/python-keepkey
- name: Generate test report PDF
env:
FW_VERSION: ${{ steps.version.outputs.fw_version }}
run: python3 scripts/generate-test-report.py
- name: Upload test report
uses: actions/upload-artifact@v4
if: always()
with:
name: test-report
path: test-report.pdf
retention-days: 90
# ═══════════════════════════════════════════════════════════
# STAGE 4: PUBLISH — manual trigger only, all tests must pass
# ═══════════════════════════════════════════════════════════
publish-emulator:
needs: [unit-tests, python-integration-tests, build-arm-firmware]
if: >-
github.event_name == 'workflow_dispatch' &&
github.event.inputs.publish_emulator == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Download emulator image
uses: actions/download-artifact@v8
with:
name: emu-image
path: /tmp
- name: Load emulator image
run: docker load -i /tmp/emu-image.tar
- name: Extract firmware version
id: version
run: |
FW_VERSION=$(sed -n '/^project/,/)/p' CMakeLists.txt | grep -oP '\d+\.\d+\.\d+')
echo "fw_version=${FW_VERSION}" >> "$GITHUB_OUTPUT"
echo "Firmware version: ${FW_VERSION}"
- name: Tag images for publish
run: |
docker tag ${{ env.EMU_IMAGE }} kktech/kkemu:latest
docker tag ${{ env.EMU_IMAGE }} kktech/kkemu:v${{ steps.version.outputs.fw_version }}
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.KK_DOCKERHUB_USER }}
password: ${{ secrets.KK_DOCKERHUB_PASS }}
- name: Push emulator images
run: |
docker push kktech/kkemu:latest
docker push kktech/kkemu:v${{ steps.version.outputs.fw_version }}