fail the build when a declared build_secret is unreadable

A repo that lists `build_secrets` in hanzo.yml has asserted its image is not
valid without them. The fetch treated every failure as survivable and exported
nothing, so buildx baked an empty value and the run went green — the artifact
was broken and only production showed it, as silence.

The declaration is now read before the two short-circuits, so an unconfigured
or unreachable KMS is an error for a repo that declares secrets instead of an
empty result that looks like "not in KMS". An unresolvable name is an error at
fetch, and the build step refuses a name that is still unset when it assembles
--build-arg.

A repo that declares no build_secrets keeps the best-effort path exactly as it
was: KMS stays optional, GHCR push still works on the workflow token.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-04 00:00:12 -07:00
co-authored by Hanzo Dev
parent e88818d544
commit a19884b259
+34 -8
View File
@@ -311,13 +311,31 @@ jobs:
fi
ENV="$(yq -r '.kms.environment // "prod"' hanzo.yml 2>/dev/null || echo prod)"
PATHQ="$(yq -r '.kms.path // "/deploy"' hanzo.yml | sed 's#^/##; s#/$##' 2>/dev/null || echo deploy)"
# A repo that DECLARES `build_secrets` has asserted its image is not
# valid without them, so for that repo KMS is REQUIRED, not best-effort.
# Read the declaration BEFORE the two short-circuits below: otherwise an
# unconfigured or unreachable KMS returns "no secrets" and the build
# proceeds to bake an EMPTY credential — green run, broken artifact. That
# is exactly how docs/chat/app shipped bundles that could not write, and
# how a stale key survived: every failure looked like "not in KMS", and
# "not in KMS" was survivable.
DECLARED="$(yq -r '[(.images // [])[] | (.build_secrets // [])[]] | unique | .[]' hanzo.yml 2>/dev/null || true)"
required() { [ -n "$DECLARED" ]; }
if [ -z "${KMS_CLIENT_ID:-}" ] || [ -z "$ORG" ]; then
if required; then
echo "::error::KMS not configured (no KMS_CLIENT_ID or org) but hanzo.yml declares build_secrets:$(echo " $DECLARED" | tr '\n' ' ')— refusing to build an image with empty credentials"; exit 1
fi
echo "::notice::KMS not configured (no KMS_CLIENT_ID or org) — skipping; GHCR push uses the workflow token"; exit 0
fi
TOKEN=$(curl -sf "$KMS_ENDPOINT/v1/kms/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"clientId\":\"$KMS_CLIENT_ID\",\"clientSecret\":\"$KMS_CLIENT_SECRET\"}" | jq -r '.accessToken // empty')
if [ -z "$TOKEN" ]; then echo "::warning::KMS login failed (org=$ORG client=$KMS_CLIENT_ID) — cross-org private deps & deploy unavailable"; exit 0; fi
if [ -z "$TOKEN" ]; then
if required; then
echo "::error::KMS login failed (endpoint=$KMS_ENDPOINT org=$ORG client=$KMS_CLIENT_ID) but hanzo.yml declares build_secrets — refusing to build an image with empty credentials"; exit 1
fi
echo "::warning::KMS login failed (org=$ORG client=$KMS_CLIENT_ID) — cross-org private deps & deploy unavailable"; exit 0
fi
# KMS is embedded in hanzoai/cloud now (HIP-0106, cloud/apps/kms), and its
# contract differs from the standalone service in two ways that BOTH fail
# silently — you get an empty string, which every caller below already
@@ -371,13 +389,16 @@ jobs:
# name — one name, one place. For publishable client tokens a Vite SPA
# must embed at build (e.g. VITE_MAPBOX_TOKEN). Undeclared → no-op, so
# every existing repo is byte-for-byte unchanged.
for bs in $(yq -r '[(.images // [])[] | (.build_secrets // [])[]] | unique | .[]' hanzo.yml 2>/dev/null || true); do
case "$bs" in ''|*[!A-Za-z0-9_]*) echo "::warning::skipping invalid build_secret name '$bs'"; continue;; esac
for bs in $DECLARED; do
case "$bs" in ''|*[!A-Za-z0-9_]*) echo "::error::invalid build_secret name '$bs'"; exit 1;; esac
v=$(get "$bs" || true)
if [ -n "$v" ]; then
echo "::add-mask::$v"
{ echo "$bs<<__KMS_BUILDARG_EOF__"; echo "$v"; echo "__KMS_BUILDARG_EOF__"; } >> "$GITHUB_ENV"
else echo "::warning::build_secret $bs not in KMS ($ORG/$PATHQ env=$ENV) — build-arg will be empty"; fi
if [ -z "$v" ]; then
# Fail CLOSED. An empty build-arg is indistinguishable from a missing
# one at build time and only shows up as silence in production.
echo "::error::build_secret $bs is not readable at $KMS_ENDPOINT ($ORG/$PATHQ env=$ENV) — refusing to bake an empty value. Seal it, or drop it from hanzo.yml build_secrets."; exit 1
fi
echo "::add-mask::$v"
{ echo "$bs<<__KMS_BUILDARG_EOF__"; echo "$v"; echo "__KMS_BUILDARG_EOF__"; } >> "$GITHUB_ENV"
done
- name: GHCR push credential (KMS write:packages token)
@@ -578,7 +599,12 @@ jobs:
while IFS= read -r kv; do [ -n "$kv" ] && BUILD_ARGS="$BUILD_ARGS --build-arg $kv"; done \
< <(echo "$img" | jq -r '(.args // {}) | to_entries[] | "\(.key)=\(.value)"')
for bs in $(echo "$img" | jq -r '(.build_secrets // [])[]'); do
v=$(printenv "$bs" 2>/dev/null || true); [ -n "$v" ] && BUILD_ARGS="$BUILD_ARGS --build-arg $bs=$v"
v=$(printenv "$bs" 2>/dev/null || true)
# Defense in depth: the KMS step above already refuses to continue
# with an unresolved build_secret, so reaching here empty means the
# two steps disagree. Stop rather than bake a blank credential.
[ -z "$v" ] && { echo "::error::build_secret $bs is unset at build time — refusing to bake an empty value"; exit 1; }
BUILD_ARGS="$BUILD_ARGS --build-arg $bs=$v"
done
# GIT_TOKEN (from KMS, via GITHUB_ENV) is passed as the `gh_token`
# BuildKit secret so Dockerfiles can clone private Go modules; omitted