Skip to content

Hand images between jobs by digest, not by tag - #12

Merged
hacker-cb merged 2 commits into
masterfrom
ci-immutable-tags
Aug 3, 2026
Merged

Hand images between jobs by digest, not by tag#12
hacker-cb merged 2 commits into
masterfrom
ci-immutable-tags

Conversation

@hacker-cb

Copy link
Copy Markdown
Contributor

The manifest jobs assembled a multi-arch image out of tag names, looked up when
they ran rather than received from the build legs that produced them:

docker buildx imagetools create -t ...:latest -t ...:sha-9e27a4a \
  ghcr.io/.../esp-idf:idf-v5.4.1-linux-amd64 \    # resolved now, not then
  ghcr.io/.../esp-idf:idf-v5.4.1-linux-arm64

Two pushes to master overlapping — a routine shape, since esp-matter runs for
minutes after esp-idf finishes — and run A's manifest publishes what run B
overwrote, under sha-<commit-of-A>. The one tag that is supposed to mean this
exact commit
was the one that could lie.

Digest handoff

Build legs push by digest and carry no tag at all:

outputs: type=image,name=…,push-by-digest=true,name-canonical=true,push=${{ github.ref_name == 'master' }}

The digests travel to the manifest job as one empty file per leg, named after the
digest, through an artifact — matrix legs cannot each set a job output, they
overwrite one another. The manifest is then built from @sha256: references, so
nothing in between is a name something else could rebind.

The -linux-<arch> tags disappear from GHCR with this. They existed only as this
handoff and were already documented as "not intended for direct use"; the tag
tables in the three image READMEs drop that row.

esp-matter builds on a pinned base

esp-idf-manifest exports its own digest as a job output, and esp-matter-build
consumes it:

build-args: |
  BASE_IMAGE=ghcr.io/…/jethome-dev-esp-idf@${{ needs.esp-idf-manifest.outputs.digest }}

Before this, an esp-matter build could be sitting on a base that a later run had
already replaced, and the two halves of one multi-arch latest could come from
different bases.

That turns ARG BASE_IMAGE_TAG into a single ARG BASE_IMAGE carrying the whole
reference, because a digest needs @ where a tag needs :. It also stops
hardcoding the owner in FROM, so a fork — or a local build — can point at its own
esp-idf:

./scripts/build.sh esp-idf
docker build --build-arg BASE_IMAGE=jethome-dev-esp-idf:local images/esp-matter

Concurrency

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

Queue on master, cancel on pull requests. Cancelling a master run would leave
images pushed by digest with no manifest pointing at them.

Verification

  • ./scripts/lint.sh green — and it caught SC2046 in the first draft of the
    manifest step, where the digest list relied on word splitting; it is an explicit
    array now
  • ARG BASE_IMAGE proven both ways locally: the Dockerfile default resolves, and
    --build-arg BASE_IMAGE=jethome-nonexistent-base:proof makes Docker resolve
    metadata for exactly that reference, so the argument does reach FROM
  • Publishing itself is master-only, so the digest handoff runs for real on merge —
    the PR checks exercise the build legs with push: false

Reviewed by

codex-review (xhigh) — no findings; it confirms the handoff follows the standard
Buildx multi-platform pattern.

The code-review workflow (high) — ten defects, six confirmed, all applied:

  • concurrency did not queue. cancel-in-progress: false is not a queue —
    GitHub cancels a pending run in a group whenever a newer one arrives, whatever
    that flag says. Grouping master pushes by ref would have dropped the middle
    commit of any three landing inside one build window, and its sha-<commit>
    image. The key now carries github.sha on a push, so every commit has its own
    group; pull requests still group by ref and cancel.
  • imagetools inspect | jq had no pipefail. jq exits 0 on empty input, so
    a transient GHCR failure would have produced an empty digest, a green manifest
    job, and a failure surfacing much later inside esp-matter-build as invalid reference format, pointing nowhere near the job that broke.
  • The manifest jobs globbed whatever digests were present. With
    fail-fast: false, one failed leg leaves a single file — and would publish a
    single-platform image under latest. Each manifest job now asserts one digest
    per platform first.
  • Two traps a second matrix version would have sprung — exactly what PR-5 adds.
    The base digest travelled as a scalar output from a matrix job (the overwrite
    hazard the build job's own comment warns about), and artifact names omitted the
    version, so two versions would collide under a version-agnostic download
    pattern. Both fixed, and the fix binds base to tag: a half-done bump now fails on
    a missing artifact instead of publishing idf-v<old> built on v<new>.
  • Documentation this branch had falsified: CLAUDE.md still described
    esp-matter as FROM a hardcoded tag, still explained a half-bumped version
    through tags that no longer exist, and still named push: as a top-level key.

The manifest jobs assembled a multi-arch image out of tag *names*, which they
looked up when they ran rather than receiving from the build legs that produced
them. Two pushes to master overlapping - now a routine shape, since esp-matter
runs for minutes after esp-idf finishes - and the manifest of run A publishes what
run B overwrote, under `sha-<commit-of-A>`. The one tag that is supposed to mean
"this exact commit" was the one that could lie.

Build legs now push by digest and carry no tag at all. Digests reach the manifest
job as one empty file per leg, named after the digest, through an artifact - matrix
legs cannot each set a job output, they overwrite one another - and the manifest is
assembled from `<image>@sha256:<digest>` references. Nothing in between is a name
that something else could rebind. The `-linux-<arch>` tags disappear from GHCR with
this; they were documented as "not intended for direct use" and existed only as
this handoff.

esp-idf-manifest now exports its own digest, and esp-matter-build builds FROM that
digest instead of `idf-<version>`. Before this, a long esp-matter build could be
sitting on a base that a later run had already replaced, and the two halves of one
multi-arch `latest` could come from different bases. That moves the Dockerfile from
`ARG BASE_IMAGE_TAG` to a single `ARG BASE_IMAGE` carrying the whole reference,
because a digest needs `@` where a tag needs `:` - and it makes the base
repository overridable, so a fork or a local build can point at its own esp-idf
rather than this one. Verified both ways locally: the default resolves, and
--build-arg overrides what FROM sees.

Both workflows also gain a concurrency group, queuing on master rather than
cancelling: a cancelled run leaves images pushed by digest with no manifest
pointing at them. Pull requests do cancel - there is nothing to leave behind, and
superseded runs are not worth paying for.
…ture

The code-review workflow found ten defects, six confirmed. Three were mine to fix
in the mechanism, three in documentation this same branch had made false.

concurrency did not do what its comment said. `cancel-in-progress: false` is not a
queue: GitHub cancels a *pending* run in a group whenever a newer one arrives,
regardless of that flag - confirmed against the workflow-syntax reference. Grouping
master pushes by ref would silently drop the middle commit of any three landing
inside one build window, and its sha-<commit> image, which the READMEs document as
the way to pin an exact commit, would never exist. The key now carries github.sha
on a push, so every commit has its own group and cancels nothing; pull requests
still group by ref and cancel, where there is nothing published to lose.

The digest capture ran `imagetools inspect | jq` with no pipefail. jq exits 0 on
empty input, so a transient GHCR failure would have produced an empty digest, a
green manifest job, and a failure surfacing 180 minutes later inside
esp-matter-build as `invalid reference format` - pointing nowhere near the job that
actually broke. Now under `set -euo pipefail`, with `jq -e` and an explicit
sha256: check.

The manifest jobs globbed whatever digest files were present. With fail-fast:
false, one failed leg would leave a single file and publish a single-platform image
under latest and the version tag. Each manifest job now asserts it got one digest
per platform first.

Two latent traps that a second version in the matrix would have sprung, which is
exactly what PR-5 adds: the esp-idf manifest digest travelled as a scalar job
output from a matrix job - the overwrite hazard the build job's own comment warns
about - and the digest artifact names omitted the version, so two versions would
collide under a version-agnostic download pattern. The digest is now an artifact
named after its version, and esp-matter-build downloads the one matching the
ESP-IDF version in its own tag. That also binds base to tag: a half-done bump now
fails on a missing artifact instead of publishing an idf-v<old> tag built on
v<new>.

Documentation: CLAUDE.md still described esp-matter as FROM a hardcoded
jethome-iot tag, still explained a half-bumped version through tags that no longer
exist, still named `push:` as a top-level key after it moved into the outputs CSV,
and both the workflow header and the esp-matter timeout rationale still reasoned
about platform tags this branch deleted.
Copilot AI review requested due to automatic review settings August 3, 2026 14:38

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 hardens the Docker image publishing workflows by handing off build outputs between matrix legs and manifest jobs via immutable image digests (artifacts), instead of mutable tag names that can be overwritten by overlapping runs.

Changes:

  • Update esp-idf.yml and platformio.yml to push per-platform images by digest (no intermediate tags), upload those digests as artifacts, and assemble multi-arch manifests from @sha256: references.
  • Pin esp-matter builds to the exact ESP-IDF base image produced in the same run by passing a full BASE_IMAGE reference (including digest) into FROM.
  • Remove documentation of *-linux-<arch> tags from image READMEs and update repo guidance to reflect digest-based handoff and revised build args.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
.github/workflows/esp-idf.yml Switch ESP-IDF + ESP-Matter workflows to digest artifact handoff, manifest assembly by digest, and base digest propagation to ESP-Matter.
.github/workflows/platformio.yml Switch PlatformIO workflow to digest artifact handoff and manifest assembly by digest.
images/esp-matter/Dockerfile Replace tag-shaped base arg with full BASE_IMAGE reference used directly in FROM.
images/esp-matter/README.md Document BASE_IMAGE build arg and add local-build-on-local-base example; drop platform-specific tag docs.
images/esp-idf/README.md Drop platform-specific tag docs now that per-arch tags are no longer published.
images/platformio/README.md Drop platform-specific tag docs now that per-arch tags are no longer published.
CLAUDE.md Update repo CI guidance to match digest-based workflow behavior and new base pinning approach.

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

Comment thread .github/workflows/esp-idf.yml
@hacker-cb
hacker-cb merged commit 22fdfa2 into master Aug 3, 2026
10 checks passed
@hacker-cb
hacker-cb deleted the ci-immutable-tags branch August 3, 2026 14:44
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