Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/image-arch-check.yml
Original file line number Diff line number Diff line change
@@ -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
139 changes: 139 additions & 0 deletions scripts/check-image-arches.sh
Original file line number Diff line number Diff line change
@@ -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 <VAR> [<file>] -> the value of VAR= in <file> (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 <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:<msg> / 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 <repo> <tag> -> 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() { # <repo> <tag> -> 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:-<unset>} — $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
130 changes: 130 additions & 0 deletions tests/test-check-image-arches.sh
Original file line number Diff line number Diff line change
@@ -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 ]
Loading