Skip to content

perf(ci): cut pipeline wall clock from ~7:20 to ~3:00 - #474

Merged
pastaghost merged 13 commits into
developfrom
perf/ci-pipeline-speed
Aug 27, 2026
Merged

perf(ci): cut pipeline wall clock from ~7:20 to ~3:00#474
pastaghost merged 13 commits into
developfrom
perf/ci-pipeline-speed

Conversation

@pastaghost

@pastaghost pastaghost commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Result

Measured, not projected. Baseline is run 33043154603 on develop; final is run 33046944618 on this branch.

baseline this branch
total wall clock 440s 183s
jobs 11 7
emulator compiles per run 2 1
845 MB artifact round-trip every run publish-dispatch only

Per job (final run): lint-format 8s · secret-scan 21s · static-analysis 94s · build-and-test 155s · build-arm-firmware 81s · python-dylib-tests 58s.

The critical path used to be a single serial chain — static-analysis 192s → build-emulator 175s → unit-tests 54s → report 9s. It is now just gate ~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:

layer time
pip install rlp eth-keys eth-utils pycryptodome 36.8s
FROM kktech/firmware:v15 (base pull) 34.4s
apk add python3-dev gcc musl-dev ~8s
make -j — the actual firmware compile 9.6s

The emulator image was compiled twice per run, an 845 MB tarball was moved between two runners to run a 1s suite (unit-tests spent 49s on transfer for 1s of testing), and cppcheck ran single-threaded while every build job waited on it.

What changed

Structural

  • Merge build-emulator + unit-tests + python-integration-tests into one build-and-test. Image built once, both suites run against it in place.
  • Fold generate-test-report into build-and-test — every input was already on the runner.
  • Fold check-submodules into lint-format — a 3s grep costing a full runner spin-up in the needs fan-in of every downstream job.

Ungating

  • static-analysis no longer gates builds. cppcheck is advisory; it still fails the run, it just no longer delays it. lint-format and secret-scan stay in the gate.

Caching

  • type=gha cache on the python-keepkey deps stage only. Cold 75s → warm 15s.
  • Base image mirrored to GHCR with Docker Hub fallback. Final run logs base image: GHCR mirror.
  • Base-image tar cache deleted — it was ref-scoped, 13 copies held 8.4 GB of the 10 GB quota and evicted each other. Those caches have been purged (repo now 2 caches / 19 MB).
  • pip + protoc caching in python-dylib-tests.

Parallelism

  • cppcheck --force -j8. Measured locally, identical findings: -j1 77s → -j4 31s. --force kept deliberately — dropping it is ~5x faster again but stops the combinatorial #ifdef sweep.
  • clang-format via xargs -P, installed from PyPI rather than the LLVM apt repo. lint-format 21s → 8s, and it now also does the submodule check.
  • Submodule init --jobs 4, with --recursive scoped to python-keepkey only — trezor-firmware declares 9 nested submodules this build never uses.

Waste

  • concurrency group cancels superseded PR runs; pushes to develop/master/release are never cancelled.
  • Docs-only commits skip the workflow.
  • docker compose down -v dropped; 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=max on the whole image. .dockerignore excludes only **/.git and bin, so COPY ./ /kkemu ships essentially the entire context — and it is invalidated by every commit, so it can never hit. The run spent 103.9s in exporting to GitHub Actions Cache to save 36.8s of pip. Fixed by splitting the Dockerfile into a deps stage 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 xunit runs as root and leaves a root-owned test-reports/firmware-unit/; docker cp chmods 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 pristine test-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:

artifact this branch baseline
junit.xml 437 tests / 58 skipped / 0 failures 437 / 58
junit-screenshots.xml 85 / 8 skipped 85 / 8
screenshot PNGs 310 ~310
test-report.pdf present present
dylib junit 4 / 0 failures 4

Also: every job resolves an identical submodule set to develop (checked programmatically); actionlint reports the same 5 pre-existing findings, zero new.

For the reviewer

Two decisions worth your explicit attention:

  1. paths-ignore skips the workflow entirely on docs-only commits rather than reporting a neutral check. Safe today because develop is unprotected and master's only required context is ci/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.
  2. static-analysis no longer gates builds, so compute is spent before cppcheck's verdict is known. Deliberate — it was the pole.

Not done, deliberately

  • Collapsing the two pytest phases — Phase 1 costs 3.3s, and merging it would run all 437 tests under KEEPKEY_SCREENSHOT=1 instead of the 85-test SECTIONS filter, changing the 90-day screenshot artifact and the report input.
  • cppcheck --cppcheck-build-dir caching — ~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.
  • Ungating python-dylib-tests — no wall-clock gain, and the gate stops a lint-failing commit spending 10x-billed macOS minutes.

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.
@pastaghost
pastaghost requested a review from markrypt0 as a code owner August 27, 2026 06:28
Copilot AI lite review requested due to automatic review settings August 27, 2026 06:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-test job 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-image workflow 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.

Comment thread .github/workflows/ci.yml Outdated
Comment thread .github/workflows/ci.yml
Comment thread .github/workflows/ci.yml Outdated
Comment thread scripts/emulator/docker-compose.yml Outdated
Comment thread .github/workflows/ci.yml
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.
@pastaghost pastaghost changed the title perf(ci): cut pipeline wall clock from ~7:20 to ~2:15 perf(ci): cut pipeline wall clock from ~7:20 to ~3:00 Aug 27, 2026
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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’ default bash -e a failing cppcheck command will terminate the step immediately. As written, the later CPPCHECK_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.
@pastaghost

Copy link
Copy Markdown
Collaborator Author

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 bash -e pattern the other two round-1 findings covered. 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 ran — leaving a bare red step with no indication of what cppcheck found.

Fixed in 6e8647d with || CPPCHECK_RC=$?. Note this one is pre-existing on develop rather than introduced by this PR; picked up here because the step was already being modified to add -j8.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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...' but grep (without -P) treats \b as 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 cp errors and never verifies that any *.xml actually made it into test-reports/firmware-unit/. If make xunit stops 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.
@pastaghost

Copy link
Copy Markdown
Collaborator Author

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 docker cp failed into || echo WARN. The unit side had the same shape — cp -r unittests/*.xml ... 2>/dev/null discards the error, so a build that stops emitting XML would produce a report reading "0 tests" with nothing objecting. Now gated: missing XML is treated as an infrastructure failure distinct from a test failure, and fails at the point of loss.

ci.yml:241 — grep -c '\berror:' counters (medium). Declined; the premise does not hold.

The claim is that grep without -P treats \b as a backspace escape rather than a word boundary, so the counters stay at 0. That is true of some grep implementations, but \b is a GNU extension supported in BRE, and ubuntu-latest runs GNU grep. Verified in a Linux container against the actual annotation format rather than reasoned about:

$ docker run --rm ubuntu:24.04 ...
grep (GNU grep) 3.11
--- \b word-boundary counts (GNU grep, BRE) ---
  error        1
  warning      1
  style        1
  performance  1
  portability  1
--- sanity: does \b actually anchor? ---
  matches in "xerror: nope": 0

All five counters match, and the negative control confirms \b is anchoring rather than being taken literally. The counters work as written; changing them would be churn against a false premise.

Worth noting the summary genuinely does read zero on this branch — but because cppcheck is clean here, not because the counting is broken.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (DEST includes 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 find expression -name '*.c' -o -name '*.h' relies on the implicit -print, which (due to -o short-circuiting) only prints the right-hand branch; this ends up skipping *.c files. Also, lib/transport/src does not exist in this repo, so lib/transport/*.c currently isn't checked at all (errors are just suppressed). Wrap the name tests in parentheses and include lib/transport so 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 '

Comment thread .github/workflows/mirror-base-image.yml
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).
@pastaghost

Copy link
Copy Markdown
Collaborator Author

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 — lib/transport/src does not exist. 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 in f37e513, and stderr is no longer suppressed so a future bad path is loud rather than quietly narrowing coverage.

Does not hold — the -o short-circuit claim. The finding says the implicit -print only applies to the right-hand branch, skipping *.c files. GNU find applies it to the whole expression. Verified under the same findutils CI uses:

find (GNU findutils) 4.9.0
current expression:      77 c    120 h

77 .c files were being checked all along. Parenthesised anyway so the intent is explicit to a reader; the resulting file set is identical at 77/120.

Recommendation declined — adding lib/transport would turn CI red. That directory holds the vendored nanopb runtime. Checked against this repo's .clang-format with clang-format 20.1.8:

  CLEAN  lib/transport/pb_common.c
  DIRTY  lib/transport/pb_decode.c
  CLEAN  lib/transport/pb_encode.c

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-emulator no longer depends on static-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.
@pastaghost

Copy link
Copy Markdown
Collaborator Author

Round 6 suppressed finding (ci.yml:945, severity high) — correct, and a real regression introduced by this branch. Fixed in c54bbcc.

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 everything behind itself. But publish-emulator used to inherit that gate transitively, through build jobs that each depended on static-analysis. Rewriting its needs 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 run would be red; the push would still complete.

static-analysis is now listed explicitly on publish-emulator. That path is manual and already slow, so the gate costs nothing there — the speed trade was only ever meant to apply to compiling, not to publishing artifacts other things consume.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-action step 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/tag and docker 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.zip exists, but the cache can restore an empty or partial file; -f only 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.
@pastaghost

Copy link
Copy Markdown
Collaborator Author

Round 7 carried four suppressed findings, all valid, all fixed in 0f2a16c.

  • GHCR login was fatal (medium, both build jobs). The sharpest of the four. The mirror is optional by design — CI warns and falls back to Docker Hub — but docker/login-action failing aborts the job before the fallback it exists to enable can run. A GHCR outage, a revoked package grant, or a token hiccup would have taken CI down despite the fallback being right there. Now continue-on-error: true; 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 by this PR and my own sloppiness — the pull-with-fallback step was applied to both build jobs identically and buildx came along for the ride. That job runs the toolchain via docker run and needs only the daemon-local tag. Removed, along with the buildx commentary that did not apply to it.
  • protoc cache checked existence, not content (low). A cache entry can restore empty or truncated; -f would skip the download and hand unzip a bad file with no retry. Now -s.

For the record on where this loop stands: rounds 2 through 7 have produced zero new inline comments — every finding has been previously missed, in code unchanged since the prior round. That pattern is a reviewer surfacing pre-existing issues incrementally rather than a fix loop churning, and it is worth knowing the review effort level here is Lite.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated no new comments.

@pastaghost
pastaghost merged commit fc1e937 into develop Aug 27, 2026
8 checks passed
@pastaghost
pastaghost deleted the perf/ci-pipeline-speed branch August 27, 2026 11:35
pastaghost added a commit that referenced this pull request Aug 27, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants