Skip to content

Build each platform natively and drop the layer cache - #9

Merged
hacker-cb merged 2 commits into
masterfrom
ci-larger-runners
Aug 3, 2026
Merged

Build each platform natively and drop the layer cache#9
hacker-cb merged 2 commits into
masterfrom
ci-larger-runners

Conversation

@hacker-cb

Copy link
Copy Markdown
Contributor

The measured problem was never runner power — it was emulation, and a cache that
cost 21.6 minutes per run to produce zero hits.

Native builds

Every build job now picks its runner by platform through a runner key in the
matrix include:, so linux/arm64 builds on arm64 hardware instead of QEMU on an
x86 host.

esp-matter leg before after (expected)
linux/amd64 24 min 12–20 min
linux/arm64 5 h 37 min (QEMU) 15–25 min

The emulated leg was a 14× penalty and it was growing — 5 h 01 min in November
2025, 5 h 37 min in July 2026, on the same Matter 1.4.2 — against a
timeout-minutes: 360 ceiling it was 23 minutes short of hitting.

Pool size is chosen for disk, not cores. connectedhomeip needs ~50 GB, and
runner-smoke.yml measured 372 GB free under Docker on ubuntu-latest-8core
and 393 GB on ubuntu-latest-8core-arm, against 88 GB on 4-core.

esp-matter-build's ceiling goes to 180 rather than something tighter: the only
native esp-matter figure that exists is the amd64 one, and killing the arm64 leg
fails quietly — the manifest job is skipped with it while the amd64 platform tag
has already been overwritten, leaving GHCR with a platform tag newer than the
manifest pointing at it. Lower it once two runs have reported real numbers.

Manifest jobs stay on ubuntu-latest. They are seconds of GHCR calls, so a pool
buys nothing, and they are the only jobs that publish a multi-arch tag — pointing
them at a pool would make tag publication hostage to that pool still existing and
still being granted to this repository.

This also retires the self-hosted incus runners from the workflows. They are 4
cores and 7.3 GiB RAM — weaker than a free ubuntu-latest — and both of their
legs ran on x86, so they never provided native arm64 at all. Their registration is
left alone for now; an idle self-hosted runner costs nothing and is a fallback
until this has run on master a couple of times.

No layer cache

cache-from/cache-to are gone from all six build steps. Measured across every
master run:

  • zero cache hits (grep -c CACHED = 0 in six checked jobs)
  • 21.6 minutes per run spent writing the cache — for esp-idf, 390 s of export
    on a 72 s build
  • the repository sat at 9.09 GB of a 10 GB quota, because mode=max wrote
    ~3.9 GiB per leg, so LRU evicted entries during the run that created them
    esp-idf-linux-arm64 was left with an index whose every blob was already gone,
    which imports successfully and yields nothing

Green 18-second cache hits on PR legs were an artefact of push: false: CACHED
is decided on the manifest and no blob is ever materialised, so the PR said the
cache worked while master got nothing.

A registry-backed cache in GHCR is the sensible replacement, but it cannot be
measured yet: esp-matter builds FROM esp-idf, and any esp-idf rebuild
invalidates every layer below it — so that goes after the workflows are split.

Forks run nothing

The build jobs kept a workflow_dispatch escape hatch while moving to pools that
exist only in jethome-iot. A fork following the documented path would have got a
24-hour queue rather than a build — the exact failure mode this series exists to
prevent, and one we hit for real on the first probe run. Forks now build with
./scripts/build.sh.

That also collapses the publish gates from three overlapping expressions into two
questions asked once each: who (the job if, owner only) and where (login
and push:, master only).

Verification

  • ./scripts/lint.sh green throughout
  • ./scripts/build.sh esp-idf and ./scripts/build.sh platformio built locally,
    verification layers reporting ESP-IDF v5.4.1 and jq-1.7
  • Runner pools measured for real: run 30812977363, all ten legs green

What a PR cannot check here: esp-matter-build needs esp-idf-manifest, which is
master-only, so the first native esp-matter build happens after this merges. That
gap is what PR-4 closes.

Reviewed by

codex-review (xhigh) and the code-review workflow (high) — eight defects, all
applied or recorded. The two that changed the design are above (forks, and the
manifest jobs staying on ubuntu-latest). One is recorded rather than fixed: a
platform: value with no matching include: entry leaves runs-on empty and the
leg silently never starts, and actionlint passes it — verified against the image
lint.yml runs. PR-3's generated matrices make that checkable.

Two changes, both measured rather than assumed.

Runners. Every build job moves to a larger-runner pool picked by platform through
a `runner` key in the matrix include, so linux/arm64 builds on arm64 hardware
instead of QEMU on an x86 host. The emulated esp-matter leg took 5 h 37 min
against 24 min for the native amd64 one - a 14x penalty, and it had grown 36
minutes in the eight months since the previous measurement, against a
`timeout-minutes: 360` ceiling it was 23 minutes short of hitting. Native builds
land around 15-25 min, so that ceiling drops to 120: wide enough to be a cap on a
wedged job rather than a target.

The pool size is chosen for disk, not cores. connectedhomeip needs ~50 GB, and
`runner-smoke.yml` measured 372 GB free under Docker on ubuntu-latest-8core and
393 GB on ubuntu-latest-8core-arm, against 88 GB on 4-core. Manifest jobs go to
ubuntu-latest-4core - they only call GHCR.

This also retires the self-hosted incus runners from the workflows. They are 4
cores and 7.3 GiB RAM, weaker than a free ubuntu-latest, and both of their legs
ran on x86 - so they never provided native arm64, which is the entire point here.

Cache. cache-from/cache-to are removed from all six build steps. Measured across
every master run: zero cache hits, against 21.6 minutes per run spent writing the
cache - for esp-idf, 390 s of export on a 72 s build. mode=max wrote ~3.9 GiB per
leg into a repository-wide 10 GB quota, so LRU evicted entries during the run that
created them; esp-idf-linux-arm64 was left with an index whose every blob was
already gone, which imports successfully and yields nothing. Green 18-second PR
legs were an artefact of push: false, where CACHED is decided on the manifest and
no blob is ever materialised.

The Dockerfile SHELL comments said bash was needed for QEMU compatibility. Nothing
is emulated now, and the real reason - these layers call the bash builtin `source`
- always was the one that mattered.
…docs

codex-review and the code-review workflow found eight defects between them.

Forks. The build jobs kept a `workflow_dispatch` escape hatch while moving to
pools that exist only in jethome-iot, so the documented fork path would have
queued for 24 hours instead of building - the exact failure this series is meant
to prevent, and one we hit for real on the first probe run. Forks now run nothing:
the job gate is the owner alone, and a fork builds with ./scripts/build.sh. That
also collapses the publish gates from three overlapping expressions to two
questions asked once each - who (the job `if`) and where (login and `push:`).

esp-matter timeout. 120 was extrapolated from the amd64 measurement; the arm64 leg
has never run outside QEMU, so its per-core speed on connectedhomeip is unknown.
Set to 180 until two runs report real numbers, because getting it wrong downward
fails quietly: the leg is killed, the manifest job is skipped with it, and the
amd64 platform tag has already been overwritten - leaving GHCR with a platform tag
newer than the manifest pointing at it.

Manifest jobs stay on ubuntu-latest rather than moving to a pool. They are seconds
of GHCR calls, so a pool buys nothing, and they are the only jobs that publish a
multi-arch tag: pointing them at a pool makes tag publication hostage to that pool
existing and still being granted to this repository.

Documentation that this series had made false. CLAUDE.md still justified the
mandatory verification RUN layer by emulation no-ops three lines after stating
that nothing is emulated, and still quoted a Dockerfile comment this branch had
already rewritten. The layer's real justification is broader than QEMU - a stale
mirror or an installer exiting 0 on a partial install produce the same silently
green build - and it is now written that way.

Also recorded, because nothing checks it: a `platform:` value with no matching
`include:` entry leaves matrix.runner empty, `runs-on` evaluates to the empty
string, and the leg never starts. Verified that actionlint - the image lint.yml
runs - passes that edit; it catches a wrong label, not a missing key. PR-3's
generated matrices are what will make this checkable.
Copilot AI review requested due to automatic review settings August 3, 2026 13:25

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

Updates the Docker image build workflows to run linux/amd64 and linux/arm64 builds on native runner pools (no QEMU) and removes the GitHub Actions layer cache configuration, with accompanying documentation updates.

Changes:

  • Switch ESP-IDF/ESP-Matter and PlatformIO build jobs to runs-on: ${{ matrix.runner }} with per-platform runner selection.
  • Remove cache-from / cache-to from docker/build-push-action steps to stop exporting an ineffective GHA cache.
  • Update README/CLAUDE and Dockerfile comments to reflect the new non-emulated, owner-only workflow behavior.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
README.md Documents that forks won’t run Actions jobs and directs fork users to ./scripts/build.sh.
images/esp-idf/Dockerfile Clarifies why bash is required for RUN layers (source), not QEMU.
images/esp-matter/Dockerfile Same clarification for bash SHELL rationale.
CLAUDE.md Updates CI guidance to match new runner-pool + no-cache behavior and workflow gating.
.github/workflows/esp-idf.yml Moves ESP-IDF + ESP-Matter builds to native runner pools and removes cache config (but matrix include structure needs correction).
.github/workflows/platformio.yml Moves PlatformIO builds to native runner pools and removes cache config (but matrix include structure needs correction).
Suppressed comments (1)

.github/workflows/esp-idf.yml:170

  • Same matrix issue as esp-idf-build: the include entries don’t specify both axes (version + platform), so the build legs can end up with missing runner / jethome_idf_base_tag / esp_matter_version values. That can break runs-on, tag generation, and the build args passed to the Dockerfile.
      matrix:
        version: ['idf-v5.4.1-matter-v1.4.2']
        platform: ['linux/amd64', 'linux/arm64']
        include:
          - version: 'idf-v5.4.1-matter-v1.4.2'

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread .github/workflows/esp-idf.yml
Comment thread .github/workflows/platformio.yml
@hacker-cb
hacker-cb merged commit 9e27a4a into master Aug 3, 2026
10 checks passed
@hacker-cb
hacker-cb deleted the ci-larger-runners branch August 3, 2026 13:30
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