diff --git a/.github/workflows/image-arch-check.yml b/.github/workflows/image-arch-check.yml new file mode 100644 index 0000000..aec8060 --- /dev/null +++ b/.github/workflows/image-arch-check.yml @@ -0,0 +1,35 @@ +# Fails when a duplocloud image this kit pins does not publish both linux/amd64 and linux/arm64. +# +# WHY: every devkit release is now expected to publish multi-arch (scripts/_runtime.sh no longer pins +# the studio platform), so a single-arch tag silently falls back to emulation on Apple Silicon instead +# of being caught — the retained Rosetta check only fires on an EXPLICIT amd64 pin, and cannot see an +# unset platform resolving to the wrong thing. See scripts/check-image-arches.sh for the full rationale. +# +# Scoped to quay.io/duplocloud/* only (decided 2026-10-01) — these are images DuploCloud itself builds +# and has committed to publishing multi-arch, so a failure here is a mistake to fix, never a legitimate +# choice. Needs no Docker, no runtime, and no credentials (quay's duplocloud images are public), so it +# runs the same on a fork PR as it does here. +# +# Gated on the two files that can actually change a pinned tag, so this does not run on every PR — +# only ones that could plausibly need it. +# +# NOTE for the PR that introduces this file: GitHub only runs a `pull_request`-triggered workflow if it +# already exists on the base branch, so this will not appear as a check on that introducing PR. It +# starts running once merged to dev. Use workflow_dispatch (below) to exercise it before then. +name: Image architecture check + +on: + pull_request: + paths: + - ".env.example" + - "docker-compose.yml" + - "scripts/check-image-arches.sh" + workflow_dispatch: {} + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Check duplocloud images publish amd64 and arm64 + run: ./scripts/check-image-arches.sh diff --git a/scripts/check-image-arches.sh b/scripts/check-image-arches.sh new file mode 100755 index 0000000..ef4afba --- /dev/null +++ b/scripts/check-image-arches.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# Checks that every duplocloud image this kit pins publishes both linux/amd64 and linux/arm64. +# +# WHY: the studio platform pin was dropped (see scripts/_runtime.sh, runtime_rosetta_check) because every +# devkit release now publishes multi-arch — an Apple Silicon host resolves the native layer instead of +# emulating under QEMU. That only stays true if every tag this repo configures actually IS multi-arch. +# Nothing else in the kit checks that: a tag bumped to an amd64-only build would silently fall back to +# emulation on arm64, and the retained Rosetta check would stay quiet, because it only fires on an +# EXPLICIT amd64 pin — an unset platform resolving to a single-arch image looks, to that check, exactly +# like one resolving correctly. +# +# Scope is quay.io/duplocloud/* only (decided 2026-10-01): these are images DuploCloud itself builds and +# has committed to publishing multi-arch, so a failing tag here is a mistake to fix, not a legitimate +# choice to accommodate — unlike scripts/_runtime.sh's Rosetta check, which has to allow for a user's own +# private or mirrored registry and so cannot hard-fail the same way. Third-party images (mongo, qdrant, +# busybox) are out of scope: stable, already multi-arch, and Docker Hub needs an auth dance this does not. +# +# Reads the registry API directly — no docker/podman pull, no runtime, no credentials for a public quay +# repo — so it runs the same on a fork PR (no org secrets) as it does here. +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 + +REQUIRED_ARCHES=(amd64 arm64) + +# repo:TAG_VAR — the quay repo and the .env.example key holding its tag. Add a line here when a new +# duplocloud image is wired into .env.example; BUILDER_TAG is deliberately absent (defaults to `latest`, +# a moving target — see _should_skip_tag). +IMAGES=( + "duplocloud/backend:STUDIO_TAG" + "duplocloud/duplo-agent:AGENT_TAG" + "duplocloud/duplo-ai-helpdesk-ui:UI_TAG" + "duplocloud/duplo-xterm:XTERM_TAG" +) + +# _tag_value [] -> the value of VAR= in (default .env.example), or empty. +# Takes the file as an argument so this is testable against a fixture without touching the real one. +_tag_value() { + grep -E "^$1=" "${2:-.env.example}" 2>/dev/null | head -1 | cut -d= -f2- +} + +# _should_skip_tag -> a reason to skip it, or empty if it should be checked. +_should_skip_tag() { + case "${1-}" in + "") echo "not set in .env.example" ;; + latest) echo "a moving target, not a pinned release" ;; + *@sha256:*|sha256:*) echo "a digest pin — a single manifest by definition, not an index" ;; + *) ;; + esac +} + +# _parse_arches -> reads a registry manifest response on stdin, prints the space-separated real +# architectures it lists, or one of UNREADABLE / ERROR: / SINGLE-ARCH. +# +# "Real" excludes attestation manifests: an OCI index for a multi-arch build typically carries extra +# entries with platform os/architecture "unknown" (SBOM/provenance attestations), which are not +# something this image runs as. Counting them would make this check pass on anything with 2+ manifests. +_parse_arches() { + python3 -c ' +import json, sys +try: + d = json.load(sys.stdin) +except Exception: + print("UNREADABLE"); sys.exit(0) +if "errors" in d: + print("ERROR:" + d["errors"][0].get("message", "?")[:80]); sys.exit(0) +ms = d.get("manifests") +if not ms: + print("SINGLE-ARCH"); sys.exit(0) +arches = sorted({ + m.get("platform", {}).get("architecture") + for m in ms + if m.get("platform", {}).get("os") not in (None, "unknown") +}) +print(" ".join(a for a in arches if a)) +' +} + +# _evaluate_manifest -> reads a registry manifest response on stdin, prints one verdict line, returns +# 0 when every REQUIRED_ARCHES entry is present, 1 otherwise (including on empty/unreadable input — +# a network failure must never read as a silent pass). +_evaluate_manifest() { + local json have missing="" a + json="$(cat)" + if [ -z "$json" ]; then echo "no response (network error?)"; return 1; fi + have="$(printf '%s' "$json" | _parse_arches)" + case "$have" in + UNREADABLE) echo "unreadable registry response"; return 1 ;; + ERROR:*) echo "registry error: ${have#ERROR:}"; return 1 ;; + SINGLE-ARCH) echo "single-arch manifest, not a multi-arch index"; return 1 ;; + esac + for a in "${REQUIRED_ARCHES[@]}"; do + case " $have " in *" $a "*) ;; *) missing="$missing $a" ;; esac + done + if [ -n "$missing" ]; then echo "missing:$missing (has: ${have:-none})"; return 1; fi + echo "ok (has: $have)" +} + +# _quay_fetch -> the registry's manifest response on stdout, or empty on a network failure. +# The one impure function in this file — everything else is pure and covered by tests/test-check-image-arches.sh. +_quay_fetch() { + curl -sL --max-time 15 \ + -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' \ + "https://quay.io/v2/$1/manifests/$2" 2>/dev/null +} + +check_image() { # -> prints the verdict, returns 0/1 + _quay_fetch "$1" "$2" | _evaluate_manifest +} + +# Only run the check when executed directly — sourcing this (as tests/test-check-image-arches.sh does) +# loads the functions above without triggering any network calls. +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + FAIL=0 + echo "Checking duplocloud images publish ${REQUIRED_ARCHES[*]}..." + for entry in "${IMAGES[@]}"; do + repo="${entry%%:*}"; var="${entry#*:}" + tag="$(_tag_value "$var")" + skip="$(_should_skip_tag "$tag")" + if [ -n "$skip" ]; then + echo " SKIP $repo:${tag:-} — $skip" + continue + fi + if result="$(check_image "$repo" "$tag")"; then + echo " OK $repo:$tag — $result" + else + echo " FAIL $repo:$tag — $result" + FAIL=1 + fi + done + + if [ "$FAIL" = 1 ]; then + echo + echo "One or more duplocloud images do not publish every required architecture (${REQUIRED_ARCHES[*]})." >&2 + echo "Every devkit release is expected to be multi-arch — fix the tag before merging." >&2 + exit 1 + fi + echo + echo "All configured duplocloud images publish ${REQUIRED_ARCHES[*]}." +fi diff --git a/tests/test-check-image-arches.sh b/tests/test-check-image-arches.sh new file mode 100755 index 0000000..23fddd0 --- /dev/null +++ b/tests/test-check-image-arches.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# Checks the pure parts of scripts/check-image-arches.sh: manifest parsing, skip-decisions and the +# verdict logic. Nothing here touches the network — the one impure function (_quay_fetch) is exercised +# only by the real workflow run against quay.io, not by this suite. +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 +PASS=0; FAIL=0 +t() { printf ' %s … ' "$1"; } +ok() { echo "ok"; PASS=$((PASS+1)); } +bad() { echo "FAIL: $1"; FAIL=$((FAIL+1)); } + +echo "image architecture check:" + +# Sourced once, at top level: nothing in this file mutates global state (no memoization, no .env +# writes), unlike _runtime.sh, so every test below shares this one load rather than re-sourcing per case. +. ./scripts/check-image-arches.sh + +t "script is syntactically valid and sourcing it defines the API" +if bash -n scripts/check-image-arches.sh 2>/dev/null; then + MISS="" + for f in _parse_arches _evaluate_manifest _should_skip_tag _tag_value _quay_fetch check_image; do + declare -F "$f" >/dev/null || MISS="$MISS $f" + done + [ -z "$MISS" ] && ok || bad "missing:$MISS" +else bad "bash -n failed"; fi + +# ── _parse_arches: given a manifest-list JSON on stdin, which real platforms are present ────────── +t "parses a clean multi-arch index" +JSON='{"manifests":[{"platform":{"os":"linux","architecture":"amd64"}},{"platform":{"os":"linux","architecture":"arm64"}}]}' +R="$(printf '%s' "$JSON" | _parse_arches)" +[ "$R" = "amd64 arm64" ] && ok || bad "got '$R'" + +t "excludes attestation manifests (os/architecture = unknown)" +# This is the real shape quay returns for duplocloud/backend: two real platforms plus two attestation +# entries. Counting the attestation entries as architectures would make the check pass on anything. +JSON='{"manifests":[{"platform":{"os":"linux","architecture":"amd64"}},{"platform":{"os":"linux","architecture":"arm64"}},{"platform":{"os":"unknown","architecture":"unknown"}},{"platform":{"os":"unknown","architecture":"unknown"}}]}' +R="$(printf '%s' "$JSON" | _parse_arches)" +[ "$R" = "amd64 arm64" ] && ok || bad "got '$R' (attestation entries leaked into the result)" + +t "reports SINGLE-ARCH for a plain (non-index) manifest" +JSON='{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{}}' +R="$(printf '%s' "$JSON" | _parse_arches)" +[ "$R" = SINGLE-ARCH ] && ok || bad "got '$R'" + +t "surfaces a registry error rather than treating it as zero architectures" +JSON='{"errors":[{"message":"manifest unknown"}]}' +R="$(printf '%s' "$JSON" | _parse_arches)" +case "$R" in ERROR:*) ok ;; *) bad "got '$R'" ;; esac + +t "reports UNREADABLE on non-JSON input rather than crashing" +R="$(printf 'not json at all' | _parse_arches)" +[ "$R" = UNREADABLE ] && ok || bad "got '$R'" + +# ── _evaluate_manifest: parse + compare against REQUIRED_ARCHES + produce the verdict line ──────── +t "evaluate passes when both required architectures are present" +JSON='{"manifests":[{"platform":{"os":"linux","architecture":"amd64"}},{"platform":{"os":"linux","architecture":"arm64"}}]}' +MSG="$(printf '%s' "$JSON" | _evaluate_manifest)"; RC=$? +[ "$RC" = 0 ] && case "$MSG" in *amd64*arm64*|*arm64*amd64*) ok ;; *) bad "wrong message: $MSG" ;; esac \ + || bad "rc=$RC msg='$MSG'" + +t "evaluate fails and names what's missing when arm64 is absent" +JSON='{"manifests":[{"platform":{"os":"linux","architecture":"amd64"}}]}' +MSG="$(printf '%s' "$JSON" | _evaluate_manifest)"; RC=$? +if [ "$RC" != 0 ] && grep -q arm64 <<<"$MSG"; then ok; else bad "rc=$RC msg='$MSG'"; fi + +t "evaluate fails on a single-arch (non-index) manifest" +JSON='{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json"}' +MSG="$(printf '%s' "$JSON" | _evaluate_manifest)"; RC=$? +[ "$RC" != 0 ] && grep -qi 'single-arch\|not.*index' <<<"$MSG" || bad "rc=$RC msg='$MSG'" +[ "$RC" != 0 ] && ok + +t "evaluate fails on an empty response (network error) instead of silently passing" +MSG="$(printf '' | _evaluate_manifest)"; RC=$? +[ "$RC" != 0 ] && ok || bad "empty input passed" + +# ── _should_skip_tag: which tags this check declines to validate, and why ────────────────────────── +t "an unset tag is skipped, not treated as a failure" +R="$(_should_skip_tag "")" +[ -n "$R" ] && ok || bad "empty tag was not flagged for skipping" + +t "'latest' is skipped — a moving target proves nothing about tomorrow's pull" +R="$(_should_skip_tag "latest")" +[ -n "$R" ] && ok || bad "'latest' was not skipped" + +t "a digest pin is skipped — it is a single manifest by definition" +R="$(_should_skip_tag "backend@sha256:abc123")" +[ -n "$R" ] && ok || bad "digest pin was not skipped" + +t "an ordinary pinned tag is NOT skipped" +R="$(_should_skip_tag "dev-1.0.6-45708c38")" +[ -z "$R" ] && ok || bad "a real tag was skipped: $R" + +# ── _tag_value: reading a *_TAG key out of an .env.example-shaped file ───────────────────────────── +t "_tag_value reads a key from the given file" +F="$(mktemp)"; printf 'STUDIO_TAG=dev-1.0.6-45708c38\nOTHER=x\n' > "$F" +R="$(_tag_value STUDIO_TAG "$F")"; rm -f "$F" +[ "$R" = dev-1.0.6-45708c38 ] && ok || bad "got '$R'" + +t "_tag_value returns empty for a key the file does not have" +F="$(mktemp)"; printf 'STUDIO_TAG=x\n' > "$F" +R="$(_tag_value NOT_A_KEY "$F")"; rm -f "$F" +[ -z "$R" ] && ok || bad "got '$R'" + +# ── scope: the configured image table is duplocloud-quay-only, per @aboutte's decision ───────────── +t "every configured image is a duplocloud/* repo (no third-party images checked)" +BAD="" +for entry in "${IMAGES[@]}"; do + repo="${entry%%:*}" + case "$repo" in duplocloud/*) ;; *) BAD="$BAD $repo" ;; esac +done +if [ -z "$BAD" ]; then ok; else bad "non-duplocloud repo(s) in scope:$BAD"; fi + +t "BUILDER_TAG is not checked — it defaults to 'latest', a moving target" +FOUND="" +for entry in "${IMAGES[@]}"; do + case "$entry" in *:BUILDER_TAG) FOUND=1 ;; esac +done +[ -z "$FOUND" ] && ok || bad "BUILDER_TAG is in the table despite defaulting to latest" + +t "every *_TAG the table checks actually exists in .env.example" +MISS="" +for entry in "${IMAGES[@]}"; do + var="${entry#*:}" + grep -qE "^$var=" .env.example || MISS="$MISS $var" +done +if [ -z "$MISS" ]; then ok; else bad "table references key(s) .env.example does not define:$MISS"; fi + +echo +echo "$PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ]