perf(ci): cut pipeline wall clock from ~7:20 to ~3:00 - #474
Conversation
The pipeline spent its time almost entirely on work it did not need to
do. Measured against runs 33043154603 and 33042916635 (both ~440s), the
critical path was a single chain:
static-analysis 192s -> build-emulator 175s -> unit-tests 54s -> report 9s
Inside that chain, the emulator image was compiled twice, an 845 MB
tarball was moved between two runners to run a 1s test suite, and
cppcheck ran single-threaded while every build job waited on it.
Changes:
* Merge build-emulator, unit-tests and python-integration-tests into a
single build-and-test job. The image is now built exactly once and both
suites run against it in place. This removes the 845 MB emu-image
round-trip (77s save+upload, 49s download+load, for suites that take 1s
and 11s) and the duplicate `docker compose --build` compile. The
tarball is still produced, but only on the workflow_dispatch publish
path that actually consumes it.
* Parallelise cppcheck with -j8. --force is kept deliberately: dropping
it is ~5x faster again but stops the combinatorial #ifdef sweep, which
is worth keeping on firmware. Measured locally, identical findings:
--force -j1 77s --force -j4 31s
* Stop gating builds on static-analysis. cppcheck is advisory and was the
slowest Stage 1 job, so it serialised everything behind itself. It
still fails the run, it just no longer delays it. lint-format and
secret-scan stay in the gate; both finish in ~20s, and a leaked
credential should stop the run before any artifact exists.
* Drop the base-image tar cache. It was ref-scoped, so every branch wrote
its own 649 MB copy; 13 of them held 8.4 GB of the repo's 10 GB quota
and evicted each other, which is why it missed on develop anyway. A
plain docker pull costs the same 40s it already cost on every miss.
* Fold check-submodules into lint-format. It was a 3s grep over
.gitmodules that cost a full runner spin-up and sat in the needs
fan-in of every downstream job.
* Install clang-format from PyPI instead of the LLVM apt repo, and fan
the per-file checks across cores with xargs -P.
* Parallelise submodule init with --jobs 4 in the three jobs that do it.
--recursive stays scoped to python-keepkey only: trezor-firmware
declares 9 nested submodules this build never uses.
* Cache pip and the pinned protoc download in python-dylib-tests.
* Add a concurrency group so superseded PR runs are cancelled. Pushes to
develop, master and release branches are never cancelled.
Every job still checks out an identical submodule set, and actionlint
reports the same 5 pre-existing findings as before.
Deliberately not changed: collapsing the two pytest phases in
python-keepkey-tests.sh. Phase 1 costs 3.3s, and merging it into Phase 2
would run all 437 tests under KEEPKEY_SCREENSHOT=1 instead of the
85-test SECTIONS-derived filter, changing what lands in the 90-day
oled-screenshots artifact and what generate-test-report.py consumes.
…mage Follow-up to the previous commit, driven by the layer-level timings in the build log of run 33043154603 rather than by estimate. That log shows the firmware compile is not the expensive part of the pipeline: RUN pip install rlp eth-keys eth-utils pycryptodome 28.8s FROM kktech/firmware:v15 (base image pull) 34.4s RUN apk add python3-dev gcc musl-dev ~8s RUN make -j (the actual firmware compile) 9.6s Merging the jobs put that 37s of pip/apk work onto the critical path, so build-and-test would have landed near 155s rather than the ~100s estimated. * Cache the python-keepkey image layers via type=gha. Both RUN layers sit before `COPY ./ /kkemu`, so they are stable across commits and only the final COPY is invalidated by a source change. Driven through docker/build-push-action rather than a `run:` step on purpose: the gha cache backend needs ACTIONS_RUNTIME_TOKEN and ACTIONS_CACHE_URL, which GitHub injects into actions but not into `run:` shells -- buildx from a shell would warn and export nothing, giving a cache that looks configured and silently does no work. * Fold generate-test-report into build-and-test. Every input it needed was already on the runner: the three report directories, the checkout, and the python-keepkey submodule. As a separate job it paid a runner spin-up plus three artifact downloads to reassemble state that had just been torn down. Kept at if: always(), so a red run still produces its PDF. * Mirror the base image to GHCR, with a Docker Hub fallback. Serving 650 MB to a GitHub-hosted runner from GHCR avoids both the slower transfer and Docker Hub's anonymous pull limits. The mirrored image is re-tagged to its Docker Hub name locally because scripts/emulator/Dockerfile hardcodes `FROM kktech/firmware:v15`; neither `docker build` nor `docker compose build` passes --pull, so a local image under that name satisfies the FROM without editing the Dockerfile. CI warns and falls back when the mirror is absent, so this never becomes a hard dependency of the build. * Drop `docker compose down -v`. The runner is destroyed within seconds of the job ending; this was ~10s spent tidying a machine about to cease to exist. * Tighten the emulator healthcheck from a 3s to a 1s interval. The service is listening well inside a second, so the suite was waiting on poll granularity. retries is raised to 40 so the worst-case ceiling stays generous rather than shrinking with the faster interval. * Skip the workflow on documentation-only commits. The run benchmarked for this work was itself a docs commit that spent 7:20 and two firmware compiles on a Markdown edit. This skips the workflow rather than reporting a neutral check, which is safe today because develop is unprotected and master's only required context is a CircleCI job that no longer exists in this repo -- noted inline so it is revisited if branch protection starts requiring a CI job. Not folded in, deliberately: cppcheck --cppcheck-build-dir caching would take static-analysis from ~85s to ~30s, but that job is no longer on the critical path, so it buys billed minutes rather than wall clock -- not worth incremental-analysis cache-correctness risk on a security tool in this change. python-dylib-tests stays gated. Ungating it wins no wall clock (82s against a 155s pole) and the gate is what stops a lint-failing commit from spending 10x-billed macOS minutes. Jobs: 11 -> 7. actionlint reports the same 5 pre-existing findings.
There was a problem hiding this comment.
Pull request overview
This PR restructures the GitHub Actions CI workflow to significantly reduce wall-clock time by removing redundant Docker builds/artifact shuttling, increasing parallelism, and introducing better caching/mirroring for large base images.
Changes:
- Consolidates emulator build + firmware unit tests + python integration tests (and report generation) into a single
build-and-testjob to avoid rebuilding and re-uploading/loading large Docker images. - Adds base-image mirroring to GHCR (with Docker Hub fallback) and introduces a dedicated
mirror-base-imageworkflow to populate the mirror. - Improves CI efficiency via higher parallelism (cppcheck
-j8, clang-format fan-out), concurrency cancellation for PRs, and docs-only workflow skipping.
Reviewed changes
Copilot reviewed 3 out of 4 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
.github/workflows/ci.yml |
Major CI pipeline restructure (job consolidation, caching/mirroring, parallelism, concurrency, docs-only skipping). |
.github/workflows/mirror-base-image.yml |
New manual workflow to mirror the Docker Hub base image into GHCR for faster pulls. |
scripts/emulator/docker-compose.yml |
Compose tweaks to support single-build reuse in CI and faster healthcheck polling. |
.gitignore |
Ignores cppcheck output files generated by CI runs. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The previous commit's type=gha cache on the whole python-keepkey image made that step slower, not faster. Measured in run 33045998760: #9 RUN python3 -m pip install ... 36.8s #14 exporting to GitHub Actions Cache 103.9s The step went from ~40s to 184s. cache-to with mode=max exports every layer, including `COPY ./ /kkemu` -- and .dockerignore excludes only **/.git and bin, so that layer ships essentially the entire build context. It is also invalidated by every commit, so it can never produce a cache hit. The config spent 104s exporting a layer that will never be read, to save 37s of pip. Split the Dockerfile into a `deps` stage holding the apk and pip layers, and cache that target alone: - `deps` target: cache-from + cache-to. Small, stable across commits. - full image: cache-from only. Never exports the COPY layer. Restores the dependency-layer saving that was the point of the change while removing the export that swamped it.
… lost Run 33046518184 went green having uploaded no python-keepkey results and no screenshots. The tests themselves were fine -- 379 passed, 58 skipped in 11.94s, 310 PNGs captured, matching the pre-change baseline exactly. The results never left the container: chmod /home/runner/work/.../test-reports/firmware-unit: operation not permitted WARN: python-keepkey docker cp failed Merging the unit and integration suites into one job introduced this. The `make xunit` container runs as root and leaves a root-owned test-reports/firmware-unit/ in the bind mount. `docker cp` chmods what it extracts into, cannot chmod that directory as the runner user, and fails outright -- copying nothing. When the suites were separate jobs, the integration job always started with a pristine test-reports/. Two fixes: * chown the bind mount back to the runner after the unit-test container exits, so the later docker cp can write into it. * Gate the extraction instead of warning about it. `|| echo WARN` let the job succeed while dropping every integration result: the artifacts uploaded empty and generate-test-report.py rendered a PDF reading "0 passed, 0 failed, 143 pending". The step now fails if junit.xml is missing or empty, if no screenshots were extracted, or if the container cannot be resolved. A pipeline that loses test evidence while reporting success is a worse outcome than the slow pipeline this branch set out to fix.
Five findings from the Copilot review on #474, all valid. * The mirror was not reaching the buildx builds (medium). `Pull base image` re-tags the GHCR mirror to the Docker Hub name so `FROM` resolves locally. That works for `docker build`, which reads the daemon image store, but buildx uses the docker-container driver and resolves FROM against a registry. Run 33046944618 confirms it: the buildx step logged `[auth] kktech/firmware:pull token for registry-1.docker.io`, so the python-keepkey images were still pulling from Docker Hub and the mirror bought nothing there. The resolved reference is now exported as RESOLVED_BASE and passed as a build-arg, with the Dockerfile taking `ARG BASE_IMAGE` ahead of its FROM. The default keeps plain `docker build` and local use unchanged. * Compose failure aborted before report extraction (high). `run:` steps execute under `bash -e`, so `docker compose up ...; PY_RC=$?` never reached the `$?` assignment on failure -- the step died and none of the extraction or evidence gating ran, losing the reports on exactly the runs where they matter. Now `|| PY_RC=$?`, which is exempt from -e. * A red unit suite suppressed the integration suite (medium). Consequence of merging what used to be two jobs: `make xunit` failing aborted the step and skipped everything after it. Both suites now always run; their exit codes are captured and the verdict is applied once, at the end, after every artifact is captured. * Mirror pull errors were being swallowed (low). Dropped `2>/dev/null`. The fallback keeps a mirror problem non-fatal, but hiding stderr made auth vs missing tag vs transient network indistinguishable. * Healthcheck comment stated a wrong bound (low). With timeout 3s, interval 1s and retries 40 the ceiling is roughly start_period + retries * (timeout + interval), not retries * interval. The comment claimed 40s.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
.github/workflows/ci.yml:213
- Severity: high. This step is intended to keep running after cppcheck returns non-zero so it can
cat cppcheck_report.txt(for annotations) and build the summary counts, but under GitHub Actions’ defaultbash -ea failingcppcheckcommand will terminate the step immediately. As written, the laterCPPCHECK_RC=$?/summary logic won’t run on the runs where cppcheck finds issues, reducing diagnostics. Capture the exit code via an||list (or temporarily disable-e) so the remainder of the step executes and can emit annotations + a clear error.
--inconclusive \
--force \
-j8 \
--inline-suppr \
--suppressions-list=.cppcheck-suppressions \
…sues Suppressed finding from the round-2 Copilot review on #474 -- flagged as "previously missed, in code that hasn't changed since the last review", and present on develop rather than introduced here. cppcheck runs with --error-exitcode=1, so it returns non-zero whenever it finds anything. `run:` steps execute under `bash -e`, so `cppcheck ...; CPPCHECK_RC=$?` terminated the step at the cppcheck line on exactly the runs that have findings. The `cat cppcheck_report.txt` that emits the ::warning file= annotations, the per-severity summary written to GITHUB_STEP_SUMMARY, and the explanatory ::error:: all never executed -- leaving a bare red step with no indication of what cppcheck actually found. Captured with `|| CPPCHECK_RC=$?` instead, which is exempt from -e. Third instance of this pattern in the file; the other two were fixed in 050d600.
|
Round 2 reported no new inline comments, but carried one suppressed finding in the review body (ci.yml:213, severity high, "previously missed"). Recording here since a suppressed finding has no thread to resolve. It was correct, and it is a third instance of the Fixed in 6e8647d with |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
.github/workflows/ci.yml:241
- Severity: medium. The cppcheck summary counts use
grep -c '\b...'butgrep(without-P) treats\bas a backspace escape, not a word boundary, so these counters will likely stay at 0 and the step summary becomes misleading. Match the actual::{severity}:tokens from the template using fixed-string grep (or add-P).
# 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)
.github/workflows/ci.yml:385
- Severity: medium. The unit-test extraction suppresses
cperrors and never verifies that any*.xmlactually made it intotest-reports/firmware-unit/. Ifmake xunitstops producing XML (or the glob doesn’t match), the job can still proceed and generate a misleading PDF/report with "0 tests". Consider gating on at least one copied XML (while still preserving the original test exit code).
-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" || UNIT_RC=$?
Suppressed finding from the round-3 Copilot review on #474 (ci.yml:385, severity medium). The unit-test container copies its XML with `cp -r unittests/*.xml ... 2>/dev/null`. If make xunit stops emitting XML, or the glob stops matching, that error is discarded and the job carries on to generate a report showing "0 tests" with nothing objecting. That is the same silent-evidence-loss mode the python side hit earlier on this branch, where a failed docker cp let a run go green having discarded 437 test results. Gated the same way: missing XML is an infrastructure failure distinct from a test failure, and it now fails at the point of loss rather than being discovered in a PDF.
|
Round 3: no new inline comments, but two more suppressed findings in the body. Recording both here since suppressed findings have no thread to resolve. One fixed, one declined with evidence. ci.yml:385 — unit XML extraction unverified (medium). Fixed in 21ed963. Correct, and it is the same silent-evidence-loss mode that already bit this branch: an earlier run went green having discarded 437 test results because a ci.yml:241 — The claim is that All five counters match, and the negative control confirms Worth noting the summary genuinely does read zero on this branch — but because cppcheck is clean here, not because the counting is broken. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
.github/workflows/mirror-base-image.yml:45
- Severity: low. This comment claims the image is mirrored under both the bare name (no tag) and its tag, but the script only tags/pushes a single reference (
DESTincludes the tag). That mismatch can confuse maintainers who expect an additional:latest/untagged push.
Recommendation: reword the comment to match what the script actually does (mirror the tagged image), or add a second tag/push if you truly want both.
# Mirror under the bare image name (no tag) plus its tag, so the
# destination reads ghcr.io/<owner>/firmware:<tag>.
.github/workflows/ci.yml:148
- Severity: low.
grep -v '.pb.'treats.as a regex wildcard, so it will exclude any path containing any character +pb+ any character (not just literal.pb.). That can unintentionally skip files from the formatting check.
Recommendation: use fixed-string matching (or escape the dots) to exclude only generated protobuf artifacts.
| grep -v generated | grep -v '.pb.' \
| xargs -P "$(nproc)" -I{} sh -c '
Two suppressed low-severity findings from the round-4 Copilot review on #474, both in code this PR touched but did not introduce. * ci.yml:148 -- `grep -v '.pb.'` treats the dots as wildcards, so it would exclude any <char>pb<char> path from the formatting check, not only generated protobuf output. Switched to `grep -vF`. Checked against the current tree first: of the 197 files the check scans, the filter currently excludes zero either way, so this is latent rather than active -- but a future foo.pb.c would diverge. * mirror-base-image.yml:45 -- the comment read as though two references were pushed (bare name and tag). Only one tagged reference is pushed. Reworded to say what the script does.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 5 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
.github/workflows/ci.yml:148
- Severity: medium. The
findexpression-name '*.c' -o -name '*.h'relies on the implicit-print, which (due to-oshort-circuiting) only prints the right-hand branch; this ends up skipping*.cfiles. Also,lib/transport/srcdoes not exist in this repo, solib/transport/*.ccurrently isn't checked at all (errors are just suppressed). Wrap the name tests in parentheses and includelib/transportso both C and header files are formatting-checked.
if ! find include/keepkey lib/firmware lib/board lib/transport/src \
-name '*.c' -o -name '*.h' 2>/dev/null \
| grep -v generated | grep -vF '.pb.' \
| xargs -P "$(nproc)" -I{} sh -c '
From the round-5 Copilot review on #474 (ci.yml:148). The finding made two claims; one holds and one does not. Holds: lib/transport/src has never existed in this repo. The transport sources sit directly in lib/transport/. Because find's stderr went to /dev/null the bad path contributed nothing and said nothing. Dropped, and stderr is no longer suppressed so a future bad path is loud rather than quietly narrowing coverage. Does not hold: the claim that `-name '*.c' -o -name '*.h'` skips .c files via -o short-circuiting. GNU find applies the implicit -print to the whole expression. Verified under GNU findutils 4.9.0 -- the current expression yields 77 .c and 120 .h. Parenthesised anyway so intent is explicit; the resulting file set is byte-identical at 77/120. The recommendation to add lib/transport to the check is NOT taken, because it would turn CI red. That directory holds the vendored nanopb runtime, and pb_decode.c does not satisfy this repo's .clang-format (verified with clang-format 20.1.8). Reformatting vendored upstream sources to house style would make future nanopb updates needlessly painful. Recorded as a comment so the exclusion reads as deliberate rather than accidental.
From the round-5 Copilot review on #474. Filed as low severity; the blast radius is larger than that once ci.yml consumes the mirror. The workflow mirrored any dispatched image reference and derived the destination from the input via ${SOURCE##*/}, which keeps only the last path segment. So dispatching anyone/firmware:v15 resolved to ghcr.io/<owner>/firmware:v15 -- the exact reference ci.yml now pulls and builds firmware from. Dispatch requires write access, but "a writer can typo" and "a writer can silently replace the base image every firmware build trusts" are different problems. Two changes: * The source must match ^kktech/firmware:[A-Za-z0-9._-]+$. Anything else is refused with an explanatory error. * The destination repository name is now fixed rather than derived from input; only the validated tag is taken from the dispatch. Verified against kktech/firmware:v15 and :v16 (accepted) and attacker/firmware:v15, ghcr.io/evil/firmware:v15, kktech/other:v15, and a shell-metacharacter tag (all refused).
|
Round 5 also carried a suppressed finding at ci.yml:148 with two claims. One holds, one does not, and its recommendation is deliberately not taken. Holds — Does not hold — the 77 Recommendation declined — adding Including it would fail the formatting gate immediately, and reformatting vendored upstream sources to house style makes future nanopb updates needlessly painful. The exclusion is now commented so it reads as deliberate rather than accidental. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
.github/workflows/ci.yml:945
- Severity: high.
publish-emulatorno longer depends onstatic-analysis, so a workflow_dispatch publish can push DockerHub images even when cppcheck fails (the overall run will be red, but publishing can still complete). If cppcheck is meant to “still fail the run”, it should also block publishing.
publish-emulator:
needs: [build-and-test, python-dylib-tests, build-arm-firmware]
if: >-
Suppressed high-severity finding from the round-6 Copilot review on #474, and a real regression introduced by this branch. Taking static-analysis out of the build gate was deliberate: a cppcheck finding does not mean the firmware is wrong to compile, and as the slowest Stage 1 job it was serialising the pipeline behind itself. But publish-emulator used to inherit that gate transitively, through build jobs that each depended on static-analysis. Rewriting its needs list onto the merged build-and-test dropped the dependency altogether, so a workflow_dispatch publish could push kktech/kkemu images to DockerHub from a commit with cppcheck red -- the overall run would be red, but the push would still complete. static-analysis is now listed explicitly on publish-emulator. The publish path is manual and already slow, so the gate costs nothing there, and the speed trade was only ever meant to apply to compiling.
|
Round 6 suppressed finding (ci.yml:945, severity high) — correct, and a real regression introduced by this branch. Fixed in c54bbcc. Taking
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (4)
Previously missed (3) — in code that hasn't changed since the last review.
.github/workflows/ci.yml:333
- Severity: medium. This workflow describes the GHCR mirror as optional (it falls back to Docker Hub), but the preceding
docker/login-actionstep will currently fail the entire job if GHCR login errors, preventing the fallback from running. Making the login non-fatal keeps the mirror truly optional.
This issue also appears on line 634 of the same file.
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
.github/workflows/ci.yml:645
- Severity: low. In build-arm-firmware this job sets up Buildx and exports RESOLVED_BASE (and includes a long comment about buildx behavior), but the job never uses buildx nor RESOLVED_BASE afterwards—only
docker pull/taganddocker run ${{ env.BASE_IMAGE }}. This adds a bit of overhead and makes the step comments misleading for this job; consider dropping Buildx + RESOLVED_BASE here and simplifying the Pull base image comment accordingly.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Pull base image
run: |
.github/workflows/ci.yml:814
- Severity: low. The protoc download is skipped when
/tmp/protoc.zipexists, but the cache can restore an empty or partial file;-fonly checks existence, so this can lead to an unzip failure with no retry download. Using-s(non-empty) makes the step resilient to a bad cache entry.
if [ ! -f /tmp/protoc.zip ]; then
curl -sSL -fL -o /tmp/protoc.zip \
"https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/${PROTOC_ASSET}"
.github/workflows/ci.yml:638
- Severity: medium. Same as build-and-test: GHCR login failing will currently fail the whole job before the mirror fallback can run. If the mirror is intended to be optional, the login step should be non-fatal.
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
…otoc cache Four suppressed findings from the round-7 Copilot review on #474. * GHCR login was fatal, defeating the fallback it exists to enable (medium, both build jobs). The mirror is optional by design -- ci.yml warns and falls back to Docker Hub when the pull fails -- but docker/login-action failing aborts the job before that fallback can run. A GHCR outage, a revoked package grant or a token hiccup would therefore take CI down despite the fallback. Marked continue-on-error; a failed login just means the mirror pull is unauthenticated, which takes the fallback path. * build-arm-firmware set up Buildx and exported RESOLVED_BASE, using neither (low). Introduced here: the pull-with-fallback step was applied to both build jobs identically, and buildx came along with it. That job runs the toolchain via docker run and needs only the daemon-local tag. Removed, along with the buildx-specific commentary that did not apply to it. * protoc download skipped on existence rather than content (low). An actions/cache entry can restore empty or truncated; -f would skip the download and hand unzip a bad file with no retry. Uses -s. The buildx/RESOLVED_BASE machinery remains in build-and-test, which does need it: buildx resolves FROM against a registry rather than the daemon.
|
Round 7 carried four suppressed findings, all valid, all fixed in 0f2a16c.
For the record on where this loop stands: rounds 2 through 7 have produced zero new inline comments — every finding has been |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
.github/workflows/ci.yml:536
- This workflow mostly uses actions/upload-artifact@v7, but this step still uses @v4. Mixing major versions can lead to subtle behavior differences; standardize on one version (v7 seems to be the intent here).
This issue also appears on line 583 of the same file.
- name: Upload OLED screenshots
uses: actions/upload-artifact@v4
if: always()
.github/workflows/ci.yml:585
- This step still uses actions/upload-artifact@v4 while the rest of the workflow uses @v7. Standardizing avoids inconsistent artifact handling across jobs/steps.
- name: Upload test report
uses: actions/upload-artifact@v4
if: always()
Suppressed findings from the round-8 Copilot review on #474. The workflow mixed @v4 and @v7 across steps -- in places within the same job -- which is pre-existing rather than introduced here. Both versions were demonstrably working side by side, so this is consistency rather than a fix, but there is no reason for one workflow to pin two majors of the same action. All five remaining @v4 uploads moved to @v7. download-artifact stays at @v8, which is its own current major.
Three conflicts, plus one semantic clash the auto-merge hid. .github/workflows/ci.yml -- took develop's pipeline (PR #474 cut it from ~7:20 to ~2:00 and folded check-submodules, build-emulator, unit-tests and python-integration-tests into build-and-test) and re-applied what this branch adds on top: - permissions: contents: read at the workflow level. develop has no permissions block, so code-scanning rule actions/missing-workflow-permissions had one alert open per job. - generate-test-report restored as its OWN job rather than folded into build-and-test. develop's fold is correct for develop's report script, which only needs directories that job already has. This branch's generate-test-report.py also consumes test-reports/dylib-junit.xml and the ARM manifest -- produced by other jobs, on other runners -- and fail()s hard when either is missing. Folded, the report could never succeed. - the report's provenance env (both source SHAs, run URL, both PR URLs). develop passes only KK_BUILD_LABEL, which this branch's script does not read; the fields it does read are release gates for 7.14.2. - the report artifact is a directory again. The script emits test-report/test-report.pdf beside test-report-manifest.json and test-report.pdf.sha256; develop uploaded a bare test-report.pdf, which would have found no files. - release-evidence-gate, rewired to develop's folded job names, so a release still has one required check that every evidence-producing job succeeded. .gitleaks.toml -- union. Kept develop's header and its docs-SHA allowlist, which is broader than and subsumes this branch's python-keepkey-pin rule, plus this branch's U2F/vendored-deps allowlist and the note recording why no first-party test tree is exempted. scripts/emulator/python-keepkey.Dockerfile -- took develop's parameterised deps-stage version wholesale. scripts/emulator/Dockerfile did NOT conflict: only this branch had changed it (base -> digest), so git kept the digest silently. That defeats develop's GHCR mirror, which tags the mirrored image as kktech/firmware:v15 -- a digest FROM ignores that tag and pulls from Docker Hub on the heaviest job in the run. Parameterised it the way python-keepkey.Dockerfile already is, defaulting to the digest so a release build still names an immutable base, with CI passing the resolved mirror. kktech/firmware:v15 resolves to exactly that digest today. Verified: emulator image builds both with the default and with an explicit --build-arg; 153/153 unit tests pass; gitleaks 8.30.1 finds no leaks with the merged config; clang-format clean.
Result
Measured, not projected. Baseline is run 33043154603 on develop; final is run 33046944618 on this branch.
Per job (final run):
lint-format8s ·secret-scan21s ·static-analysis94s ·build-and-test155s ·build-arm-firmware81s ·python-dylib-tests58s.The critical path used to be a single serial chain —
static-analysis 192s → build-emulator 175s → unit-tests 54s → report 9s. It is now justgate ~20s → build-and-test 155s, with cppcheck running alongside.Why there was so much to reclaim
Layer-level timings from the baseline build log — the firmware compile was never the expensive part:
pip install rlp eth-keys eth-utils pycryptodomeFROM kktech/firmware:v15(base pull)apk add python3-dev gcc musl-devmake -j— the actual firmware compileThe emulator image was compiled twice per run, an 845 MB tarball was moved between two runners to run a 1s suite (
unit-testsspent 49s on transfer for 1s of testing), and cppcheck ran single-threaded while every build job waited on it.What changed
Structural
build-emulator+unit-tests+python-integration-testsinto onebuild-and-test. Image built once, both suites run against it in place.generate-test-reportintobuild-and-test— every input was already on the runner.check-submodulesintolint-format— a 3s grep costing a full runner spin-up in theneedsfan-in of every downstream job.Ungating
static-analysisno longer gates builds. cppcheck is advisory; it still fails the run, it just no longer delays it.lint-formatandsecret-scanstay in the gate.Caching
type=ghacache on the python-keepkeydepsstage only. Cold 75s → warm 15s.base image: GHCR mirror.python-dylib-tests.Parallelism
--force -j8. Measured locally, identical findings:-j177s →-j431s.--forcekept deliberately — dropping it is ~5x faster again but stops the combinatorial#ifdefsweep.xargs -P, installed from PyPI rather than the LLVM apt repo.lint-format21s → 8s, and it now also does the submodule check.--jobs 4, with--recursivescoped topython-keepkeyonly —trezor-firmwaredeclares 9 nested submodules this build never uses.Waste
concurrencygroup cancels superseded PR runs; pushes to develop/master/release are never cancelled.docker compose down -vdropped; healthcheck interval 3s → 1s.Two bugs found and fixed in review of this branch's own CI runs
Both were introduced by this branch and caught by inspecting real runs. They are the reason for commits 3 and 4.
1. The layer cache made things slower. First attempt put
cache-to: mode=maxon the whole image..dockerignoreexcludes only**/.gitandbin, soCOPY ./ /kkemuships essentially the entire context — and it is invalidated by every commit, so it can never hit. The run spent 103.9s inexporting to GitHub Actions Cacheto save 36.8s of pip. Fixed by splitting the Dockerfile into adepsstage and caching that target alone.2. The pipeline went green while silently losing every integration test result. Run 33046518184 uploaded no python results and no screenshots. The tests were fine — 379 passed, 58 skipped, 310 PNGs — but
make xunitruns as root and leaves a root-ownedtest-reports/firmware-unit/;docker cpchmods what it extracts into, could not, and failed outright.|| echo "WARN: ..."swallowed it. When the suites were separate jobs the integration job always started with a pristinetest-reports/. Fixed by chowning the bind mount, and by gating extraction so lost evidence fails the run instead of warning.Verification
Artifacts from the final run, checked against baseline:
junit.xmljunit-screenshots.xmltest-report.pdfAlso: every job resolves an identical submodule set to develop (checked programmatically);
actionlintreports the same 5 pre-existing findings, zero new.For the reviewer
Two decisions worth your explicit attention:
paths-ignoreskips the workflow entirely on docs-only commits rather than reporting a neutral check. Safe today becausedevelopis unprotected andmaster's only required context isci/circleci: emulator-build-test— a job with no.circleci/config in this repo, so it can never report. If you want CI jobs to become required checks, this needs rethinking first, or docs PRs will become unmergeable.static-analysisno longer gates builds, so compute is spent before cppcheck's verdict is known. Deliberate — it was the pole.Not done, deliberately
KEEPKEY_SCREENSHOT=1instead of the 85-test SECTIONS filter, changing the 90-day screenshot artifact and the report input.--cppcheck-build-dircaching — ~85s → ~30s, but that job is off the critical path now, so it buys billed minutes in exchange for incremental-analysis cache risk on a security tool. Better as its own change.python-dylib-tests— no wall-clock gain, and the gate stops a lint-failing commit spending 10x-billed macOS minutes.