diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index 7d3e77826..cd034ec04 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -47,10 +47,15 @@ jobs: # `buildcache-*` registry cache tags written by --cache-backend= # registry. `contents: read` is the implicit default but pinned # explicitly so a future workflow edit can't silently lose - # checkout access. + # checkout access. `id-token: write` + `attestations: write` let + # the keyless build-provenance step below mint Sigstore-backed + # attestations for the pushed images (same OIDC model the SBOM + # workflow already uses). permissions: contents: read packages: write + id-token: write + attestations: write strategy: # Don't cancel sibling jobs on the first failure: any platform that # has already finished building its images will have pushed the @@ -150,6 +155,94 @@ jobs: --service=redis \ ${{ github.event_name == 'push' && '--push --skip-latest-tag' || '' }} + # Sign the freshly-pushed images with keyless build-provenance + # attestations (Sigstore via GitHub OIDC — the same mechanism the + # SBOM workflow already uses). Done here, per board, because this + # is where the immutable - images are pushed + # and their digests are known. The floating latest- tags + # added later by publish-latest re-point at these same manifests + # (identical digest), so they inherit the attestation for free. + # + # Attestation is by digest, and GitHub's attestation store is keyed + # on (repository, digest) rather than on the container registry, so + # a single attestation makes the image verifiable on BOTH registries: + # gh attestation verify \ + # oci://ghcr.io/screenly/anthias-server:- \ + # --repo ${{ github.repository }} + # gh attestation verify \ + # oci://docker.io/screenly/anthias-server:- \ + # --repo ${{ github.repository }} + # We additionally push an in-registry copy (push-to-registry) to + # each registry so registry-native / offline tooling can discover + # the attestation without the GitHub API. GHCR is hard-fail; the + # Docker Hub copy is best-effort (continue-on-error) because its + # referrers API periodically 429s under the free-tier pull limit — + # the same reason publish-latest soft-fails its Docker Hub mirror. + - name: Resolve pushed image digests + if: success() && github.event_name == 'push' + id: digests + run: | + set -euo pipefail + short_hash="$(git rev-parse --short=7 HEAD)" + for service in server viewer redis; do + ref="ghcr.io/screenly/anthias-${service}:${short_hash}-${{ matrix.board }}" + digest="$(docker buildx imagetools inspect "${ref}" \ + --format '{{ .Manifest.Digest }}')" + echo "${service}=${digest}" >> "${GITHUB_OUTPUT}" + echo "Resolved ${ref} -> ${digest}" + done + + - name: Attest anthias-server provenance (GHCR) + if: success() && github.event_name == 'push' + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4 + with: + subject-name: ghcr.io/screenly/anthias-server + subject-digest: ${{ steps.digests.outputs.server }} + push-to-registry: true + + - name: Attest anthias-viewer provenance (GHCR) + if: success() && github.event_name == 'push' + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4 + with: + subject-name: ghcr.io/screenly/anthias-viewer + subject-digest: ${{ steps.digests.outputs.viewer }} + push-to-registry: true + + - name: Attest anthias-redis provenance (GHCR) + if: success() && github.event_name == 'push' + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4 + with: + subject-name: ghcr.io/screenly/anthias-redis + subject-digest: ${{ steps.digests.outputs.redis }} + push-to-registry: true + + - name: Attest anthias-server provenance (Docker Hub) + if: success() && github.event_name == 'push' + continue-on-error: true + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4 + with: + subject-name: docker.io/screenly/anthias-server + subject-digest: ${{ steps.digests.outputs.server }} + push-to-registry: true + + - name: Attest anthias-viewer provenance (Docker Hub) + if: success() && github.event_name == 'push' + continue-on-error: true + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4 + with: + subject-name: docker.io/screenly/anthias-viewer + subject-digest: ${{ steps.digests.outputs.viewer }} + push-to-registry: true + + - name: Attest anthias-redis provenance (Docker Hub) + if: success() && github.event_name == 'push' + continue-on-error: true + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4 + with: + subject-name: docker.io/screenly/anthias-redis + subject-digest: ${{ steps.digests.outputs.redis }} + push-to-registry: true + # Mirror the immutable - tags pushed by the buildx # matrix onto the floating latest- tag. Runs only after every # buildx job has succeeded, so latest-* is never advanced from a diff --git a/docker/Dockerfile.qt5-webview-builder.j2 b/docker/Dockerfile.qt5-webview-builder.j2 index 85e401603..4f303fba3 100644 --- a/docker/Dockerfile.qt5-webview-builder.j2 +++ b/docker/Dockerfile.qt5-webview-builder.j2 @@ -6,16 +6,29 @@ 1. qt5-sysroot — armhf Debian + Raspbian/Pi userland; provides the link-time sysroot the Qt 5 toolchain's qmake.conf points at via `-sysroot /sysroot`. - 2. webview-builder — host x86 (`$BUILDPLATFORM`); installs the - Linaro gcc-7.4.1 cross-compiler plus the pre-built Qt 5 + 2. webview-builder — host x86, pinned to linux/amd64 via the + QT5_HOST_PLATFORM ARG (not $BUILDPLATFORM — see the stage's + own comment); installs Debian's armhf cross-compiler + (crossbuild-essential-armhf, symlinked under the legacy Linaro + path the frozen qmake.conf bakes in) plus the pre-built Qt 5 toolchain pinned to WebView-v2026.07.1, then compiles the in-tree src/anthias_webview/ source. Qt 5 is frozen for pi2/pi3 boards, so the toolchain artifact at WebView-v2026.07.1 never changes. To CVE-patch the toolchain run bin/rebuild_qt5_toolchain.sh and re-upload to that tag. -#} -FROM --platform=linux/arm/v7 {{ base_image }}:{{ base_image_tag }} AS qt5-sysroot + + This template is always included into Dockerfile.viewer.j2 (never + built standalone), so QT5_HOST_PLATFORM below resolves to the global + ARG that template declares before its first FROM. #} + +{# No --platform: the sysroot stage builds at the armhf target platform, + which is exactly what a bare FROM defaults to (the viewer image's + target is linux/arm/v7 for pi2/pi3). A literal --platform=linux/arm/v7 + here would trip BuildKit's FromPlatformFlagConstDisallowed lint, and + spelling it $TARGETPLATFORM would trip RedundantTargetPlatform — so we + just let it default. #} +FROM {{ base_image }}:{{ base_image_tag }} AS qt5-sysroot USER root @@ -142,15 +155,19 @@ RUN --mount=type=cache,target=/var/cache/apt,id=qt5-sysroot-apt-{{ artifact_boar dpkg --purge libraspberrypi-dev -FROM --platform=linux/amd64 {{ base_image }}:{{ base_image_tag }} AS webview-builder +FROM --platform=${QT5_HOST_PLATFORM} {{ base_image }}:{{ base_image_tag }} AS webview-builder USER root -# Pinned to linux/amd64 (not $BUILDPLATFORM): the pre-built Qt 5 -# toolchain bundle ships an x86_64 host qmake under qt5pi/bin, and the -# Debian armhf cross-compiler installed below is the x86_64-hosted -# toolchain — so this stage must run on x86_64. CI is amd64; arm64 -# build hosts (e.g. Apple Silicon) will execute this stage under QEMU. +# Pinned to linux/amd64 via the QT5_HOST_PLATFORM global ARG (declared +# in Dockerfile.viewer.j2) rather than $BUILDPLATFORM: the pre-built +# Qt 5 toolchain bundle ships an x86_64 host qmake under qt5pi/bin, and +# the Debian armhf cross-compiler installed below is the x86_64-hosted +# toolchain — so this stage must run on x86_64 regardless of the build +# host. $BUILDPLATFORM would be arm64 on an Apple Silicon host and break +# it. CI is amd64; arm64 build hosts will execute this stage under QEMU. +# The ARG default keeps the pin while avoiding BuildKit's +# FromPlatformFlagConstDisallowed lint on literal --platform values. # # Host build tools plus the armhf cross-toolchain. The webview app # builds against the pre-built Qt 5 toolchain, so we don't need the diff --git a/docker/Dockerfile.viewer.j2 b/docker/Dockerfile.viewer.j2 index 37852a58b..94b59d1df 100644 --- a/docker/Dockerfile.viewer.j2 +++ b/docker/Dockerfile.viewer.j2 @@ -1,5 +1,13 @@ # syntax=mirror.gcr.io/docker/dockerfile:1.24 # vim: ft=dockerfile +{% if not is_qt6 %} +{# Global ARG for the Qt 5 cross-compile host stage's pinned + --platform (see Dockerfile.qt5-webview-builder.j2). Declared here, + before the first FROM, so it is in scope for that stage's + FROM --platform — an ARG declared mid-file would be stage-scoped and + unusable in a later FROM. #} +ARG QT5_HOST_PLATFORM=linux/amd64 +{% endif %} {% include 'uv-builder.j2' %} {% if is_qt6 %}