Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b047a7a569 | ||
|
|
8a415e9745 | ||
|
|
a830325d31 | ||
|
|
d1e6edf6d1 | ||
|
|
2c036d3a61 | ||
|
|
11fcb7b145 | ||
|
|
5e0e4f906c | ||
|
|
f098b39ecd | ||
|
|
674fabdcba | ||
|
|
3f65c9e24f | ||
|
|
8e43277aab | ||
|
|
d3823df85a | ||
|
|
b6b52b4a81 | ||
|
|
331e05625f |
@@ -0,0 +1,60 @@
|
||||
name: sitedeploy
|
||||
description: Publish a built static export to the Hanzo PaaS Sites plane.
|
||||
# For every repo whose deploy is "a static export goes live" — hanzo.ai, hanzo.app,
|
||||
# hips, computer and the ones after them. Those were each about to carry their own
|
||||
# copy of enqueue → upload → complete, which is how one contract becomes N
|
||||
# transcriptions that drift (see the imgver action next door for the same story
|
||||
# told about image tags).
|
||||
#
|
||||
# - uses: hanzoai/ci/.github/actions/sitedeploy@v1
|
||||
# with: { slug: hanzo-ai, dir: out }
|
||||
# env: { HANZO_DEPLOY_TOKEN: '${{ secrets.HANZO_DEPLOY_TOKEN }}' }
|
||||
#
|
||||
# A SITE IS NOT AN APP. This publishes files and stops: no image, no CR, no
|
||||
# replicas, no registry. Building a container so a Go binary can serve /public is
|
||||
# the shape the Sites plane exists to retire.
|
||||
#
|
||||
# ONE credential. The 202 hands back a prefix-scoped, 30-minute presigned POST
|
||||
# grant, so CI never holds a bucket key — do NOT add SITES_S3_* here; that is the
|
||||
# standing shared-bucket credential the grant replaced.
|
||||
inputs:
|
||||
slug:
|
||||
description: The project slug on the Sites plane (POST /v1/projects/<slug>/deploy)
|
||||
required: true
|
||||
dir:
|
||||
description: The built export directory
|
||||
required: true
|
||||
api:
|
||||
description: Cloud API base
|
||||
required: false
|
||||
default: https://api.hanzo.ai
|
||||
jobs:
|
||||
description: Parallel uploads
|
||||
required: false
|
||||
default: '24'
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Fetch sitedeploy
|
||||
shell: bash
|
||||
# The action ref is the script ref: an action pinned to @v1 runs v1's
|
||||
# sitedeploy. Both forges, because this repo is served from each.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ref="${GITHUB_ACTION_REF:-v1}"
|
||||
for url in https://git.hanzo.ai/hanzoai/ci https://github.com/hanzoai/ci; do
|
||||
git clone -q --depth 1 --branch "$ref" "$url" "$RUNNER_TEMP/sitedeploy-ci" 2>/dev/null && break
|
||||
done
|
||||
[ -x "$RUNNER_TEMP/sitedeploy-ci/bin/sitedeploy" ] \
|
||||
|| { echo "::error::could not fetch hanzoai/ci@$ref (bin/sitedeploy)"; exit 1; }
|
||||
|
||||
- name: Deploy
|
||||
shell: bash
|
||||
env:
|
||||
HANZO_API: ${{ inputs.api }}
|
||||
SITEDEPLOY_JOBS: ${{ inputs.jobs }}
|
||||
SITEDEPLOY_COMMIT: ${{ github.sha }}
|
||||
SITEDEPLOY_BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash "$RUNNER_TEMP/sitedeploy-ci/bin/sitedeploy" '${{ inputs.slug }}' '${{ inputs.dir }}'
|
||||
+463
-287
@@ -29,14 +29,17 @@ on:
|
||||
default: '["hanzo-build-linux-amd64"]'
|
||||
mode:
|
||||
description: >-
|
||||
Build execution mode. `buildx` (default) runs the full buildx →
|
||||
test → deploy pipeline ON the runner. `delegate` instead POSTs the
|
||||
build to platform.hanzo.ai (`/v1/runner`) — platform builds
|
||||
in-cluster with BuildKit and rolls the operator Service CR itself, so
|
||||
the GitHub job finishes in seconds with no runner buildx. A repo opts
|
||||
in by passing `with: { mode: delegate }`; everything else is unchanged.
|
||||
Requires the `PLATFORM_BUILD_CALLBACK_TOKEN` secret (via secrets:
|
||||
inherit).
|
||||
Where the IMAGE is built. `buildx` (default) builds it on this runner.
|
||||
`delegate` POSTs it to platform.hanzo.ai (`/v1/runner`), which builds
|
||||
in-cluster with BuildKit and rolls the operator Service CR itself.
|
||||
A repo opts in with `with: { mode: delegate }` and needs the
|
||||
`PLATFORM_BUILD_CALLBACK_TOKEN` secret (via secrets: inherit).
|
||||
|
||||
THE GATE RUNS IN BOTH MODES, and that is the whole point of the input
|
||||
being named for the build. `delegate` used to be evaluated fourth, ahead
|
||||
of every toolchain and ahead of `test:`, so it skipped the gate along
|
||||
with the build — 20 steps skipped, 0 executed, green. What a delegated
|
||||
run skips is buildx, publish and deploy. It does not skip being tested.
|
||||
type: string
|
||||
default: buildx
|
||||
tests:
|
||||
@@ -73,7 +76,7 @@ on:
|
||||
A string sidesteps it on both planes with identical meaning, and the
|
||||
comparison is against 'false' rather than a truthiness test so that the
|
||||
expression callers already write — hanzoai/cloud's
|
||||
`tests: ${{ github.ref_type != 'tag' }}`, which renders the strings
|
||||
`tests:` set from `github.ref_type != 'tag'`, which renders the strings
|
||||
"true"/"false" — keeps working unchanged.
|
||||
type: string
|
||||
default: 'true'
|
||||
@@ -104,6 +107,14 @@ permissions:
|
||||
jobs:
|
||||
cicd:
|
||||
runs-on: ${{ fromJson(inputs.runner) }}
|
||||
env:
|
||||
# corepack fetches the pnpm/yarn TARBALL ITSELF from registry.npmjs.org,
|
||||
# and it does not read .npmrc to do it — so 317 repos that pin
|
||||
# `packageManager` reach out to npmjs on every install no matter how
|
||||
# carefully their registry is configured. This is the one variable that
|
||||
# closes that hole; without it "installs go through our registry" is true
|
||||
# of the dependencies and false of the package manager fetching them.
|
||||
COREPACK_NPM_REGISTRY: https://pkg.hanzo.ai
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -214,64 +225,7 @@ jobs:
|
||||
jq --version
|
||||
yq --version
|
||||
|
||||
- name: Delegate build to platform (mode=delegate)
|
||||
# The GHA-escape fast path: instead of running buildx on this runner, POST
|
||||
# each image in hanzo.yml to platform.hanzo.ai's direct-enqueue webhook
|
||||
# (`/v1/runner`). Platform creates a build_job row, launches an
|
||||
# in-cluster BuildKit Job on its own pool, pushes to the registry, and —
|
||||
# for a system service — patches the operator Service CR to roll it. The
|
||||
# downstream is IDENTICAL to the platform GitHub-App webhook path (one
|
||||
# build path, two front doors), so a delegated build behaves exactly like
|
||||
# a platform-native one. This job then exits in seconds — no buildx, no
|
||||
# KMS, no runner-side deploy.
|
||||
if: inputs.mode == 'delegate'
|
||||
env:
|
||||
ENQUEUE_URL: ${{ vars.PLATFORM_ENQUEUE_URL || 'https://platform.hanzo.ai/v1/runner' }}
|
||||
ENQUEUE_TOKEN: ${{ secrets.PLATFORM_BUILD_CALLBACK_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${ENQUEUE_TOKEN:-}" ]; then
|
||||
echo "::error::mode=delegate needs the PLATFORM_BUILD_CALLBACK_TOKEN secret (secrets: inherit)"; exit 1
|
||||
fi
|
||||
REPO="${{ github.repository }}"
|
||||
SHA="${{ github.sha }}"
|
||||
SHORT=$(echo "$SHA" | cut -c1-7)
|
||||
REF="${{ github.ref }}"
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
# One enqueue per (image, platform), mirroring the buildx tag shape the
|
||||
# deploy path expects (`sha-<short>-<arch>[-<suffix>]`). Default arch is
|
||||
# amd64 (single-arch), so an existing repo's tag shape is unchanged.
|
||||
yq -o=json -I=0 '.images' hanzo.yml | jq -c '.[]' | while read -r img; do
|
||||
name=$(echo "$img"|jq -r .name); repo=$(echo "$img"|jq -r .repo)
|
||||
ctx=$(echo "$img"|jq -r .context); df=$(echo "$img"|jq -r '.dockerfile // (.context+"/Dockerfile")')
|
||||
sfx=$(echo "$img"|jq -r '."tag-suffix" // ""')
|
||||
# Same Go-builder check the buildx lane runs. It belongs here too:
|
||||
# delegating the build moves WHERE it runs, not whether the image
|
||||
# can compile the module, and a mismatch enqueued to platform fails
|
||||
# on a machine whose logs this run never shows. Cheap to check on
|
||||
# the runner that already has the checkout; expensive to discover
|
||||
# from the in-cluster builder.
|
||||
"$CI_HOME/bin/gover" "$df" "$ctx"
|
||||
echo "$img" | jq -r '(.platforms // ["linux/amd64"])[]' | while read -r plat; do
|
||||
arch="${plat##*/}"
|
||||
image="${repo}:sha-${SHORT}-${arch}${sfx:+-$sfx}"
|
||||
body=$(jq -nc \
|
||||
--arg repo "$REPO" --arg sha "$SHA" --arg image "$image" \
|
||||
--arg ref "$REF" --arg branch "$BRANCH" \
|
||||
--arg dockerfile "$df" --arg context "$ctx" --arg arch "$arch" \
|
||||
'{repo:$repo,sha:$sha,image:$image,ref:$ref,branch:$branch,dockerfile:$dockerfile,context:$context,os:"linux",arch:$arch}')
|
||||
echo "::group::delegate $name → $image"
|
||||
code=$(curl -sS -o /tmp/enqueue.out -w '%{http_code}' -X POST "$ENQUEUE_URL" \
|
||||
-H "Authorization: Bearer $ENQUEUE_TOKEN" -H 'Content-Type: application/json' -d "$body")
|
||||
cat /tmp/enqueue.out; echo
|
||||
# 202 Accepted = queued; 409 = no live runner for the pool (surface it loud).
|
||||
if [ "$code" != "202" ]; then echo "::error::enqueue $image failed (HTTP $code)"; exit 1; fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
done
|
||||
|
||||
- name: Authenticated git for go modules (rate-limit + any private repo)
|
||||
if: inputs.mode != 'delegate'
|
||||
# luxfi/hanzoai/zooai Go modules are PUBLIC, so `go` resolves them through
|
||||
# the default public proxy (proxy.golang.org) + checksum db (sum.golang.org)
|
||||
# — canonical, IMMUTABLE hashes that a force-moved tag can no longer break.
|
||||
@@ -327,7 +281,6 @@ jobs:
|
||||
|
||||
- name: Fetch deploy credentials from KMS
|
||||
id: kms
|
||||
if: inputs.mode != 'delegate'
|
||||
env:
|
||||
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
|
||||
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
|
||||
@@ -367,6 +320,15 @@ jobs:
|
||||
if [ -z "$ORG" ]; then
|
||||
case "${{ github.repository_owner }}" in
|
||||
hanzoai) ORG=hanzo ;;
|
||||
# hanzo-inc is the same TENANT as hanzoai — pay, commerce and the
|
||||
# rest of the company surfaces bill and seal to KMS org `hanzo`.
|
||||
# Without this line ORG fell through to the literal owner,
|
||||
# "hanzo-inc", which is not a KMS org. That still LOOKED fine,
|
||||
# because the read below takes its org from the TOKEN, not the URL
|
||||
# — so the only things that were wrong were the fallback URL and
|
||||
# every error message, which named an org that does not exist and
|
||||
# sent the reader looking for a tenant instead of a credential.
|
||||
hanzo-inc) ORG=hanzo ;;
|
||||
luxfi) ORG=lux ;;
|
||||
zooai) ORG=zoo ;;
|
||||
*) ORG="${{ github.repository_owner }}" ;;
|
||||
@@ -383,6 +345,40 @@ jobs:
|
||||
# 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)"
|
||||
# A build_secret becomes `--build-arg NAME=value` below, and a build-arg
|
||||
# is IN THE PUBLISHED IMAGE — `docker history` prints it to anyone who
|
||||
# can pull. So `build_secrets` can only ever carry values that are
|
||||
# public on purpose: a Vite/Next SPA has no server to read an env from,
|
||||
# so its ingest key must be inlined at build, and inlining it is what
|
||||
# publishing it means. Every other value must not be here at all.
|
||||
#
|
||||
# Nothing checked that. The one repo whose value is genuinely
|
||||
# publishable (hanzoai/ui, a `pk-…` ingest key) asserted the `pk-`
|
||||
# prefix INSIDE ITS OWN Dockerfile — so the check existed once, for one
|
||||
# image, and any repo that added a real credential got no check at all.
|
||||
# Per-repo vigilance is not a mechanism; the assertion belongs here,
|
||||
# where the build-arg is assembled, once, for every caller.
|
||||
#
|
||||
# INLINE, not "$CI_HOME/bin/publishable", and that is forced rather than
|
||||
# chosen. The tools checkout and this workflow resolve from DIFFERENT
|
||||
# places: the caller pins the workflow at a ref its forge resolves, while
|
||||
# the tools step derives its ref from GITHUB_WORKFLOW_REF — which
|
||||
# git.hanzo.ai does not set, so it falls back to `v1` and clones
|
||||
# github.com. Measured on run 36473: `GITHUB_WORKFLOW_REF=<unset>`,
|
||||
# `derived ref=v1`, `cloned https://github.com/hanzoai/ci@v1`. A step
|
||||
# that calls a NEW file under $CI_HOME is therefore broken on every forge
|
||||
# run until a tag moves on a host this repo is not even pinned to.
|
||||
#
|
||||
# The rule is also spelled in bin/publishable, which the `publishable`
|
||||
# gate tests. Two spellings of one rule is the same shape as the two
|
||||
# copies of this file, and it gets the same treatment: a gate refuses
|
||||
# any difference between them. See `publishable-rule-is-one-rule`.
|
||||
for bs in $DECLARED; do
|
||||
case "$bs" in
|
||||
PUBLISHABLE_*|PUBLIC_*|NEXT_PUBLIC_*|EXPO_PUBLIC_*|NUXT_PUBLIC_*|VITE_*|REACT_APP_*|*_PUBLISHABLE|*_PUBLIC) ;;
|
||||
*) echo "::error::build_secret '$bs' does not declare itself publishable, and a build_secret is baked into the image as a --build-arg where \`docker history\` reveals it. Rename it (PUBLISHABLE_*, PUBLIC_*, NEXT_PUBLIC_*, VITE_*, REACT_APP_*) if the value is public on purpose; if it is a real credential it cannot be a build_secret at all."; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
required() { [ -n "$DECLARED" ]; }
|
||||
if [ -z "${KMS_CLIENT_ID:-}" ] || [ -z "$ORG" ]; then
|
||||
if required; then
|
||||
@@ -417,10 +413,35 @@ jobs:
|
||||
if [ -n "$GIT_TOKEN" ]; then echo "::add-mask::$GIT_TOKEN"; echo "GIT_TOKEN=$GIT_TOKEN" >> "$GITHUB_ENV"; fi
|
||||
KUBECONFIG_B64=$(get KUBECONFIG)
|
||||
if [ -n "$KUBECONFIG_B64" ]; then echo "$KUBECONFIG_B64" | base64 -d > "$RUNNER_TEMP/kubeconfig"; echo "kubeconfig=$RUNNER_TEMP/kubeconfig" >> "$GITHUB_OUTPUT"; fi
|
||||
# The REGISTRY credential, and it is fetched UNCONDITIONALLY because
|
||||
# every repo that publishes an image needs it — which is most of them.
|
||||
#
|
||||
# It used to sit inside the `bucket:`/`site:` gate below, and that gate
|
||||
# is about writing bytes to hanzoai/s3, a completely unrelated lane. So
|
||||
# a repo declaring only `images:` — hanzoai/bot, and it is not alone —
|
||||
# asked KMS for nothing and reached the docker login step with empty
|
||||
# credentials. The build then failed at the push with no clue pointing
|
||||
# here, and the obvious "fix" is to paste REGISTRY_USER/REGISTRY_PASSWORD
|
||||
# into the forge's org secrets, which is exactly the CI-only copy the
|
||||
# comment below warns about: a second thing to rotate, and the one
|
||||
# nobody remembers. The contract is that the ONLY things living in the
|
||||
# forge are KMS_CLIENT_ID/SECRET, so the fetch moves out of the gate
|
||||
# rather than the credential moving into GitHub.
|
||||
#
|
||||
# Empty is harmless: nothing is exported when KMS has no such key, and
|
||||
# the login step already falls back to the kubeconfig read for repos
|
||||
# provisioned before these names existed.
|
||||
for k in REGISTRY_USER REGISTRY_PASSWORD; do
|
||||
v=$(get "$k")
|
||||
if [ -n "$v" ]; then
|
||||
[ "$k" = REGISTRY_PASSWORD ] && echo "::add-mask::$v"
|
||||
echo "$k=$v" >> "$GITHUB_ENV"
|
||||
fi
|
||||
done
|
||||
# Object-store credential, for the two lanes that write bytes to
|
||||
# hanzoai/s3: a repo publishing `binaries:` to a bucket:, and a repo
|
||||
# publishing a `site:` (whose build output has to land in the org's own
|
||||
# storage space before /v1/sites can promote it). Same four names the
|
||||
# storage space before /v1/sites can promote it). Same names the
|
||||
# services read at run time, from the same org/path/env — a CI-only copy
|
||||
# of a credential is a second thing to rotate and the one nobody
|
||||
# remembers. One fetch serves both lanes; a repo declaring both does not
|
||||
@@ -521,14 +542,20 @@ jobs:
|
||||
echo "::notice::KMS GHCR token login failed — keeping the earlier login"
|
||||
fi
|
||||
|
||||
- name: Native registry credential (oci.hanzo.ai)
|
||||
- name: Native registry credential (registry.hanzo.ai)
|
||||
if: inputs.mode != 'delegate'
|
||||
env:
|
||||
KUBECONFIG: ${{ steps.kms.outputs.kubeconfig }}
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
# KMS first (exported to GITHUB_ENV by the step above), GitHub secret
|
||||
# second. `env:` here cannot see GITHUB_ENV from THIS step's own env
|
||||
# block, so the KMS values are read out of the environment in `run:`.
|
||||
GH_REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
GH_REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# REGISTRY_USER/PASSWORD may already be in the environment from KMS.
|
||||
REGISTRY_USER="${REGISTRY_USER:-${GH_REGISTRY_USER:-}}"
|
||||
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${GH_REGISTRY_PASSWORD:-}}"
|
||||
# Direct credential first (repo/org secret — works on private repos,
|
||||
# where the Free plan hides org secrets... including these; private
|
||||
# repos set them at REPO level). KMS-kubeconfig read is the fallback.
|
||||
@@ -537,14 +564,14 @@ jobs:
|
||||
# cred) must never fail the run — the image still pushes to GHCR, the
|
||||
# primary. Without this guard, bash -e aborts the step and SKIPS the
|
||||
# build entirely (a registry hiccup takes the whole lane red).
|
||||
if echo "$REGISTRY_PASSWORD" | docker login oci.hanzo.ai -u "$REGISTRY_USER" --password-stdin; then
|
||||
if echo "$REGISTRY_PASSWORD" | docker login registry.hanzo.ai -u "$REGISTRY_USER" --password-stdin; then
|
||||
echo "MIRROR_OK=1" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "::notice::registry.hanzo.ai login failed — mirror skipped (GHCR-only push)"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
[ -z "${KUBECONFIG:-}" ] && { echo "::warning::no registry credential and no kubeconfig — image will NOT reach oci.hanzo.ai (ghcr only)"; exit 0; }
|
||||
[ -z "${KUBECONFIG:-}" ] && { echo "::warning::no registry credential and no kubeconfig — image will NOT reach registry.hanzo.ai (ghcr only)"; exit 0; }
|
||||
# Bare runners ship no kubectl — same static provision the deploy
|
||||
# step uses.
|
||||
command -v kubectl >/dev/null 2>&1 || {
|
||||
@@ -555,218 +582,18 @@ jobs:
|
||||
}
|
||||
CFG=$(kubectl -n hanzo get secret registry-credentials -o jsonpath='{.data.\.dockerconfigjson}' 2>/dev/null | base64 -d || true)
|
||||
if [ -z "$CFG" ]; then
|
||||
echo "::warning::registry-credentials not readable from this kubeconfig — image will NOT reach oci.hanzo.ai (ghcr only)"; exit 0
|
||||
echo "::warning::registry-credentials not readable from this kubeconfig — image will NOT reach registry.hanzo.ai (ghcr only)"; exit 0
|
||||
fi
|
||||
USERPASS=$(echo "$CFG" | jq -r '.auths["registry.hanzo.ai"].auth // empty' | base64 -d)
|
||||
[ -z "$USERPASS" ] && { echo "::warning::no registry auth in dockerconfig — image will NOT reach oci.hanzo.ai (ghcr only)"; exit 0; }
|
||||
[ -z "$USERPASS" ] && { echo "::warning::no registry auth in dockerconfig — image will NOT reach registry.hanzo.ai (ghcr only)"; exit 0; }
|
||||
echo "::add-mask::${USERPASS#*:}"
|
||||
# Best-effort: login failure → skip mirror, never fail the run (see above).
|
||||
if echo "${USERPASS#*:}" | docker login oci.hanzo.ai -u "${USERPASS%%:*}" --password-stdin; then
|
||||
if echo "${USERPASS#*:}" | docker login registry.hanzo.ai -u "${USERPASS%%:*}" --password-stdin; then
|
||||
echo "MIRROR_OK=1" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "::notice::registry.hanzo.ai login failed — mirror skipped (GHCR-only push)"
|
||||
fi
|
||||
|
||||
- name: Build & push images (per hanzo.yml)
|
||||
if: inputs.mode != 'delegate'
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Test-only callers (hanzo.yml without `images:` — e.g. a repo whose
|
||||
# image lane lives in its own release.yml, or a pure library) skip the
|
||||
# build step entirely instead of exploding on a null .images.
|
||||
if [ "$(yq -r '.images // [] | length' hanzo.yml 2>/dev/null || echo 0)" = "0" ]; then
|
||||
echo "::notice::no images: in hanzo.yml — test-only caller, skipping build"; exit 0
|
||||
fi
|
||||
# Build-time private cross-org Go module read (the buildx `gh_token`
|
||||
# secret): prefer the KMS-fetched GIT_TOKEN, else fall back to the org
|
||||
# GH_PAT — the SAME BuildKit gh_token cloud's release.yml uses (proven
|
||||
# working). Keeps image builds green when the KMS deploy-cred fetch is
|
||||
# unavailable (a repo with a private cross-org dep like hanzoai/cloud
|
||||
# otherwise fails `go mod tidy` with git exit 128 in the buildx stage).
|
||||
# No-op for public-only builds when both are empty. Exported so the
|
||||
# `--secret id=gh_token,env=GIT_TOKEN` below reads it from the env.
|
||||
export GIT_TOKEN="${GIT_TOKEN:-${GH_PAT:-}}"
|
||||
SHORT=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
IS_TAG=$([ "${{ github.ref_type }}" = "tag" ] && echo 1 || echo 0)
|
||||
REL="${{ github.ref_name }}" # the git tag, verbatim: v1.26.19
|
||||
VER="${REL#v}" # v-stripped alias: 1.26.19
|
||||
yq -o=json -I=0 '.images' hanzo.yml | jq -c '.[]' | while read -r img; do
|
||||
name=$(echo "$img"|jq -r .name); ctx=$(echo "$img"|jq -r .context)
|
||||
df=$(echo "$img"|jq -r '.dockerfile // (.context+"/Dockerfile")'); repo=$(echo "$img"|jq -r .repo)
|
||||
# tag-suffix is OPTIONAL (most repos ship a single variant). When set
|
||||
# (e.g. "ce"/"ee") it qualifies every tag; when absent the tags are
|
||||
# clean (no trailing dash). Build + deploy must agree on this shape.
|
||||
sfx=$(echo "$img"|jq -r '."tag-suffix" // ""')
|
||||
# platforms is OPT-IN per image in hanzo.yml (default: amd64 only, so
|
||||
# every existing repo's tag shape "-amd64" is UNCHANGED). Set e.g.
|
||||
# platforms: [linux/amd64, linux/arm64]
|
||||
# to emit a multi-arch MANIFEST LIST — one digest serving both arches.
|
||||
# DOKS has no arm64 nodes, so arm64 builds via buildx QEMU emulation
|
||||
# (binfmt set up below); pure-Go (CGO_ENABLED=0) Dockerfiles that honor
|
||||
# $TARGETARCH cross-compile natively (fast, no emulation). For true
|
||||
# native-speed arm64, register a bare-metal arm64 host (spark/GB10) as
|
||||
# the hanzo-build-linux-arm64 self-hosted runner (values-build-arm64.yaml).
|
||||
plats=$(echo "$img"|jq -r '(.platforms // ["linux/amd64"]) | join(",")')
|
||||
# ---- Refuse a Go builder older than the module it compiles -------
|
||||
# The official golang images set GOTOOLCHAIN=local, so a go.mod
|
||||
# requiring a newer Go than the base image does not degrade — it
|
||||
# dies mid-build with "go.mod requires go >= X (running Y;
|
||||
# GOTOOLCHAIN=local)". hanzoai/visor v1.108.16 is the shipped
|
||||
# instance, and it is invisible before the image build: every local
|
||||
# `go build` succeeds because a dev box runs GOTOOLCHAIN=auto and
|
||||
# just downloads what the module asks for. So the break is
|
||||
# introduced by editing go.mod — a file with nothing to do with
|
||||
# Docker — and found by a red release build.
|
||||
#
|
||||
# It is not one repo's problem either: a sweep of every Dockerfile
|
||||
# across the orgs found 54 below their own go.mod in 23 repos, and
|
||||
# only 7 of 223 Go builder stages set GOTOOLCHAIN=auto. Fixing those
|
||||
# fixes today; this line is what makes the 55th impossible.
|
||||
#
|
||||
# Checked here, before any build work, so the run fails in seconds
|
||||
# rather than after binfmt, a version derivation and a layer cache.
|
||||
"$CI_HOME/bin/gover" "$df" "$ctx"
|
||||
# ---- The semver this build publishes ----------------------------
|
||||
# A branch build used to publish sha-<short7> and nothing else, so an
|
||||
# image only earned a version when a human remembered to cut a git
|
||||
# tag. That is the whole reason 14 of the fleet's 117 pins named a
|
||||
# commit instead of a release: not drift, just the only tag CI ever
|
||||
# offered them. Every build now publishes a version too, and the
|
||||
# sha- tag stays beside it for forensics.
|
||||
#
|
||||
# The number is DERIVED, never typed, by bin/imgver — the ONE
|
||||
# implementation, shared with the hand-rolled
|
||||
# .hanzo/workflows/deploy.yml that 11 repos carry instead of importing
|
||||
# this workflow (see .github/actions/imgver). Written twice it would be
|
||||
# right twice and then wrong once, which is exactly how `sha-<short>`
|
||||
# became the only tag those repos ever published. Read that script for
|
||||
# the rule; it is monotonic against the repo's manifest AND the
|
||||
# registry, so one tag can never cover two digests.
|
||||
ver=""
|
||||
if [ "$IS_TAG" = 1 ]; then
|
||||
# Release build: the git tag IS the version. Identity in, identity
|
||||
# out — deriving here would publish a number nobody asked for.
|
||||
:
|
||||
else
|
||||
# An explicit `version:` on the image entry wins over the repo-wide
|
||||
# one; both are optional, and imgver falls back to the manifest.
|
||||
iv=$(echo "$img"|jq -r '.version // ""')
|
||||
[ -z "$iv" ] && iv=$(yq -r '.version // ""' hanzo.yml)
|
||||
ver=$(IMGVER_VERSION="$iv" "$CI_HOME/bin/imgver" "$repo" "$ctx")
|
||||
fi
|
||||
if [ "$plats" = "linux/amd64" ]; then
|
||||
# single-arch: keep the exact legacy tag shape (-amd64) deploys expect.
|
||||
TAGS="-t $repo:sha-${SHORT}-amd64${sfx:+-$sfx} -t $repo:${sfx:+$sfx-}latest"
|
||||
else
|
||||
# multi-arch: one arch-neutral manifest-list tag (no -amd64 suffix).
|
||||
docker run --privileged --rm tonistiigi/binfmt --install arm64 >/dev/null 2>&1 || true
|
||||
TAGS="-t $repo:sha-${SHORT}${sfx:+-$sfx} -t $repo:${sfx:+$sfx-}latest"
|
||||
fi
|
||||
# Release (tag) build. The git tag IS the release name, so publish it
|
||||
# VERBATIM (v1.26.19) — that is the shape a universe CR pins, and
|
||||
# stripping the v is why releases were finished by hand-`crane copy`ing
|
||||
# sha-<sha7> onto the semver a human typed. Identity in, identity out.
|
||||
# The v-stripped alias stays for CRs already pinned that way (world
|
||||
# 2.4.51), and is skipped when a repo tags without a v. The old
|
||||
# `<ver>-amd64` alias is deleted: no CR in the fleet pinned it.
|
||||
if [ "$IS_TAG" = 1 ]; then
|
||||
TAGS="$TAGS -t $repo:${REL}${sfx:+-$sfx}"
|
||||
[ "$REL" != "$VER" ] && TAGS="$TAGS -t $repo:${VER}${sfx:+-$sfx}"
|
||||
else
|
||||
# The tag universe is meant to PIN. It goes on every build, not
|
||||
# only the ones somebody remembered to tag by hand.
|
||||
TAGS="$TAGS -t $repo:${ver}${sfx:+-$sfx}"
|
||||
fi
|
||||
echo "::group::build $name → $repo (${sfx}) [$plats]"
|
||||
# --build-arg assembly: static hanzo.yml `args` (a fixed value, e.g. a
|
||||
# pinned base image tag) + `build_secrets` (KMS values the KMS step
|
||||
# exported into the env above). Empty when a repo declares neither, so
|
||||
# the buildx line is unchanged for every existing repo.
|
||||
BUILD_ARGS=""
|
||||
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)
|
||||
# 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
|
||||
# PROVENANCE: which commit are these bytes. Passed BOTH ways on
|
||||
# purpose. The --label sets it from outside for any Dockerfile; the
|
||||
# --build-arg feeds `ARG REVISION` for the Dockerfiles that stamp the
|
||||
# label themselves (hanzoai/cloud declares `ARG REVISION=unknown`,
|
||||
# and `unknown` is exactly what every image built without this said).
|
||||
#
|
||||
# An unlabelled image cannot be traced to source, and that stops being
|
||||
# cosmetic the moment two builders race for one tag: with one image
|
||||
# labelled and the other reading `unknown`, "which of these is the
|
||||
# release" had no answer short of diffing layers — while the wrong one
|
||||
# was already pinned. A version is a receipt only if the image can
|
||||
# name its own commit, so every image this fleet publishes now can.
|
||||
#
|
||||
# A Dockerfile with no `ARG REVISION` ignores the build-arg (buildkit
|
||||
# warns, never fails), so this is a no-op for every repo that has not
|
||||
# asked for it.
|
||||
PROV="--build-arg REVISION=$GITHUB_SHA"
|
||||
PROV="$PROV --label org.opencontainers.image.revision=$GITHUB_SHA"
|
||||
PROV="$PROV --label org.opencontainers.image.version=${ver:-$REL}"
|
||||
PROV="$PROV --label org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY"
|
||||
# GIT_TOKEN (from KMS, via GITHUB_ENV) is passed as the `gh_token`
|
||||
# BuildKit secret so Dockerfiles can clone private Go modules; omitted
|
||||
# cleanly when absent (public-only builds unaffected).
|
||||
docker buildx build --platform "$plats" $BUILD_ARGS $PROV ${GIT_TOKEN:+--secret id=gh_token,env=GIT_TOKEN} --push $TAGS -f "$df" "$ctx"
|
||||
# The semver is the tag the fleet PINS, so prove it exists before the
|
||||
# run goes green — buildx can exit 0 while the manifest is not yet
|
||||
# resolvable, and a pin onto a phantom tag is an ImagePullBackOff.
|
||||
# The digest is printed with it because universe pins repo:tag@digest
|
||||
# and the kubelet honours the DIGEST: publishing both here is what
|
||||
# lets the two move together instead of being looked up separately
|
||||
# and drifting apart.
|
||||
pin="$repo:${ver:-$REL}${sfx:+-$sfx}"
|
||||
for i in 1 2 3 4 5 6; do
|
||||
dig=$(docker buildx imagetools inspect "$pin" --format '{{.Manifest.Digest}}' 2>/dev/null || true)
|
||||
[ -n "$dig" ] && break
|
||||
sleep 5
|
||||
done
|
||||
[ -n "$dig" ] || { echo "::error::$pin not resolvable after push"; exit 1; }
|
||||
echo "| \`$pin\` | \`$dig\` |" >> "$RUNNER_TEMP/pins.md"
|
||||
# Dual-host: mirror the exact tag set to registry.hanzo.ai (server-
|
||||
# side manifest copy — no rebuild). ghcr.io/<org>/<name> →
|
||||
# oci.hanzo.ai/<org>/<name>; public consumers keep ghcr, the fleet
|
||||
# is migrating to pull from ours. A skip here is now a WARNING, not
|
||||
# a notice: an image that never reaches our registry is the reason
|
||||
# a deploy still depends on GitHub, and that should be visible in
|
||||
# the run, not buried.
|
||||
if [ "${MIRROR_OK:-}" = "1" ]; then
|
||||
# crane, not buildx imagetools: the IAM token realm doesn't answer
|
||||
# buildx's multi-scope token request (spec gap, tracked).
|
||||
command -v crane >/dev/null 2>&1 || {
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fsSL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz \
|
||||
| tar -xz -C "$HOME/.local/bin" crane
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
}
|
||||
mrepo="oci.hanzo.ai/${repo#*/}"
|
||||
echo "$TAGS" | tr ' ' '\n' | grep -v '^-t$' | grep -v '^$' | while read -r ref; do
|
||||
crane copy "$ref" "${mrepo}:${ref##*:}" \
|
||||
|| echo "::warning::$ref did not reach oci.hanzo.ai (ghcr push unaffected)"
|
||||
done
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
# The pins this run earned, ready to paste into universe. Tag and
|
||||
# digest are printed TOGETHER because they are one fact: the failure
|
||||
# this prevents is a new tag landing beside an old digest, which
|
||||
# reports the new version on the pod and serves the old bytes.
|
||||
if [ -s "$RUNNER_TEMP/pins.md" ]; then
|
||||
{ echo "### Pins"; echo; echo "| image | digest |"; echo "|---|---|";
|
||||
cat "$RUNNER_TEMP/pins.md"; } >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Provision Go toolchain (go test gates on bare runners)
|
||||
# hanzo.yml `test:` gates (e.g. `go vet ./...`, `go test ...`) run
|
||||
# DIRECTLY on the runner, NOT inside a build container — but the stock
|
||||
@@ -775,7 +602,7 @@ jobs:
|
||||
# OWN Go version from go.mod so the toolchain matches the module exactly.
|
||||
# Guarded to Go repos (go.mod present) so pure-JS/TS callers are
|
||||
# unaffected; harmless if a future runner image bakes Go in.
|
||||
if: inputs.mode != 'delegate' && hashFiles('go.mod') != ''
|
||||
if: hashFiles('go.mod') != ''
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -789,7 +616,7 @@ jobs:
|
||||
# fails (snapshot mirror rot: "no longer has a Release file"),
|
||||
# repoint archive.ubuntu.com at the DO mirror — both sources.list and
|
||||
# noble's deb822 ubuntu.sources — and retry once.
|
||||
if: inputs.mode != 'delegate' && hashFiles('go.mod') != ''
|
||||
if: hashFiles('go.mod') != ''
|
||||
run: |
|
||||
command -v gcc >/dev/null 2>&1 && exit 0
|
||||
sudo apt-get update -qq || {
|
||||
@@ -814,12 +641,12 @@ jobs:
|
||||
# root-only hashFiles SKIPPED this step and the gate died at
|
||||
# bash: line 3: pnpm: command not found (exit 127)
|
||||
# after 49 ginkgo suites had already passed.
|
||||
if: inputs.mode != 'delegate' && hashFiles('package.json', '**/package.json') != ''
|
||||
if: hashFiles('package.json', '**/package.json') != ''
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Enable corepack (pnpm/yarn shims for JS test gates)
|
||||
if: inputs.mode != 'delegate' && hashFiles('package.json', '**/package.json') != ''
|
||||
if: hashFiles('package.json', '**/package.json') != ''
|
||||
run: corepack enable
|
||||
|
||||
- name: Provision Rust toolchain (cargo test gates)
|
||||
@@ -831,7 +658,7 @@ jobs:
|
||||
# the exact failure this workflow refuses everywhere else.
|
||||
# Guarded to Rust callers (Cargo.toml present); a no-op once a runner
|
||||
# image bakes rustup in.
|
||||
if: inputs.mode != 'delegate' && hashFiles('Cargo.toml') != ''
|
||||
if: hashFiles('Cargo.toml') != ''
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Provision Java toolchain (client codegen)
|
||||
@@ -1013,16 +840,72 @@ jobs:
|
||||
# unset) — so `go vet` dies with `could not read Username for github.com`.
|
||||
# Reuse the SAME token here for the runner's git (GIT_TOKEN, set by the
|
||||
# KMS step above; GH_PAT fallback). No-op when neither is present.
|
||||
if: inputs.mode != 'delegate' && hashFiles('go.mod') != ''
|
||||
#
|
||||
# AND OUR OWN MODULES RESOLVE FROM OUR OWN FORGE. A module path is a NAME,
|
||||
# not an address: `github.com/<owner>/x` is what the package is called, and
|
||||
# which host git dials for it is a separate decision. On a forge runner
|
||||
# that decision is the forge — the forge is where the code canonically
|
||||
# lives, and github.com is fed by a mirror that trails it, sometimes by
|
||||
# hours and sometimes not at all.
|
||||
#
|
||||
# Measured, on the first run in which this step's gate had ever actually
|
||||
# executed: hanzoai/cloud's go.mod carries `github.com/hanzoai/dashscopego
|
||||
# v0.6.0` as an indirect dependency. git.hanzo.ai serves it; github.com
|
||||
# answers `Repository not found`. So `go vet` died before a single test
|
||||
# ran, on a module that was never missing. Its own caller already knew the
|
||||
# answer — cloud's `containment` and `reach` jobs each make this exact
|
||||
# substitution, with a comment about the nine consecutive releases an
|
||||
# ACL drift on ONE sibling module once blocked — but the gate that runs
|
||||
# its tests reached the modules through a different door.
|
||||
#
|
||||
# go.sum is what makes this safe rather than merely convenient: the forge
|
||||
# serves the same objects, so the fetched zip hashes to the h1: line
|
||||
# already committed. A forge serving different bytes fails the build
|
||||
# loudly instead of shipping them.
|
||||
#
|
||||
# Longest prefix wins in git, so the owner-scoped forge rule outranks the
|
||||
# bare github.com rule beneath it and every OTHER module still resolves
|
||||
# from github.com. Both rules are optional and independent: no FORGE_TOKEN
|
||||
# (or a github.com-hosted run, where the server IS github.com) leaves the
|
||||
# old behaviour exactly as it was.
|
||||
#
|
||||
# SPLIT THE SERVER, do not strip a scheme you assumed. GITHUB_SERVER_URL
|
||||
# on this fleet's runners is `http://hanzo-git.hanzo.svc` — the forge's
|
||||
# in-cluster address, plain http — and a `${SERVER#https://}` that only
|
||||
# knows the https spelling passes it through whole, producing
|
||||
# `https://http://hanzo-git.hanzo.svc/...` and a git that resolves the host
|
||||
# `http`. Take the scheme and the host as the two values they are, and
|
||||
# compare the HOST against github.com so the guard cannot be fooled by a
|
||||
# spelling either.
|
||||
if: hashFiles('go.mod') != ''
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
TOKEN="${GIT_TOKEN:-${GH_PAT:-}}"
|
||||
if [ -z "$TOKEN" ]; then echo "no git token — public modules only"; exit 0; fi
|
||||
CFG="$RUNNER_TEMP/gitconfig-go-test"; : > "$CFG"
|
||||
GIT_CONFIG_GLOBAL="$CFG" git config --global \
|
||||
url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/"
|
||||
SERVER="${GITHUB_SERVER_URL:-https://github.com}"
|
||||
SCHEME="${SERVER%%://*}"; HOST="${SERVER#*://}"; HOST="${HOST%/}"
|
||||
wrote=0
|
||||
|
||||
if [ -n "${FORGE_TOKEN:-}" ] && [ "$HOST" != "github.com" ]; then
|
||||
GIT_CONFIG_GLOBAL="$CFG" git config --global \
|
||||
url."${SCHEME}://x:${FORGE_TOKEN}@${HOST}/${OWNER}/".insteadOf \
|
||||
"https://github.com/${OWNER}/"
|
||||
echo "${OWNER}/* modules resolve from ${SCHEME}://${HOST}"
|
||||
wrote=1
|
||||
fi
|
||||
|
||||
TOKEN="${GIT_TOKEN:-${GH_PAT:-}}"
|
||||
if [ -n "$TOKEN" ]; then
|
||||
GIT_CONFIG_GLOBAL="$CFG" git config --global \
|
||||
url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/"
|
||||
echo "github.com modules authenticated"
|
||||
wrote=1
|
||||
fi
|
||||
|
||||
[ "$wrote" = 1 ] || { echo "no git credential — public modules only"; exit 0; }
|
||||
{ echo "GIT_CONFIG_GLOBAL=$CFG"; echo "GIT_CONFIG_NOSYSTEM=1"; } >> "$GITHUB_ENV"
|
||||
echo "runner git authenticated for private Go modules"
|
||||
|
||||
@@ -1045,7 +928,7 @@ jobs:
|
||||
# it ran nothing AND nothing shows as having run. A gate that is not a test
|
||||
# gate — vet, lint, a build, a codegen-freshness check — says neither and is
|
||||
# untouched. To silence it legitimately, make the gate run a test.
|
||||
if: inputs.mode != 'delegate' && inputs.tests != 'false'
|
||||
if: inputs.tests != 'false'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NOTHING='\[no test files\]|\[no tests to run\]|no tests ran|collected 0 items|No tests found|no test specified|running 0 tests|Tests:[[:space:]]+0 total|(^|[^0-9])0 (passing|passed)'
|
||||
@@ -1074,6 +957,299 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Delegate build to platform (mode=delegate)
|
||||
# Delegate moves WHERE the image is built. It does not move whether the
|
||||
# commit was gated — so this step sits AFTER `Test (per hanzo.yml)`, and
|
||||
# every step the gate needs runs in both modes. It used to sit fourth,
|
||||
# before any of them, and `mode: delegate` therefore skipped the gate
|
||||
# along with the build: 20 steps skipped, 0 executed, green. "ci gates,
|
||||
# platform builds" was the design and the ordering said otherwise.
|
||||
#
|
||||
# What it does: POST each image in hanzo.yml to platform.hanzo.ai's
|
||||
# direct-enqueue webhook (`/v1/runner`). Platform creates a build_job
|
||||
# row, launches an in-cluster BuildKit Job on its own pool, pushes to the
|
||||
# registry, and — for a system service — patches the operator Service CR
|
||||
# to roll it. Downstream is IDENTICAL to the platform webhook path, so a
|
||||
# delegated build behaves exactly like a platform-native one. What this
|
||||
# job skips is buildx, publish and deploy — not the gate.
|
||||
if: inputs.mode == 'delegate'
|
||||
env:
|
||||
ENQUEUE_URL: ${{ vars.PLATFORM_ENQUEUE_URL || 'https://platform.hanzo.ai/v1/runner' }}
|
||||
ENQUEUE_TOKEN: ${{ secrets.PLATFORM_BUILD_CALLBACK_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${ENQUEUE_TOKEN:-}" ]; then
|
||||
echo "::error::mode=delegate needs the PLATFORM_BUILD_CALLBACK_TOKEN secret (secrets: inherit)"; exit 1
|
||||
fi
|
||||
# `/v1/runner` has no way to carry a build_secret: its wire body accepts
|
||||
# buildArgs, and platform resolves NO KMS name — its own secrets arrive
|
||||
# as a KMSSecret CRD with a statically declared key list, which cannot
|
||||
# serve a name discovered by reading a hanzo.yml at some SHA. A repo
|
||||
# that declares build_secrets and delegates gets an image built without
|
||||
# them: green run, empty credential, exactly the silence the KMS step
|
||||
# refuses. Say so here rather than downstream, where this run shows no
|
||||
# logs at all.
|
||||
if [ -n "$(yq -r '[(.images // [])[] | (.build_secrets // [])[]] | unique | .[]' hanzo.yml 2>/dev/null || true)" ]; then
|
||||
echo "::error::hanzo.yml declares build_secrets, which mode=delegate cannot deliver — platform builds with no KMS. Build on the runner (drop mode: delegate), or drop the build_secrets."; exit 1
|
||||
fi
|
||||
REPO="${{ github.repository }}"
|
||||
SHA="${{ github.sha }}"
|
||||
SHORT=$(echo "$SHA" | cut -c1-7)
|
||||
REF="${{ github.ref }}"
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
# One enqueue per (image, platform), mirroring the buildx tag shape the
|
||||
# deploy path expects (`sha-<short>-<arch>[-<suffix>]`). Default arch is
|
||||
# amd64 (single-arch), so an existing repo's tag shape is unchanged.
|
||||
yq -o=json -I=0 '.images' hanzo.yml | jq -c '.[]' | while read -r img; do
|
||||
name=$(echo "$img"|jq -r .name); repo=$(echo "$img"|jq -r .repo)
|
||||
ctx=$(echo "$img"|jq -r .context); df=$(echo "$img"|jq -r '.dockerfile // (.context+"/Dockerfile")')
|
||||
sfx=$(echo "$img"|jq -r '."tag-suffix" // ""')
|
||||
# Same Go-builder check the buildx lane runs. It belongs here too:
|
||||
# delegating the build moves WHERE it runs, not whether the image
|
||||
# can compile the module, and a mismatch enqueued to platform fails
|
||||
# on a machine whose logs this run never shows. Cheap to check on
|
||||
# the runner that already has the checkout; expensive to discover
|
||||
# from the in-cluster builder.
|
||||
"$CI_HOME/bin/gover" "$df" "$ctx"
|
||||
echo "$img" | jq -r '(.platforms // ["linux/amd64"])[]' | while read -r plat; do
|
||||
arch="${plat##*/}"
|
||||
image="${repo}:sha-${SHORT}-${arch}${sfx:+-$sfx}"
|
||||
body=$(jq -nc \
|
||||
--arg repo "$REPO" --arg sha "$SHA" --arg image "$image" \
|
||||
--arg ref "$REF" --arg branch "$BRANCH" \
|
||||
--arg dockerfile "$df" --arg context "$ctx" --arg arch "$arch" \
|
||||
'{repo:$repo,sha:$sha,image:$image,ref:$ref,branch:$branch,dockerfile:$dockerfile,context:$context,os:"linux",arch:$arch}')
|
||||
echo "::group::delegate $name → $image"
|
||||
code=$(curl -sS -o /tmp/enqueue.out -w '%{http_code}' -X POST "$ENQUEUE_URL" \
|
||||
-H "Authorization: Bearer $ENQUEUE_TOKEN" -H 'Content-Type: application/json' -d "$body")
|
||||
cat /tmp/enqueue.out; echo
|
||||
# 202 Accepted = queued; 409 = no live runner for the pool (surface it loud).
|
||||
if [ "$code" != "202" ]; then echo "::error::enqueue $image failed (HTTP $code)"; exit 1; fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
done
|
||||
|
||||
- name: Build & push images (per hanzo.yml)
|
||||
# AFTER the gate, deliberately. This step used to run before `test:`,
|
||||
# so a repo whose gates FAILED still published an image: hanzoai/base
|
||||
# f9ec7067 pushed sha-f9ec706-amd64 on a run whose tests then went red.
|
||||
# Deploy and Publish site were skipped, so nothing shipped it — but the
|
||||
# tag existed, and a tag that exists is something a human pins by hand.
|
||||
# An artifact is a claim that the commit passed; it should not be
|
||||
# possible to produce one from a commit that did not.
|
||||
#
|
||||
# Nothing here needs the toolchains above it, and Deploy still follows
|
||||
# this step, so the only thing the move changes is that a red gate now
|
||||
# produces no image at all.
|
||||
if: inputs.mode != 'delegate'
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Test-only callers (hanzo.yml without `images:` — e.g. a repo whose
|
||||
# image lane lives in its own release.yml, or a pure library) skip the
|
||||
# build step entirely instead of exploding on a null .images.
|
||||
if [ "$(yq -r '.images // [] | length' hanzo.yml 2>/dev/null || echo 0)" = "0" ]; then
|
||||
echo "::notice::no images: in hanzo.yml — test-only caller, skipping build"; exit 0
|
||||
fi
|
||||
# Build-time private cross-org Go module read (the buildx `gh_token`
|
||||
# secret): prefer the KMS-fetched GIT_TOKEN, else fall back to the org
|
||||
# GH_PAT — the SAME BuildKit gh_token cloud's release.yml uses (proven
|
||||
# working). Keeps image builds green when the KMS deploy-cred fetch is
|
||||
# unavailable (a repo with a private cross-org dep like hanzoai/cloud
|
||||
# otherwise fails `go mod tidy` with git exit 128 in the buildx stage).
|
||||
# No-op for public-only builds when both are empty. Exported so the
|
||||
# `--secret id=gh_token,env=GIT_TOKEN` below reads it from the env.
|
||||
export GIT_TOKEN="${GIT_TOKEN:-${GH_PAT:-}}"
|
||||
SHORT=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
IS_TAG=$([ "${{ github.ref_type }}" = "tag" ] && echo 1 || echo 0)
|
||||
REL="${{ github.ref_name }}" # the git tag, verbatim: v1.26.19
|
||||
VER="${REL#v}" # v-stripped alias: 1.26.19
|
||||
yq -o=json -I=0 '.images' hanzo.yml | jq -c '.[]' | while read -r img; do
|
||||
name=$(echo "$img"|jq -r .name); ctx=$(echo "$img"|jq -r .context)
|
||||
df=$(echo "$img"|jq -r '.dockerfile // (.context+"/Dockerfile")'); repo=$(echo "$img"|jq -r .repo)
|
||||
# tag-suffix is OPTIONAL (most repos ship a single variant). When set
|
||||
# (e.g. "ce"/"ee") it qualifies every tag; when absent the tags are
|
||||
# clean (no trailing dash). Build + deploy must agree on this shape.
|
||||
sfx=$(echo "$img"|jq -r '."tag-suffix" // ""')
|
||||
# platforms is OPT-IN per image in hanzo.yml (default: amd64 only, so
|
||||
# every existing repo's tag shape "-amd64" is UNCHANGED). Set e.g.
|
||||
# platforms: [linux/amd64, linux/arm64]
|
||||
# to emit a multi-arch MANIFEST LIST — one digest serving both arches.
|
||||
# DOKS has no arm64 nodes, so arm64 builds via buildx QEMU emulation
|
||||
# (binfmt set up below); pure-Go (CGO_ENABLED=0) Dockerfiles that honor
|
||||
# $TARGETARCH cross-compile natively (fast, no emulation). For true
|
||||
# native-speed arm64, register a bare-metal arm64 host (spark/GB10) as
|
||||
# the hanzo-build-linux-arm64 self-hosted runner (values-build-arm64.yaml).
|
||||
plats=$(echo "$img"|jq -r '(.platforms // ["linux/amd64"]) | join(",")')
|
||||
# ---- Refuse a Go builder older than the module it compiles -------
|
||||
# The official golang images set GOTOOLCHAIN=local, so a go.mod
|
||||
# requiring a newer Go than the base image does not degrade — it
|
||||
# dies mid-build with "go.mod requires go >= X (running Y;
|
||||
# GOTOOLCHAIN=local)". hanzoai/visor v1.108.16 is the shipped
|
||||
# instance, and it is invisible before the image build: every local
|
||||
# `go build` succeeds because a dev box runs GOTOOLCHAIN=auto and
|
||||
# just downloads what the module asks for. So the break is
|
||||
# introduced by editing go.mod — a file with nothing to do with
|
||||
# Docker — and found by a red release build.
|
||||
#
|
||||
# It is not one repo's problem either: a sweep of every Dockerfile
|
||||
# across the orgs found 54 below their own go.mod in 23 repos, and
|
||||
# only 7 of 223 Go builder stages set GOTOOLCHAIN=auto. Fixing those
|
||||
# fixes today; this line is what makes the 55th impossible.
|
||||
#
|
||||
# Checked here, before any build work, so the run fails in seconds
|
||||
# rather than after binfmt, a version derivation and a layer cache.
|
||||
"$CI_HOME/bin/gover" "$df" "$ctx"
|
||||
# ---- The semver this build publishes ----------------------------
|
||||
# A branch build used to publish sha-<short7> and nothing else, so an
|
||||
# image only earned a version when a human remembered to cut a git
|
||||
# tag. That is the whole reason 14 of the fleet's 117 pins named a
|
||||
# commit instead of a release: not drift, just the only tag CI ever
|
||||
# offered them. Every build now publishes a version too, and the
|
||||
# sha- tag stays beside it for forensics.
|
||||
#
|
||||
# The number is DERIVED, never typed, by bin/imgver — the ONE
|
||||
# implementation, shared with the hand-rolled
|
||||
# .hanzo/workflows/deploy.yml that 11 repos carry instead of importing
|
||||
# this workflow (see .github/actions/imgver). Written twice it would be
|
||||
# right twice and then wrong once, which is exactly how `sha-<short>`
|
||||
# became the only tag those repos ever published. Read that script for
|
||||
# the rule; it is monotonic against the repo's manifest AND the
|
||||
# registry, so one tag can never cover two digests.
|
||||
ver=""
|
||||
if [ "$IS_TAG" = 1 ]; then
|
||||
# Release build: the git tag IS the version. Identity in, identity
|
||||
# out — deriving here would publish a number nobody asked for.
|
||||
:
|
||||
else
|
||||
# An explicit `version:` on the image entry wins over the repo-wide
|
||||
# one; both are optional, and imgver falls back to the manifest.
|
||||
iv=$(echo "$img"|jq -r '.version // ""')
|
||||
[ -z "$iv" ] && iv=$(yq -r '.version // ""' hanzo.yml)
|
||||
ver=$(IMGVER_VERSION="$iv" "$CI_HOME/bin/imgver" "$repo" "$ctx")
|
||||
fi
|
||||
if [ "$plats" = "linux/amd64" ]; then
|
||||
# single-arch: keep the exact legacy tag shape (-amd64) deploys expect.
|
||||
TAGS="-t $repo:sha-${SHORT}-amd64${sfx:+-$sfx} -t $repo:${sfx:+$sfx-}latest"
|
||||
else
|
||||
# multi-arch: one arch-neutral manifest-list tag (no -amd64 suffix).
|
||||
docker run --privileged --rm tonistiigi/binfmt --install arm64 >/dev/null 2>&1 || true
|
||||
TAGS="-t $repo:sha-${SHORT}${sfx:+-$sfx} -t $repo:${sfx:+$sfx-}latest"
|
||||
fi
|
||||
# Release (tag) build. The git tag IS the release name, so publish it
|
||||
# VERBATIM (v1.26.19) — that is the shape a universe CR pins, and
|
||||
# stripping the v is why releases were finished by hand-`crane copy`ing
|
||||
# sha-<sha7> onto the semver a human typed. Identity in, identity out.
|
||||
# The v-stripped alias stays for CRs already pinned that way (world
|
||||
# 2.4.51), and is skipped when a repo tags without a v. The old
|
||||
# `<ver>-amd64` alias is deleted: no CR in the fleet pinned it.
|
||||
if [ "$IS_TAG" = 1 ]; then
|
||||
TAGS="$TAGS -t $repo:${REL}${sfx:+-$sfx}"
|
||||
[ "$REL" != "$VER" ] && TAGS="$TAGS -t $repo:${VER}${sfx:+-$sfx}"
|
||||
else
|
||||
# The tag universe is meant to PIN. It goes on every build, not
|
||||
# only the ones somebody remembered to tag by hand.
|
||||
TAGS="$TAGS -t $repo:${ver}${sfx:+-$sfx}"
|
||||
fi
|
||||
echo "::group::build $name → $repo (${sfx}) [$plats]"
|
||||
# --build-arg assembly: static hanzo.yml `args` (a fixed value, e.g. a
|
||||
# pinned base image tag) + `build_secrets` (KMS values the KMS step
|
||||
# exported into the env above). Empty when a repo declares neither, so
|
||||
# the buildx line is unchanged for every existing repo.
|
||||
BUILD_ARGS=""
|
||||
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)
|
||||
# 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
|
||||
# PROVENANCE: which commit are these bytes. Passed BOTH ways on
|
||||
# purpose. The --label sets it from outside for any Dockerfile; the
|
||||
# --build-arg feeds `ARG REVISION` for the Dockerfiles that stamp the
|
||||
# label themselves (hanzoai/cloud declares `ARG REVISION=unknown`,
|
||||
# and `unknown` is exactly what every image built without this said).
|
||||
#
|
||||
# An unlabelled image cannot be traced to source, and that stops being
|
||||
# cosmetic the moment two builders race for one tag: with one image
|
||||
# labelled and the other reading `unknown`, "which of these is the
|
||||
# release" had no answer short of diffing layers — while the wrong one
|
||||
# was already pinned. A version is a receipt only if the image can
|
||||
# name its own commit, so every image this fleet publishes now can.
|
||||
#
|
||||
# A Dockerfile with no `ARG REVISION` ignores the build-arg (buildkit
|
||||
# warns, never fails), so this is a no-op for every repo that has not
|
||||
# asked for it.
|
||||
PROV="--build-arg REVISION=$GITHUB_SHA"
|
||||
PROV="$PROV --label org.opencontainers.image.revision=$GITHUB_SHA"
|
||||
PROV="$PROV --label org.opencontainers.image.version=${ver:-$REL}"
|
||||
PROV="$PROV --label org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY"
|
||||
# GIT_TOKEN (from KMS, via GITHUB_ENV) is passed as the `gh_token`
|
||||
# BuildKit secret so Dockerfiles can clone private Go modules; omitted
|
||||
# cleanly when absent (public-only builds unaffected).
|
||||
docker buildx build --platform "$plats" $BUILD_ARGS $PROV ${GIT_TOKEN:+--secret id=gh_token,env=GIT_TOKEN} --push $TAGS -f "$df" "$ctx"
|
||||
# The semver is the tag the fleet PINS, so prove it exists before the
|
||||
# run goes green — buildx can exit 0 while the manifest is not yet
|
||||
# resolvable, and a pin onto a phantom tag is an ImagePullBackOff.
|
||||
# The digest is printed with it because universe pins repo:tag@digest
|
||||
# and the kubelet honours the DIGEST: publishing both here is what
|
||||
# lets the two move together instead of being looked up separately
|
||||
# and drifting apart.
|
||||
pin="$repo:${ver:-$REL}${sfx:+-$sfx}"
|
||||
for i in 1 2 3 4 5 6; do
|
||||
dig=$(docker buildx imagetools inspect "$pin" --format '{{.Manifest.Digest}}' 2>/dev/null || true)
|
||||
[ -n "$dig" ] && break
|
||||
sleep 5
|
||||
done
|
||||
[ -n "$dig" ] || { echo "::error::$pin not resolvable after push"; exit 1; }
|
||||
echo "| \`$pin\` | \`$dig\` |" >> "$RUNNER_TEMP/pins.md"
|
||||
# Dual-host: mirror the exact tag set to registry.hanzo.ai (server-
|
||||
# side manifest copy — no rebuild). ghcr.io/<org>/<name> →
|
||||
# registry.hanzo.ai/<org>/<name>; public consumers keep ghcr, the fleet
|
||||
# is migrating to pull from ours. A skip here is now a WARNING, not
|
||||
# a notice: an image that never reaches our registry is the reason
|
||||
# a deploy still depends on GitHub, and that should be visible in
|
||||
# the run, not buried.
|
||||
if [ "${MIRROR_OK:-}" = "1" ]; then
|
||||
# crane, not buildx imagetools: the IAM token realm doesn't answer
|
||||
# buildx's multi-scope token request (spec gap, tracked).
|
||||
command -v crane >/dev/null 2>&1 || {
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fsSL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz \
|
||||
| tar -xz -C "$HOME/.local/bin" crane
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
}
|
||||
# ONE mapping, stated once. The GHCR org and the registry org are
|
||||
# not the same word — ghcr.io/hanzoai/x mirrors to
|
||||
# registry.hanzo.ai/hanzo/x — and a second copy of this table is
|
||||
# how a push path and a pin drift into naming different images.
|
||||
rorg="${repo#*/}"; rname="${rorg#*/}"; rorg="${rorg%%/*}"
|
||||
case "$rorg" in
|
||||
hanzoai) rorg=hanzo ;;
|
||||
zooai) rorg=zoo ;;
|
||||
*) ;; # bootnode, lux-private, ... map to themselves
|
||||
esac
|
||||
mrepo="registry.hanzo.ai/$rorg/$rname"
|
||||
echo "$TAGS" | tr ' ' '\n' | grep -v '^-t$' | grep -v '^$' | while read -r ref; do
|
||||
crane copy "$ref" "${mrepo}:${ref##*:}" \
|
||||
|| echo "::warning::$ref did not reach registry.hanzo.ai (ghcr push unaffected)"
|
||||
done
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
# The pins this run earned, ready to paste into universe. Tag and
|
||||
# digest are printed TOGETHER because they are one fact: the failure
|
||||
# this prevents is a new tag landing beside an old digest, which
|
||||
# reports the new version on the pod and serves the old bytes.
|
||||
if [ -s "$RUNNER_TEMP/pins.md" ]; then
|
||||
{ echo "### Pins"; echo; echo "| image | digest |"; echo "|---|---|";
|
||||
cat "$RUNNER_TEMP/pins.md"; } >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Client — commit the projection and cut the patch
|
||||
# LAST, and after `test:` on purpose: this pushes a tag, the tag starts a
|
||||
# publish, and a published client is the one artifact in this workflow
|
||||
|
||||
+463
-287
@@ -29,14 +29,17 @@ on:
|
||||
default: '["hanzo-build-linux-amd64"]'
|
||||
mode:
|
||||
description: >-
|
||||
Build execution mode. `buildx` (default) runs the full buildx →
|
||||
test → deploy pipeline ON the runner. `delegate` instead POSTs the
|
||||
build to platform.hanzo.ai (`/v1/runner`) — platform builds
|
||||
in-cluster with BuildKit and rolls the operator Service CR itself, so
|
||||
the GitHub job finishes in seconds with no runner buildx. A repo opts
|
||||
in by passing `with: { mode: delegate }`; everything else is unchanged.
|
||||
Requires the `PLATFORM_BUILD_CALLBACK_TOKEN` secret (via secrets:
|
||||
inherit).
|
||||
Where the IMAGE is built. `buildx` (default) builds it on this runner.
|
||||
`delegate` POSTs it to platform.hanzo.ai (`/v1/runner`), which builds
|
||||
in-cluster with BuildKit and rolls the operator Service CR itself.
|
||||
A repo opts in with `with: { mode: delegate }` and needs the
|
||||
`PLATFORM_BUILD_CALLBACK_TOKEN` secret (via secrets: inherit).
|
||||
|
||||
THE GATE RUNS IN BOTH MODES, and that is the whole point of the input
|
||||
being named for the build. `delegate` used to be evaluated fourth, ahead
|
||||
of every toolchain and ahead of `test:`, so it skipped the gate along
|
||||
with the build — 20 steps skipped, 0 executed, green. What a delegated
|
||||
run skips is buildx, publish and deploy. It does not skip being tested.
|
||||
type: string
|
||||
default: buildx
|
||||
tests:
|
||||
@@ -73,7 +76,7 @@ on:
|
||||
A string sidesteps it on both planes with identical meaning, and the
|
||||
comparison is against 'false' rather than a truthiness test so that the
|
||||
expression callers already write — hanzoai/cloud's
|
||||
`tests: ${{ github.ref_type != 'tag' }}`, which renders the strings
|
||||
`tests:` set from `github.ref_type != 'tag'`, which renders the strings
|
||||
"true"/"false" — keeps working unchanged.
|
||||
type: string
|
||||
default: 'true'
|
||||
@@ -104,6 +107,14 @@ permissions:
|
||||
jobs:
|
||||
cicd:
|
||||
runs-on: ${{ fromJson(inputs.runner) }}
|
||||
env:
|
||||
# corepack fetches the pnpm/yarn TARBALL ITSELF from registry.npmjs.org,
|
||||
# and it does not read .npmrc to do it — so 317 repos that pin
|
||||
# `packageManager` reach out to npmjs on every install no matter how
|
||||
# carefully their registry is configured. This is the one variable that
|
||||
# closes that hole; without it "installs go through our registry" is true
|
||||
# of the dependencies and false of the package manager fetching them.
|
||||
COREPACK_NPM_REGISTRY: https://pkg.hanzo.ai
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -214,64 +225,7 @@ jobs:
|
||||
jq --version
|
||||
yq --version
|
||||
|
||||
- name: Delegate build to platform (mode=delegate)
|
||||
# The GHA-escape fast path: instead of running buildx on this runner, POST
|
||||
# each image in hanzo.yml to platform.hanzo.ai's direct-enqueue webhook
|
||||
# (`/v1/runner`). Platform creates a build_job row, launches an
|
||||
# in-cluster BuildKit Job on its own pool, pushes to the registry, and —
|
||||
# for a system service — patches the operator Service CR to roll it. The
|
||||
# downstream is IDENTICAL to the platform GitHub-App webhook path (one
|
||||
# build path, two front doors), so a delegated build behaves exactly like
|
||||
# a platform-native one. This job then exits in seconds — no buildx, no
|
||||
# KMS, no runner-side deploy.
|
||||
if: inputs.mode == 'delegate'
|
||||
env:
|
||||
ENQUEUE_URL: ${{ vars.PLATFORM_ENQUEUE_URL || 'https://platform.hanzo.ai/v1/runner' }}
|
||||
ENQUEUE_TOKEN: ${{ secrets.PLATFORM_BUILD_CALLBACK_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${ENQUEUE_TOKEN:-}" ]; then
|
||||
echo "::error::mode=delegate needs the PLATFORM_BUILD_CALLBACK_TOKEN secret (secrets: inherit)"; exit 1
|
||||
fi
|
||||
REPO="${{ github.repository }}"
|
||||
SHA="${{ github.sha }}"
|
||||
SHORT=$(echo "$SHA" | cut -c1-7)
|
||||
REF="${{ github.ref }}"
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
# One enqueue per (image, platform), mirroring the buildx tag shape the
|
||||
# deploy path expects (`sha-<short>-<arch>[-<suffix>]`). Default arch is
|
||||
# amd64 (single-arch), so an existing repo's tag shape is unchanged.
|
||||
yq -o=json -I=0 '.images' hanzo.yml | jq -c '.[]' | while read -r img; do
|
||||
name=$(echo "$img"|jq -r .name); repo=$(echo "$img"|jq -r .repo)
|
||||
ctx=$(echo "$img"|jq -r .context); df=$(echo "$img"|jq -r '.dockerfile // (.context+"/Dockerfile")')
|
||||
sfx=$(echo "$img"|jq -r '."tag-suffix" // ""')
|
||||
# Same Go-builder check the buildx lane runs. It belongs here too:
|
||||
# delegating the build moves WHERE it runs, not whether the image
|
||||
# can compile the module, and a mismatch enqueued to platform fails
|
||||
# on a machine whose logs this run never shows. Cheap to check on
|
||||
# the runner that already has the checkout; expensive to discover
|
||||
# from the in-cluster builder.
|
||||
"$CI_HOME/bin/gover" "$df" "$ctx"
|
||||
echo "$img" | jq -r '(.platforms // ["linux/amd64"])[]' | while read -r plat; do
|
||||
arch="${plat##*/}"
|
||||
image="${repo}:sha-${SHORT}-${arch}${sfx:+-$sfx}"
|
||||
body=$(jq -nc \
|
||||
--arg repo "$REPO" --arg sha "$SHA" --arg image "$image" \
|
||||
--arg ref "$REF" --arg branch "$BRANCH" \
|
||||
--arg dockerfile "$df" --arg context "$ctx" --arg arch "$arch" \
|
||||
'{repo:$repo,sha:$sha,image:$image,ref:$ref,branch:$branch,dockerfile:$dockerfile,context:$context,os:"linux",arch:$arch}')
|
||||
echo "::group::delegate $name → $image"
|
||||
code=$(curl -sS -o /tmp/enqueue.out -w '%{http_code}' -X POST "$ENQUEUE_URL" \
|
||||
-H "Authorization: Bearer $ENQUEUE_TOKEN" -H 'Content-Type: application/json' -d "$body")
|
||||
cat /tmp/enqueue.out; echo
|
||||
# 202 Accepted = queued; 409 = no live runner for the pool (surface it loud).
|
||||
if [ "$code" != "202" ]; then echo "::error::enqueue $image failed (HTTP $code)"; exit 1; fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
done
|
||||
|
||||
- name: Authenticated git for go modules (rate-limit + any private repo)
|
||||
if: inputs.mode != 'delegate'
|
||||
# luxfi/hanzoai/zooai Go modules are PUBLIC, so `go` resolves them through
|
||||
# the default public proxy (proxy.golang.org) + checksum db (sum.golang.org)
|
||||
# — canonical, IMMUTABLE hashes that a force-moved tag can no longer break.
|
||||
@@ -327,7 +281,6 @@ jobs:
|
||||
|
||||
- name: Fetch deploy credentials from KMS
|
||||
id: kms
|
||||
if: inputs.mode != 'delegate'
|
||||
env:
|
||||
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
|
||||
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
|
||||
@@ -367,6 +320,15 @@ jobs:
|
||||
if [ -z "$ORG" ]; then
|
||||
case "${{ github.repository_owner }}" in
|
||||
hanzoai) ORG=hanzo ;;
|
||||
# hanzo-inc is the same TENANT as hanzoai — pay, commerce and the
|
||||
# rest of the company surfaces bill and seal to KMS org `hanzo`.
|
||||
# Without this line ORG fell through to the literal owner,
|
||||
# "hanzo-inc", which is not a KMS org. That still LOOKED fine,
|
||||
# because the read below takes its org from the TOKEN, not the URL
|
||||
# — so the only things that were wrong were the fallback URL and
|
||||
# every error message, which named an org that does not exist and
|
||||
# sent the reader looking for a tenant instead of a credential.
|
||||
hanzo-inc) ORG=hanzo ;;
|
||||
luxfi) ORG=lux ;;
|
||||
zooai) ORG=zoo ;;
|
||||
*) ORG="${{ github.repository_owner }}" ;;
|
||||
@@ -383,6 +345,40 @@ jobs:
|
||||
# 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)"
|
||||
# A build_secret becomes `--build-arg NAME=value` below, and a build-arg
|
||||
# is IN THE PUBLISHED IMAGE — `docker history` prints it to anyone who
|
||||
# can pull. So `build_secrets` can only ever carry values that are
|
||||
# public on purpose: a Vite/Next SPA has no server to read an env from,
|
||||
# so its ingest key must be inlined at build, and inlining it is what
|
||||
# publishing it means. Every other value must not be here at all.
|
||||
#
|
||||
# Nothing checked that. The one repo whose value is genuinely
|
||||
# publishable (hanzoai/ui, a `pk-…` ingest key) asserted the `pk-`
|
||||
# prefix INSIDE ITS OWN Dockerfile — so the check existed once, for one
|
||||
# image, and any repo that added a real credential got no check at all.
|
||||
# Per-repo vigilance is not a mechanism; the assertion belongs here,
|
||||
# where the build-arg is assembled, once, for every caller.
|
||||
#
|
||||
# INLINE, not "$CI_HOME/bin/publishable", and that is forced rather than
|
||||
# chosen. The tools checkout and this workflow resolve from DIFFERENT
|
||||
# places: the caller pins the workflow at a ref its forge resolves, while
|
||||
# the tools step derives its ref from GITHUB_WORKFLOW_REF — which
|
||||
# git.hanzo.ai does not set, so it falls back to `v1` and clones
|
||||
# github.com. Measured on run 36473: `GITHUB_WORKFLOW_REF=<unset>`,
|
||||
# `derived ref=v1`, `cloned https://github.com/hanzoai/ci@v1`. A step
|
||||
# that calls a NEW file under $CI_HOME is therefore broken on every forge
|
||||
# run until a tag moves on a host this repo is not even pinned to.
|
||||
#
|
||||
# The rule is also spelled in bin/publishable, which the `publishable`
|
||||
# gate tests. Two spellings of one rule is the same shape as the two
|
||||
# copies of this file, and it gets the same treatment: a gate refuses
|
||||
# any difference between them. See `publishable-rule-is-one-rule`.
|
||||
for bs in $DECLARED; do
|
||||
case "$bs" in
|
||||
PUBLISHABLE_*|PUBLIC_*|NEXT_PUBLIC_*|EXPO_PUBLIC_*|NUXT_PUBLIC_*|VITE_*|REACT_APP_*|*_PUBLISHABLE|*_PUBLIC) ;;
|
||||
*) echo "::error::build_secret '$bs' does not declare itself publishable, and a build_secret is baked into the image as a --build-arg where \`docker history\` reveals it. Rename it (PUBLISHABLE_*, PUBLIC_*, NEXT_PUBLIC_*, VITE_*, REACT_APP_*) if the value is public on purpose; if it is a real credential it cannot be a build_secret at all."; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
required() { [ -n "$DECLARED" ]; }
|
||||
if [ -z "${KMS_CLIENT_ID:-}" ] || [ -z "$ORG" ]; then
|
||||
if required; then
|
||||
@@ -417,10 +413,35 @@ jobs:
|
||||
if [ -n "$GIT_TOKEN" ]; then echo "::add-mask::$GIT_TOKEN"; echo "GIT_TOKEN=$GIT_TOKEN" >> "$GITHUB_ENV"; fi
|
||||
KUBECONFIG_B64=$(get KUBECONFIG)
|
||||
if [ -n "$KUBECONFIG_B64" ]; then echo "$KUBECONFIG_B64" | base64 -d > "$RUNNER_TEMP/kubeconfig"; echo "kubeconfig=$RUNNER_TEMP/kubeconfig" >> "$GITHUB_OUTPUT"; fi
|
||||
# The REGISTRY credential, and it is fetched UNCONDITIONALLY because
|
||||
# every repo that publishes an image needs it — which is most of them.
|
||||
#
|
||||
# It used to sit inside the `bucket:`/`site:` gate below, and that gate
|
||||
# is about writing bytes to hanzoai/s3, a completely unrelated lane. So
|
||||
# a repo declaring only `images:` — hanzoai/bot, and it is not alone —
|
||||
# asked KMS for nothing and reached the docker login step with empty
|
||||
# credentials. The build then failed at the push with no clue pointing
|
||||
# here, and the obvious "fix" is to paste REGISTRY_USER/REGISTRY_PASSWORD
|
||||
# into the forge's org secrets, which is exactly the CI-only copy the
|
||||
# comment below warns about: a second thing to rotate, and the one
|
||||
# nobody remembers. The contract is that the ONLY things living in the
|
||||
# forge are KMS_CLIENT_ID/SECRET, so the fetch moves out of the gate
|
||||
# rather than the credential moving into GitHub.
|
||||
#
|
||||
# Empty is harmless: nothing is exported when KMS has no such key, and
|
||||
# the login step already falls back to the kubeconfig read for repos
|
||||
# provisioned before these names existed.
|
||||
for k in REGISTRY_USER REGISTRY_PASSWORD; do
|
||||
v=$(get "$k")
|
||||
if [ -n "$v" ]; then
|
||||
[ "$k" = REGISTRY_PASSWORD ] && echo "::add-mask::$v"
|
||||
echo "$k=$v" >> "$GITHUB_ENV"
|
||||
fi
|
||||
done
|
||||
# Object-store credential, for the two lanes that write bytes to
|
||||
# hanzoai/s3: a repo publishing `binaries:` to a bucket:, and a repo
|
||||
# publishing a `site:` (whose build output has to land in the org's own
|
||||
# storage space before /v1/sites can promote it). Same four names the
|
||||
# storage space before /v1/sites can promote it). Same names the
|
||||
# services read at run time, from the same org/path/env — a CI-only copy
|
||||
# of a credential is a second thing to rotate and the one nobody
|
||||
# remembers. One fetch serves both lanes; a repo declaring both does not
|
||||
@@ -521,14 +542,20 @@ jobs:
|
||||
echo "::notice::KMS GHCR token login failed — keeping the earlier login"
|
||||
fi
|
||||
|
||||
- name: Native registry credential (oci.hanzo.ai)
|
||||
- name: Native registry credential (registry.hanzo.ai)
|
||||
if: inputs.mode != 'delegate'
|
||||
env:
|
||||
KUBECONFIG: ${{ steps.kms.outputs.kubeconfig }}
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
# KMS first (exported to GITHUB_ENV by the step above), GitHub secret
|
||||
# second. `env:` here cannot see GITHUB_ENV from THIS step's own env
|
||||
# block, so the KMS values are read out of the environment in `run:`.
|
||||
GH_REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
GH_REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# REGISTRY_USER/PASSWORD may already be in the environment from KMS.
|
||||
REGISTRY_USER="${REGISTRY_USER:-${GH_REGISTRY_USER:-}}"
|
||||
REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-${GH_REGISTRY_PASSWORD:-}}"
|
||||
# Direct credential first (repo/org secret — works on private repos,
|
||||
# where the Free plan hides org secrets... including these; private
|
||||
# repos set them at REPO level). KMS-kubeconfig read is the fallback.
|
||||
@@ -537,14 +564,14 @@ jobs:
|
||||
# cred) must never fail the run — the image still pushes to GHCR, the
|
||||
# primary. Without this guard, bash -e aborts the step and SKIPS the
|
||||
# build entirely (a registry hiccup takes the whole lane red).
|
||||
if echo "$REGISTRY_PASSWORD" | docker login oci.hanzo.ai -u "$REGISTRY_USER" --password-stdin; then
|
||||
if echo "$REGISTRY_PASSWORD" | docker login registry.hanzo.ai -u "$REGISTRY_USER" --password-stdin; then
|
||||
echo "MIRROR_OK=1" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "::notice::registry.hanzo.ai login failed — mirror skipped (GHCR-only push)"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
[ -z "${KUBECONFIG:-}" ] && { echo "::warning::no registry credential and no kubeconfig — image will NOT reach oci.hanzo.ai (ghcr only)"; exit 0; }
|
||||
[ -z "${KUBECONFIG:-}" ] && { echo "::warning::no registry credential and no kubeconfig — image will NOT reach registry.hanzo.ai (ghcr only)"; exit 0; }
|
||||
# Bare runners ship no kubectl — same static provision the deploy
|
||||
# step uses.
|
||||
command -v kubectl >/dev/null 2>&1 || {
|
||||
@@ -555,218 +582,18 @@ jobs:
|
||||
}
|
||||
CFG=$(kubectl -n hanzo get secret registry-credentials -o jsonpath='{.data.\.dockerconfigjson}' 2>/dev/null | base64 -d || true)
|
||||
if [ -z "$CFG" ]; then
|
||||
echo "::warning::registry-credentials not readable from this kubeconfig — image will NOT reach oci.hanzo.ai (ghcr only)"; exit 0
|
||||
echo "::warning::registry-credentials not readable from this kubeconfig — image will NOT reach registry.hanzo.ai (ghcr only)"; exit 0
|
||||
fi
|
||||
USERPASS=$(echo "$CFG" | jq -r '.auths["registry.hanzo.ai"].auth // empty' | base64 -d)
|
||||
[ -z "$USERPASS" ] && { echo "::warning::no registry auth in dockerconfig — image will NOT reach oci.hanzo.ai (ghcr only)"; exit 0; }
|
||||
[ -z "$USERPASS" ] && { echo "::warning::no registry auth in dockerconfig — image will NOT reach registry.hanzo.ai (ghcr only)"; exit 0; }
|
||||
echo "::add-mask::${USERPASS#*:}"
|
||||
# Best-effort: login failure → skip mirror, never fail the run (see above).
|
||||
if echo "${USERPASS#*:}" | docker login oci.hanzo.ai -u "${USERPASS%%:*}" --password-stdin; then
|
||||
if echo "${USERPASS#*:}" | docker login registry.hanzo.ai -u "${USERPASS%%:*}" --password-stdin; then
|
||||
echo "MIRROR_OK=1" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "::notice::registry.hanzo.ai login failed — mirror skipped (GHCR-only push)"
|
||||
fi
|
||||
|
||||
- name: Build & push images (per hanzo.yml)
|
||||
if: inputs.mode != 'delegate'
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Test-only callers (hanzo.yml without `images:` — e.g. a repo whose
|
||||
# image lane lives in its own release.yml, or a pure library) skip the
|
||||
# build step entirely instead of exploding on a null .images.
|
||||
if [ "$(yq -r '.images // [] | length' hanzo.yml 2>/dev/null || echo 0)" = "0" ]; then
|
||||
echo "::notice::no images: in hanzo.yml — test-only caller, skipping build"; exit 0
|
||||
fi
|
||||
# Build-time private cross-org Go module read (the buildx `gh_token`
|
||||
# secret): prefer the KMS-fetched GIT_TOKEN, else fall back to the org
|
||||
# GH_PAT — the SAME BuildKit gh_token cloud's release.yml uses (proven
|
||||
# working). Keeps image builds green when the KMS deploy-cred fetch is
|
||||
# unavailable (a repo with a private cross-org dep like hanzoai/cloud
|
||||
# otherwise fails `go mod tidy` with git exit 128 in the buildx stage).
|
||||
# No-op for public-only builds when both are empty. Exported so the
|
||||
# `--secret id=gh_token,env=GIT_TOKEN` below reads it from the env.
|
||||
export GIT_TOKEN="${GIT_TOKEN:-${GH_PAT:-}}"
|
||||
SHORT=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
IS_TAG=$([ "${{ github.ref_type }}" = "tag" ] && echo 1 || echo 0)
|
||||
REL="${{ github.ref_name }}" # the git tag, verbatim: v1.26.19
|
||||
VER="${REL#v}" # v-stripped alias: 1.26.19
|
||||
yq -o=json -I=0 '.images' hanzo.yml | jq -c '.[]' | while read -r img; do
|
||||
name=$(echo "$img"|jq -r .name); ctx=$(echo "$img"|jq -r .context)
|
||||
df=$(echo "$img"|jq -r '.dockerfile // (.context+"/Dockerfile")'); repo=$(echo "$img"|jq -r .repo)
|
||||
# tag-suffix is OPTIONAL (most repos ship a single variant). When set
|
||||
# (e.g. "ce"/"ee") it qualifies every tag; when absent the tags are
|
||||
# clean (no trailing dash). Build + deploy must agree on this shape.
|
||||
sfx=$(echo "$img"|jq -r '."tag-suffix" // ""')
|
||||
# platforms is OPT-IN per image in hanzo.yml (default: amd64 only, so
|
||||
# every existing repo's tag shape "-amd64" is UNCHANGED). Set e.g.
|
||||
# platforms: [linux/amd64, linux/arm64]
|
||||
# to emit a multi-arch MANIFEST LIST — one digest serving both arches.
|
||||
# DOKS has no arm64 nodes, so arm64 builds via buildx QEMU emulation
|
||||
# (binfmt set up below); pure-Go (CGO_ENABLED=0) Dockerfiles that honor
|
||||
# $TARGETARCH cross-compile natively (fast, no emulation). For true
|
||||
# native-speed arm64, register a bare-metal arm64 host (spark/GB10) as
|
||||
# the hanzo-build-linux-arm64 self-hosted runner (values-build-arm64.yaml).
|
||||
plats=$(echo "$img"|jq -r '(.platforms // ["linux/amd64"]) | join(",")')
|
||||
# ---- Refuse a Go builder older than the module it compiles -------
|
||||
# The official golang images set GOTOOLCHAIN=local, so a go.mod
|
||||
# requiring a newer Go than the base image does not degrade — it
|
||||
# dies mid-build with "go.mod requires go >= X (running Y;
|
||||
# GOTOOLCHAIN=local)". hanzoai/visor v1.108.16 is the shipped
|
||||
# instance, and it is invisible before the image build: every local
|
||||
# `go build` succeeds because a dev box runs GOTOOLCHAIN=auto and
|
||||
# just downloads what the module asks for. So the break is
|
||||
# introduced by editing go.mod — a file with nothing to do with
|
||||
# Docker — and found by a red release build.
|
||||
#
|
||||
# It is not one repo's problem either: a sweep of every Dockerfile
|
||||
# across the orgs found 54 below their own go.mod in 23 repos, and
|
||||
# only 7 of 223 Go builder stages set GOTOOLCHAIN=auto. Fixing those
|
||||
# fixes today; this line is what makes the 55th impossible.
|
||||
#
|
||||
# Checked here, before any build work, so the run fails in seconds
|
||||
# rather than after binfmt, a version derivation and a layer cache.
|
||||
"$CI_HOME/bin/gover" "$df" "$ctx"
|
||||
# ---- The semver this build publishes ----------------------------
|
||||
# A branch build used to publish sha-<short7> and nothing else, so an
|
||||
# image only earned a version when a human remembered to cut a git
|
||||
# tag. That is the whole reason 14 of the fleet's 117 pins named a
|
||||
# commit instead of a release: not drift, just the only tag CI ever
|
||||
# offered them. Every build now publishes a version too, and the
|
||||
# sha- tag stays beside it for forensics.
|
||||
#
|
||||
# The number is DERIVED, never typed, by bin/imgver — the ONE
|
||||
# implementation, shared with the hand-rolled
|
||||
# .hanzo/workflows/deploy.yml that 11 repos carry instead of importing
|
||||
# this workflow (see .github/actions/imgver). Written twice it would be
|
||||
# right twice and then wrong once, which is exactly how `sha-<short>`
|
||||
# became the only tag those repos ever published. Read that script for
|
||||
# the rule; it is monotonic against the repo's manifest AND the
|
||||
# registry, so one tag can never cover two digests.
|
||||
ver=""
|
||||
if [ "$IS_TAG" = 1 ]; then
|
||||
# Release build: the git tag IS the version. Identity in, identity
|
||||
# out — deriving here would publish a number nobody asked for.
|
||||
:
|
||||
else
|
||||
# An explicit `version:` on the image entry wins over the repo-wide
|
||||
# one; both are optional, and imgver falls back to the manifest.
|
||||
iv=$(echo "$img"|jq -r '.version // ""')
|
||||
[ -z "$iv" ] && iv=$(yq -r '.version // ""' hanzo.yml)
|
||||
ver=$(IMGVER_VERSION="$iv" "$CI_HOME/bin/imgver" "$repo" "$ctx")
|
||||
fi
|
||||
if [ "$plats" = "linux/amd64" ]; then
|
||||
# single-arch: keep the exact legacy tag shape (-amd64) deploys expect.
|
||||
TAGS="-t $repo:sha-${SHORT}-amd64${sfx:+-$sfx} -t $repo:${sfx:+$sfx-}latest"
|
||||
else
|
||||
# multi-arch: one arch-neutral manifest-list tag (no -amd64 suffix).
|
||||
docker run --privileged --rm tonistiigi/binfmt --install arm64 >/dev/null 2>&1 || true
|
||||
TAGS="-t $repo:sha-${SHORT}${sfx:+-$sfx} -t $repo:${sfx:+$sfx-}latest"
|
||||
fi
|
||||
# Release (tag) build. The git tag IS the release name, so publish it
|
||||
# VERBATIM (v1.26.19) — that is the shape a universe CR pins, and
|
||||
# stripping the v is why releases were finished by hand-`crane copy`ing
|
||||
# sha-<sha7> onto the semver a human typed. Identity in, identity out.
|
||||
# The v-stripped alias stays for CRs already pinned that way (world
|
||||
# 2.4.51), and is skipped when a repo tags without a v. The old
|
||||
# `<ver>-amd64` alias is deleted: no CR in the fleet pinned it.
|
||||
if [ "$IS_TAG" = 1 ]; then
|
||||
TAGS="$TAGS -t $repo:${REL}${sfx:+-$sfx}"
|
||||
[ "$REL" != "$VER" ] && TAGS="$TAGS -t $repo:${VER}${sfx:+-$sfx}"
|
||||
else
|
||||
# The tag universe is meant to PIN. It goes on every build, not
|
||||
# only the ones somebody remembered to tag by hand.
|
||||
TAGS="$TAGS -t $repo:${ver}${sfx:+-$sfx}"
|
||||
fi
|
||||
echo "::group::build $name → $repo (${sfx}) [$plats]"
|
||||
# --build-arg assembly: static hanzo.yml `args` (a fixed value, e.g. a
|
||||
# pinned base image tag) + `build_secrets` (KMS values the KMS step
|
||||
# exported into the env above). Empty when a repo declares neither, so
|
||||
# the buildx line is unchanged for every existing repo.
|
||||
BUILD_ARGS=""
|
||||
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)
|
||||
# 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
|
||||
# PROVENANCE: which commit are these bytes. Passed BOTH ways on
|
||||
# purpose. The --label sets it from outside for any Dockerfile; the
|
||||
# --build-arg feeds `ARG REVISION` for the Dockerfiles that stamp the
|
||||
# label themselves (hanzoai/cloud declares `ARG REVISION=unknown`,
|
||||
# and `unknown` is exactly what every image built without this said).
|
||||
#
|
||||
# An unlabelled image cannot be traced to source, and that stops being
|
||||
# cosmetic the moment two builders race for one tag: with one image
|
||||
# labelled and the other reading `unknown`, "which of these is the
|
||||
# release" had no answer short of diffing layers — while the wrong one
|
||||
# was already pinned. A version is a receipt only if the image can
|
||||
# name its own commit, so every image this fleet publishes now can.
|
||||
#
|
||||
# A Dockerfile with no `ARG REVISION` ignores the build-arg (buildkit
|
||||
# warns, never fails), so this is a no-op for every repo that has not
|
||||
# asked for it.
|
||||
PROV="--build-arg REVISION=$GITHUB_SHA"
|
||||
PROV="$PROV --label org.opencontainers.image.revision=$GITHUB_SHA"
|
||||
PROV="$PROV --label org.opencontainers.image.version=${ver:-$REL}"
|
||||
PROV="$PROV --label org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY"
|
||||
# GIT_TOKEN (from KMS, via GITHUB_ENV) is passed as the `gh_token`
|
||||
# BuildKit secret so Dockerfiles can clone private Go modules; omitted
|
||||
# cleanly when absent (public-only builds unaffected).
|
||||
docker buildx build --platform "$plats" $BUILD_ARGS $PROV ${GIT_TOKEN:+--secret id=gh_token,env=GIT_TOKEN} --push $TAGS -f "$df" "$ctx"
|
||||
# The semver is the tag the fleet PINS, so prove it exists before the
|
||||
# run goes green — buildx can exit 0 while the manifest is not yet
|
||||
# resolvable, and a pin onto a phantom tag is an ImagePullBackOff.
|
||||
# The digest is printed with it because universe pins repo:tag@digest
|
||||
# and the kubelet honours the DIGEST: publishing both here is what
|
||||
# lets the two move together instead of being looked up separately
|
||||
# and drifting apart.
|
||||
pin="$repo:${ver:-$REL}${sfx:+-$sfx}"
|
||||
for i in 1 2 3 4 5 6; do
|
||||
dig=$(docker buildx imagetools inspect "$pin" --format '{{.Manifest.Digest}}' 2>/dev/null || true)
|
||||
[ -n "$dig" ] && break
|
||||
sleep 5
|
||||
done
|
||||
[ -n "$dig" ] || { echo "::error::$pin not resolvable after push"; exit 1; }
|
||||
echo "| \`$pin\` | \`$dig\` |" >> "$RUNNER_TEMP/pins.md"
|
||||
# Dual-host: mirror the exact tag set to registry.hanzo.ai (server-
|
||||
# side manifest copy — no rebuild). ghcr.io/<org>/<name> →
|
||||
# oci.hanzo.ai/<org>/<name>; public consumers keep ghcr, the fleet
|
||||
# is migrating to pull from ours. A skip here is now a WARNING, not
|
||||
# a notice: an image that never reaches our registry is the reason
|
||||
# a deploy still depends on GitHub, and that should be visible in
|
||||
# the run, not buried.
|
||||
if [ "${MIRROR_OK:-}" = "1" ]; then
|
||||
# crane, not buildx imagetools: the IAM token realm doesn't answer
|
||||
# buildx's multi-scope token request (spec gap, tracked).
|
||||
command -v crane >/dev/null 2>&1 || {
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fsSL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz \
|
||||
| tar -xz -C "$HOME/.local/bin" crane
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
}
|
||||
mrepo="oci.hanzo.ai/${repo#*/}"
|
||||
echo "$TAGS" | tr ' ' '\n' | grep -v '^-t$' | grep -v '^$' | while read -r ref; do
|
||||
crane copy "$ref" "${mrepo}:${ref##*:}" \
|
||||
|| echo "::warning::$ref did not reach oci.hanzo.ai (ghcr push unaffected)"
|
||||
done
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
# The pins this run earned, ready to paste into universe. Tag and
|
||||
# digest are printed TOGETHER because they are one fact: the failure
|
||||
# this prevents is a new tag landing beside an old digest, which
|
||||
# reports the new version on the pod and serves the old bytes.
|
||||
if [ -s "$RUNNER_TEMP/pins.md" ]; then
|
||||
{ echo "### Pins"; echo; echo "| image | digest |"; echo "|---|---|";
|
||||
cat "$RUNNER_TEMP/pins.md"; } >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Provision Go toolchain (go test gates on bare runners)
|
||||
# hanzo.yml `test:` gates (e.g. `go vet ./...`, `go test ...`) run
|
||||
# DIRECTLY on the runner, NOT inside a build container — but the stock
|
||||
@@ -775,7 +602,7 @@ jobs:
|
||||
# OWN Go version from go.mod so the toolchain matches the module exactly.
|
||||
# Guarded to Go repos (go.mod present) so pure-JS/TS callers are
|
||||
# unaffected; harmless if a future runner image bakes Go in.
|
||||
if: inputs.mode != 'delegate' && hashFiles('go.mod') != ''
|
||||
if: hashFiles('go.mod') != ''
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
@@ -789,7 +616,7 @@ jobs:
|
||||
# fails (snapshot mirror rot: "no longer has a Release file"),
|
||||
# repoint archive.ubuntu.com at the DO mirror — both sources.list and
|
||||
# noble's deb822 ubuntu.sources — and retry once.
|
||||
if: inputs.mode != 'delegate' && hashFiles('go.mod') != ''
|
||||
if: hashFiles('go.mod') != ''
|
||||
run: |
|
||||
command -v gcc >/dev/null 2>&1 && exit 0
|
||||
sudo apt-get update -qq || {
|
||||
@@ -814,12 +641,12 @@ jobs:
|
||||
# root-only hashFiles SKIPPED this step and the gate died at
|
||||
# bash: line 3: pnpm: command not found (exit 127)
|
||||
# after 49 ginkgo suites had already passed.
|
||||
if: inputs.mode != 'delegate' && hashFiles('package.json', '**/package.json') != ''
|
||||
if: hashFiles('package.json', '**/package.json') != ''
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Enable corepack (pnpm/yarn shims for JS test gates)
|
||||
if: inputs.mode != 'delegate' && hashFiles('package.json', '**/package.json') != ''
|
||||
if: hashFiles('package.json', '**/package.json') != ''
|
||||
run: corepack enable
|
||||
|
||||
- name: Provision Rust toolchain (cargo test gates)
|
||||
@@ -831,7 +658,7 @@ jobs:
|
||||
# the exact failure this workflow refuses everywhere else.
|
||||
# Guarded to Rust callers (Cargo.toml present); a no-op once a runner
|
||||
# image bakes rustup in.
|
||||
if: inputs.mode != 'delegate' && hashFiles('Cargo.toml') != ''
|
||||
if: hashFiles('Cargo.toml') != ''
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Provision Java toolchain (client codegen)
|
||||
@@ -1013,16 +840,72 @@ jobs:
|
||||
# unset) — so `go vet` dies with `could not read Username for github.com`.
|
||||
# Reuse the SAME token here for the runner's git (GIT_TOKEN, set by the
|
||||
# KMS step above; GH_PAT fallback). No-op when neither is present.
|
||||
if: inputs.mode != 'delegate' && hashFiles('go.mod') != ''
|
||||
#
|
||||
# AND OUR OWN MODULES RESOLVE FROM OUR OWN FORGE. A module path is a NAME,
|
||||
# not an address: `github.com/<owner>/x` is what the package is called, and
|
||||
# which host git dials for it is a separate decision. On a forge runner
|
||||
# that decision is the forge — the forge is where the code canonically
|
||||
# lives, and github.com is fed by a mirror that trails it, sometimes by
|
||||
# hours and sometimes not at all.
|
||||
#
|
||||
# Measured, on the first run in which this step's gate had ever actually
|
||||
# executed: hanzoai/cloud's go.mod carries `github.com/hanzoai/dashscopego
|
||||
# v0.6.0` as an indirect dependency. git.hanzo.ai serves it; github.com
|
||||
# answers `Repository not found`. So `go vet` died before a single test
|
||||
# ran, on a module that was never missing. Its own caller already knew the
|
||||
# answer — cloud's `containment` and `reach` jobs each make this exact
|
||||
# substitution, with a comment about the nine consecutive releases an
|
||||
# ACL drift on ONE sibling module once blocked — but the gate that runs
|
||||
# its tests reached the modules through a different door.
|
||||
#
|
||||
# go.sum is what makes this safe rather than merely convenient: the forge
|
||||
# serves the same objects, so the fetched zip hashes to the h1: line
|
||||
# already committed. A forge serving different bytes fails the build
|
||||
# loudly instead of shipping them.
|
||||
#
|
||||
# Longest prefix wins in git, so the owner-scoped forge rule outranks the
|
||||
# bare github.com rule beneath it and every OTHER module still resolves
|
||||
# from github.com. Both rules are optional and independent: no FORGE_TOKEN
|
||||
# (or a github.com-hosted run, where the server IS github.com) leaves the
|
||||
# old behaviour exactly as it was.
|
||||
#
|
||||
# SPLIT THE SERVER, do not strip a scheme you assumed. GITHUB_SERVER_URL
|
||||
# on this fleet's runners is `http://hanzo-git.hanzo.svc` — the forge's
|
||||
# in-cluster address, plain http — and a `${SERVER#https://}` that only
|
||||
# knows the https spelling passes it through whole, producing
|
||||
# `https://http://hanzo-git.hanzo.svc/...` and a git that resolves the host
|
||||
# `http`. Take the scheme and the host as the two values they are, and
|
||||
# compare the HOST against github.com so the guard cannot be fooled by a
|
||||
# spelling either.
|
||||
if: hashFiles('go.mod') != ''
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
TOKEN="${GIT_TOKEN:-${GH_PAT:-}}"
|
||||
if [ -z "$TOKEN" ]; then echo "no git token — public modules only"; exit 0; fi
|
||||
CFG="$RUNNER_TEMP/gitconfig-go-test"; : > "$CFG"
|
||||
GIT_CONFIG_GLOBAL="$CFG" git config --global \
|
||||
url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/"
|
||||
SERVER="${GITHUB_SERVER_URL:-https://github.com}"
|
||||
SCHEME="${SERVER%%://*}"; HOST="${SERVER#*://}"; HOST="${HOST%/}"
|
||||
wrote=0
|
||||
|
||||
if [ -n "${FORGE_TOKEN:-}" ] && [ "$HOST" != "github.com" ]; then
|
||||
GIT_CONFIG_GLOBAL="$CFG" git config --global \
|
||||
url."${SCHEME}://x:${FORGE_TOKEN}@${HOST}/${OWNER}/".insteadOf \
|
||||
"https://github.com/${OWNER}/"
|
||||
echo "${OWNER}/* modules resolve from ${SCHEME}://${HOST}"
|
||||
wrote=1
|
||||
fi
|
||||
|
||||
TOKEN="${GIT_TOKEN:-${GH_PAT:-}}"
|
||||
if [ -n "$TOKEN" ]; then
|
||||
GIT_CONFIG_GLOBAL="$CFG" git config --global \
|
||||
url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/"
|
||||
echo "github.com modules authenticated"
|
||||
wrote=1
|
||||
fi
|
||||
|
||||
[ "$wrote" = 1 ] || { echo "no git credential — public modules only"; exit 0; }
|
||||
{ echo "GIT_CONFIG_GLOBAL=$CFG"; echo "GIT_CONFIG_NOSYSTEM=1"; } >> "$GITHUB_ENV"
|
||||
echo "runner git authenticated for private Go modules"
|
||||
|
||||
@@ -1045,7 +928,7 @@ jobs:
|
||||
# it ran nothing AND nothing shows as having run. A gate that is not a test
|
||||
# gate — vet, lint, a build, a codegen-freshness check — says neither and is
|
||||
# untouched. To silence it legitimately, make the gate run a test.
|
||||
if: inputs.mode != 'delegate' && inputs.tests != 'false'
|
||||
if: inputs.tests != 'false'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NOTHING='\[no test files\]|\[no tests to run\]|no tests ran|collected 0 items|No tests found|no test specified|running 0 tests|Tests:[[:space:]]+0 total|(^|[^0-9])0 (passing|passed)'
|
||||
@@ -1074,6 +957,299 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Delegate build to platform (mode=delegate)
|
||||
# Delegate moves WHERE the image is built. It does not move whether the
|
||||
# commit was gated — so this step sits AFTER `Test (per hanzo.yml)`, and
|
||||
# every step the gate needs runs in both modes. It used to sit fourth,
|
||||
# before any of them, and `mode: delegate` therefore skipped the gate
|
||||
# along with the build: 20 steps skipped, 0 executed, green. "ci gates,
|
||||
# platform builds" was the design and the ordering said otherwise.
|
||||
#
|
||||
# What it does: POST each image in hanzo.yml to platform.hanzo.ai's
|
||||
# direct-enqueue webhook (`/v1/runner`). Platform creates a build_job
|
||||
# row, launches an in-cluster BuildKit Job on its own pool, pushes to the
|
||||
# registry, and — for a system service — patches the operator Service CR
|
||||
# to roll it. Downstream is IDENTICAL to the platform webhook path, so a
|
||||
# delegated build behaves exactly like a platform-native one. What this
|
||||
# job skips is buildx, publish and deploy — not the gate.
|
||||
if: inputs.mode == 'delegate'
|
||||
env:
|
||||
ENQUEUE_URL: ${{ vars.PLATFORM_ENQUEUE_URL || 'https://platform.hanzo.ai/v1/runner' }}
|
||||
ENQUEUE_TOKEN: ${{ secrets.PLATFORM_BUILD_CALLBACK_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${ENQUEUE_TOKEN:-}" ]; then
|
||||
echo "::error::mode=delegate needs the PLATFORM_BUILD_CALLBACK_TOKEN secret (secrets: inherit)"; exit 1
|
||||
fi
|
||||
# `/v1/runner` has no way to carry a build_secret: its wire body accepts
|
||||
# buildArgs, and platform resolves NO KMS name — its own secrets arrive
|
||||
# as a KMSSecret CRD with a statically declared key list, which cannot
|
||||
# serve a name discovered by reading a hanzo.yml at some SHA. A repo
|
||||
# that declares build_secrets and delegates gets an image built without
|
||||
# them: green run, empty credential, exactly the silence the KMS step
|
||||
# refuses. Say so here rather than downstream, where this run shows no
|
||||
# logs at all.
|
||||
if [ -n "$(yq -r '[(.images // [])[] | (.build_secrets // [])[]] | unique | .[]' hanzo.yml 2>/dev/null || true)" ]; then
|
||||
echo "::error::hanzo.yml declares build_secrets, which mode=delegate cannot deliver — platform builds with no KMS. Build on the runner (drop mode: delegate), or drop the build_secrets."; exit 1
|
||||
fi
|
||||
REPO="${{ github.repository }}"
|
||||
SHA="${{ github.sha }}"
|
||||
SHORT=$(echo "$SHA" | cut -c1-7)
|
||||
REF="${{ github.ref }}"
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
# One enqueue per (image, platform), mirroring the buildx tag shape the
|
||||
# deploy path expects (`sha-<short>-<arch>[-<suffix>]`). Default arch is
|
||||
# amd64 (single-arch), so an existing repo's tag shape is unchanged.
|
||||
yq -o=json -I=0 '.images' hanzo.yml | jq -c '.[]' | while read -r img; do
|
||||
name=$(echo "$img"|jq -r .name); repo=$(echo "$img"|jq -r .repo)
|
||||
ctx=$(echo "$img"|jq -r .context); df=$(echo "$img"|jq -r '.dockerfile // (.context+"/Dockerfile")')
|
||||
sfx=$(echo "$img"|jq -r '."tag-suffix" // ""')
|
||||
# Same Go-builder check the buildx lane runs. It belongs here too:
|
||||
# delegating the build moves WHERE it runs, not whether the image
|
||||
# can compile the module, and a mismatch enqueued to platform fails
|
||||
# on a machine whose logs this run never shows. Cheap to check on
|
||||
# the runner that already has the checkout; expensive to discover
|
||||
# from the in-cluster builder.
|
||||
"$CI_HOME/bin/gover" "$df" "$ctx"
|
||||
echo "$img" | jq -r '(.platforms // ["linux/amd64"])[]' | while read -r plat; do
|
||||
arch="${plat##*/}"
|
||||
image="${repo}:sha-${SHORT}-${arch}${sfx:+-$sfx}"
|
||||
body=$(jq -nc \
|
||||
--arg repo "$REPO" --arg sha "$SHA" --arg image "$image" \
|
||||
--arg ref "$REF" --arg branch "$BRANCH" \
|
||||
--arg dockerfile "$df" --arg context "$ctx" --arg arch "$arch" \
|
||||
'{repo:$repo,sha:$sha,image:$image,ref:$ref,branch:$branch,dockerfile:$dockerfile,context:$context,os:"linux",arch:$arch}')
|
||||
echo "::group::delegate $name → $image"
|
||||
code=$(curl -sS -o /tmp/enqueue.out -w '%{http_code}' -X POST "$ENQUEUE_URL" \
|
||||
-H "Authorization: Bearer $ENQUEUE_TOKEN" -H 'Content-Type: application/json' -d "$body")
|
||||
cat /tmp/enqueue.out; echo
|
||||
# 202 Accepted = queued; 409 = no live runner for the pool (surface it loud).
|
||||
if [ "$code" != "202" ]; then echo "::error::enqueue $image failed (HTTP $code)"; exit 1; fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
done
|
||||
|
||||
- name: Build & push images (per hanzo.yml)
|
||||
# AFTER the gate, deliberately. This step used to run before `test:`,
|
||||
# so a repo whose gates FAILED still published an image: hanzoai/base
|
||||
# f9ec7067 pushed sha-f9ec706-amd64 on a run whose tests then went red.
|
||||
# Deploy and Publish site were skipped, so nothing shipped it — but the
|
||||
# tag existed, and a tag that exists is something a human pins by hand.
|
||||
# An artifact is a claim that the commit passed; it should not be
|
||||
# possible to produce one from a commit that did not.
|
||||
#
|
||||
# Nothing here needs the toolchains above it, and Deploy still follows
|
||||
# this step, so the only thing the move changes is that a red gate now
|
||||
# produces no image at all.
|
||||
if: inputs.mode != 'delegate'
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Test-only callers (hanzo.yml without `images:` — e.g. a repo whose
|
||||
# image lane lives in its own release.yml, or a pure library) skip the
|
||||
# build step entirely instead of exploding on a null .images.
|
||||
if [ "$(yq -r '.images // [] | length' hanzo.yml 2>/dev/null || echo 0)" = "0" ]; then
|
||||
echo "::notice::no images: in hanzo.yml — test-only caller, skipping build"; exit 0
|
||||
fi
|
||||
# Build-time private cross-org Go module read (the buildx `gh_token`
|
||||
# secret): prefer the KMS-fetched GIT_TOKEN, else fall back to the org
|
||||
# GH_PAT — the SAME BuildKit gh_token cloud's release.yml uses (proven
|
||||
# working). Keeps image builds green when the KMS deploy-cred fetch is
|
||||
# unavailable (a repo with a private cross-org dep like hanzoai/cloud
|
||||
# otherwise fails `go mod tidy` with git exit 128 in the buildx stage).
|
||||
# No-op for public-only builds when both are empty. Exported so the
|
||||
# `--secret id=gh_token,env=GIT_TOKEN` below reads it from the env.
|
||||
export GIT_TOKEN="${GIT_TOKEN:-${GH_PAT:-}}"
|
||||
SHORT=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
IS_TAG=$([ "${{ github.ref_type }}" = "tag" ] && echo 1 || echo 0)
|
||||
REL="${{ github.ref_name }}" # the git tag, verbatim: v1.26.19
|
||||
VER="${REL#v}" # v-stripped alias: 1.26.19
|
||||
yq -o=json -I=0 '.images' hanzo.yml | jq -c '.[]' | while read -r img; do
|
||||
name=$(echo "$img"|jq -r .name); ctx=$(echo "$img"|jq -r .context)
|
||||
df=$(echo "$img"|jq -r '.dockerfile // (.context+"/Dockerfile")'); repo=$(echo "$img"|jq -r .repo)
|
||||
# tag-suffix is OPTIONAL (most repos ship a single variant). When set
|
||||
# (e.g. "ce"/"ee") it qualifies every tag; when absent the tags are
|
||||
# clean (no trailing dash). Build + deploy must agree on this shape.
|
||||
sfx=$(echo "$img"|jq -r '."tag-suffix" // ""')
|
||||
# platforms is OPT-IN per image in hanzo.yml (default: amd64 only, so
|
||||
# every existing repo's tag shape "-amd64" is UNCHANGED). Set e.g.
|
||||
# platforms: [linux/amd64, linux/arm64]
|
||||
# to emit a multi-arch MANIFEST LIST — one digest serving both arches.
|
||||
# DOKS has no arm64 nodes, so arm64 builds via buildx QEMU emulation
|
||||
# (binfmt set up below); pure-Go (CGO_ENABLED=0) Dockerfiles that honor
|
||||
# $TARGETARCH cross-compile natively (fast, no emulation). For true
|
||||
# native-speed arm64, register a bare-metal arm64 host (spark/GB10) as
|
||||
# the hanzo-build-linux-arm64 self-hosted runner (values-build-arm64.yaml).
|
||||
plats=$(echo "$img"|jq -r '(.platforms // ["linux/amd64"]) | join(",")')
|
||||
# ---- Refuse a Go builder older than the module it compiles -------
|
||||
# The official golang images set GOTOOLCHAIN=local, so a go.mod
|
||||
# requiring a newer Go than the base image does not degrade — it
|
||||
# dies mid-build with "go.mod requires go >= X (running Y;
|
||||
# GOTOOLCHAIN=local)". hanzoai/visor v1.108.16 is the shipped
|
||||
# instance, and it is invisible before the image build: every local
|
||||
# `go build` succeeds because a dev box runs GOTOOLCHAIN=auto and
|
||||
# just downloads what the module asks for. So the break is
|
||||
# introduced by editing go.mod — a file with nothing to do with
|
||||
# Docker — and found by a red release build.
|
||||
#
|
||||
# It is not one repo's problem either: a sweep of every Dockerfile
|
||||
# across the orgs found 54 below their own go.mod in 23 repos, and
|
||||
# only 7 of 223 Go builder stages set GOTOOLCHAIN=auto. Fixing those
|
||||
# fixes today; this line is what makes the 55th impossible.
|
||||
#
|
||||
# Checked here, before any build work, so the run fails in seconds
|
||||
# rather than after binfmt, a version derivation and a layer cache.
|
||||
"$CI_HOME/bin/gover" "$df" "$ctx"
|
||||
# ---- The semver this build publishes ----------------------------
|
||||
# A branch build used to publish sha-<short7> and nothing else, so an
|
||||
# image only earned a version when a human remembered to cut a git
|
||||
# tag. That is the whole reason 14 of the fleet's 117 pins named a
|
||||
# commit instead of a release: not drift, just the only tag CI ever
|
||||
# offered them. Every build now publishes a version too, and the
|
||||
# sha- tag stays beside it for forensics.
|
||||
#
|
||||
# The number is DERIVED, never typed, by bin/imgver — the ONE
|
||||
# implementation, shared with the hand-rolled
|
||||
# .hanzo/workflows/deploy.yml that 11 repos carry instead of importing
|
||||
# this workflow (see .github/actions/imgver). Written twice it would be
|
||||
# right twice and then wrong once, which is exactly how `sha-<short>`
|
||||
# became the only tag those repos ever published. Read that script for
|
||||
# the rule; it is monotonic against the repo's manifest AND the
|
||||
# registry, so one tag can never cover two digests.
|
||||
ver=""
|
||||
if [ "$IS_TAG" = 1 ]; then
|
||||
# Release build: the git tag IS the version. Identity in, identity
|
||||
# out — deriving here would publish a number nobody asked for.
|
||||
:
|
||||
else
|
||||
# An explicit `version:` on the image entry wins over the repo-wide
|
||||
# one; both are optional, and imgver falls back to the manifest.
|
||||
iv=$(echo "$img"|jq -r '.version // ""')
|
||||
[ -z "$iv" ] && iv=$(yq -r '.version // ""' hanzo.yml)
|
||||
ver=$(IMGVER_VERSION="$iv" "$CI_HOME/bin/imgver" "$repo" "$ctx")
|
||||
fi
|
||||
if [ "$plats" = "linux/amd64" ]; then
|
||||
# single-arch: keep the exact legacy tag shape (-amd64) deploys expect.
|
||||
TAGS="-t $repo:sha-${SHORT}-amd64${sfx:+-$sfx} -t $repo:${sfx:+$sfx-}latest"
|
||||
else
|
||||
# multi-arch: one arch-neutral manifest-list tag (no -amd64 suffix).
|
||||
docker run --privileged --rm tonistiigi/binfmt --install arm64 >/dev/null 2>&1 || true
|
||||
TAGS="-t $repo:sha-${SHORT}${sfx:+-$sfx} -t $repo:${sfx:+$sfx-}latest"
|
||||
fi
|
||||
# Release (tag) build. The git tag IS the release name, so publish it
|
||||
# VERBATIM (v1.26.19) — that is the shape a universe CR pins, and
|
||||
# stripping the v is why releases were finished by hand-`crane copy`ing
|
||||
# sha-<sha7> onto the semver a human typed. Identity in, identity out.
|
||||
# The v-stripped alias stays for CRs already pinned that way (world
|
||||
# 2.4.51), and is skipped when a repo tags without a v. The old
|
||||
# `<ver>-amd64` alias is deleted: no CR in the fleet pinned it.
|
||||
if [ "$IS_TAG" = 1 ]; then
|
||||
TAGS="$TAGS -t $repo:${REL}${sfx:+-$sfx}"
|
||||
[ "$REL" != "$VER" ] && TAGS="$TAGS -t $repo:${VER}${sfx:+-$sfx}"
|
||||
else
|
||||
# The tag universe is meant to PIN. It goes on every build, not
|
||||
# only the ones somebody remembered to tag by hand.
|
||||
TAGS="$TAGS -t $repo:${ver}${sfx:+-$sfx}"
|
||||
fi
|
||||
echo "::group::build $name → $repo (${sfx}) [$plats]"
|
||||
# --build-arg assembly: static hanzo.yml `args` (a fixed value, e.g. a
|
||||
# pinned base image tag) + `build_secrets` (KMS values the KMS step
|
||||
# exported into the env above). Empty when a repo declares neither, so
|
||||
# the buildx line is unchanged for every existing repo.
|
||||
BUILD_ARGS=""
|
||||
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)
|
||||
# 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
|
||||
# PROVENANCE: which commit are these bytes. Passed BOTH ways on
|
||||
# purpose. The --label sets it from outside for any Dockerfile; the
|
||||
# --build-arg feeds `ARG REVISION` for the Dockerfiles that stamp the
|
||||
# label themselves (hanzoai/cloud declares `ARG REVISION=unknown`,
|
||||
# and `unknown` is exactly what every image built without this said).
|
||||
#
|
||||
# An unlabelled image cannot be traced to source, and that stops being
|
||||
# cosmetic the moment two builders race for one tag: with one image
|
||||
# labelled and the other reading `unknown`, "which of these is the
|
||||
# release" had no answer short of diffing layers — while the wrong one
|
||||
# was already pinned. A version is a receipt only if the image can
|
||||
# name its own commit, so every image this fleet publishes now can.
|
||||
#
|
||||
# A Dockerfile with no `ARG REVISION` ignores the build-arg (buildkit
|
||||
# warns, never fails), so this is a no-op for every repo that has not
|
||||
# asked for it.
|
||||
PROV="--build-arg REVISION=$GITHUB_SHA"
|
||||
PROV="$PROV --label org.opencontainers.image.revision=$GITHUB_SHA"
|
||||
PROV="$PROV --label org.opencontainers.image.version=${ver:-$REL}"
|
||||
PROV="$PROV --label org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY"
|
||||
# GIT_TOKEN (from KMS, via GITHUB_ENV) is passed as the `gh_token`
|
||||
# BuildKit secret so Dockerfiles can clone private Go modules; omitted
|
||||
# cleanly when absent (public-only builds unaffected).
|
||||
docker buildx build --platform "$plats" $BUILD_ARGS $PROV ${GIT_TOKEN:+--secret id=gh_token,env=GIT_TOKEN} --push $TAGS -f "$df" "$ctx"
|
||||
# The semver is the tag the fleet PINS, so prove it exists before the
|
||||
# run goes green — buildx can exit 0 while the manifest is not yet
|
||||
# resolvable, and a pin onto a phantom tag is an ImagePullBackOff.
|
||||
# The digest is printed with it because universe pins repo:tag@digest
|
||||
# and the kubelet honours the DIGEST: publishing both here is what
|
||||
# lets the two move together instead of being looked up separately
|
||||
# and drifting apart.
|
||||
pin="$repo:${ver:-$REL}${sfx:+-$sfx}"
|
||||
for i in 1 2 3 4 5 6; do
|
||||
dig=$(docker buildx imagetools inspect "$pin" --format '{{.Manifest.Digest}}' 2>/dev/null || true)
|
||||
[ -n "$dig" ] && break
|
||||
sleep 5
|
||||
done
|
||||
[ -n "$dig" ] || { echo "::error::$pin not resolvable after push"; exit 1; }
|
||||
echo "| \`$pin\` | \`$dig\` |" >> "$RUNNER_TEMP/pins.md"
|
||||
# Dual-host: mirror the exact tag set to registry.hanzo.ai (server-
|
||||
# side manifest copy — no rebuild). ghcr.io/<org>/<name> →
|
||||
# registry.hanzo.ai/<org>/<name>; public consumers keep ghcr, the fleet
|
||||
# is migrating to pull from ours. A skip here is now a WARNING, not
|
||||
# a notice: an image that never reaches our registry is the reason
|
||||
# a deploy still depends on GitHub, and that should be visible in
|
||||
# the run, not buried.
|
||||
if [ "${MIRROR_OK:-}" = "1" ]; then
|
||||
# crane, not buildx imagetools: the IAM token realm doesn't answer
|
||||
# buildx's multi-scope token request (spec gap, tracked).
|
||||
command -v crane >/dev/null 2>&1 || {
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fsSL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz \
|
||||
| tar -xz -C "$HOME/.local/bin" crane
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
}
|
||||
# ONE mapping, stated once. The GHCR org and the registry org are
|
||||
# not the same word — ghcr.io/hanzoai/x mirrors to
|
||||
# registry.hanzo.ai/hanzo/x — and a second copy of this table is
|
||||
# how a push path and a pin drift into naming different images.
|
||||
rorg="${repo#*/}"; rname="${rorg#*/}"; rorg="${rorg%%/*}"
|
||||
case "$rorg" in
|
||||
hanzoai) rorg=hanzo ;;
|
||||
zooai) rorg=zoo ;;
|
||||
*) ;; # bootnode, lux-private, ... map to themselves
|
||||
esac
|
||||
mrepo="registry.hanzo.ai/$rorg/$rname"
|
||||
echo "$TAGS" | tr ' ' '\n' | grep -v '^-t$' | grep -v '^$' | while read -r ref; do
|
||||
crane copy "$ref" "${mrepo}:${ref##*:}" \
|
||||
|| echo "::warning::$ref did not reach registry.hanzo.ai (ghcr push unaffected)"
|
||||
done
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
# The pins this run earned, ready to paste into universe. Tag and
|
||||
# digest are printed TOGETHER because they are one fact: the failure
|
||||
# this prevents is a new tag landing beside an old digest, which
|
||||
# reports the new version on the pod and serves the old bytes.
|
||||
if [ -s "$RUNNER_TEMP/pins.md" ]; then
|
||||
{ echo "### Pins"; echo; echo "| image | digest |"; echo "|---|---|";
|
||||
cat "$RUNNER_TEMP/pins.md"; } >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Client — commit the projection and cut the patch
|
||||
# LAST, and after `test:` on purpose: this pushes a tag, the tag starts a
|
||||
# publish, and a published client is the one artifact in this workflow
|
||||
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# publishable — refuse a `build_secrets` name that does not declare itself public.
|
||||
#
|
||||
# Usage: publishable <hanzo.yml> (reads images[].build_secrets, rc=1 on refusal)
|
||||
#
|
||||
# WHY A NAME AND NOT A VALUE. A build_secret is handed to buildx as
|
||||
# `--build-arg NAME=value`, and a build-arg is IN THE PUBLISHED IMAGE: `docker
|
||||
# history` prints it to anyone who can pull. So the only value that may sit
|
||||
# here is one that is public on purpose — a Vite/Next static export has no
|
||||
# server to read an env from, so its ingest key must be inlined at build, and
|
||||
# inlining it is what publishing it means.
|
||||
#
|
||||
# The value cannot be the test. At the moment ci reads hanzo.yml the value does
|
||||
# not exist yet (KMS has not been called), and guessing secrecy from a string's
|
||||
# shape is a heuristic that is wrong in both directions. The name is what a
|
||||
# reviewer reads, it is in git, and it is decided by the person who knows the
|
||||
# answer. So the name carries the assertion.
|
||||
#
|
||||
# The fleet had already started saying it this way — hanzoai/docs renamed
|
||||
# EVENT_INGEST_KEY -> PUBLISHABLE_KEY, and hanzoai/world's four are VITE_*,
|
||||
# which a bundler inlines by construction. This turns that convention into the
|
||||
# mechanism. Before it, the ONE repo whose value is genuinely publishable
|
||||
# (hanzoai/ui, a `pk-…` key) asserted the prefix inside its own Dockerfile, so
|
||||
# the check existed once, for one image, and any repo adding a real credential
|
||||
# got no check at all.
|
||||
set -uo pipefail
|
||||
|
||||
f=${1:-hanzo.yml}
|
||||
[ -r "$f" ] || exit 0 # no config, nothing declared, nothing to refuse
|
||||
|
||||
names=$(yq -r '[(.images // [])[] | (.build_secrets // [])[]] | unique | .[]' "$f" 2>/dev/null) || exit 0
|
||||
[ -n "$names" ] || exit 0
|
||||
|
||||
rc=0
|
||||
for n in $names; do
|
||||
case "$n" in
|
||||
# Prefixes a bundler already treats as client-side, plus an explicit
|
||||
# self-declaration for everything else. Anything outside this set has not
|
||||
# claimed to be public, so it is not baked.
|
||||
PUBLISHABLE_*|PUBLIC_*|NEXT_PUBLIC_*|EXPO_PUBLIC_*|NUXT_PUBLIC_*|VITE_*|REACT_APP_*|*_PUBLISHABLE|*_PUBLIC) ;;
|
||||
*)
|
||||
echo "::error::build_secret '$n' does not declare itself publishable, and a build_secret is baked into the image as a --build-arg where \`docker history\` reveals it. Rename it (PUBLISHABLE_*, PUBLIC_*, NEXT_PUBLIC_*, VITE_*, REACT_APP_*) if the value is public on purpose; if it is a real credential it cannot be a build_secret at all." >&2
|
||||
rc=1 ;;
|
||||
esac
|
||||
done
|
||||
exit $rc
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for bin/publishable. Offline and deterministic: every case is a
|
||||
# hanzo.yml written into a temp dir. Run: bash bin/publishable_test.sh
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
PUB="$PWD/bin/publishable"
|
||||
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
||||
fail=0
|
||||
|
||||
# t <name> <want-rc> <build_secrets yaml-inline list>
|
||||
t() {
|
||||
local name=$1 want=$2 list=$3
|
||||
local d="$tmp/$RANDOM$RANDOM"; mkdir -p "$d"
|
||||
{ echo 'images:'; echo ' - name: app'; echo ' repo: ghcr.io/hanzoai/app'
|
||||
[ -n "$list" ] && echo " build_secrets: $list"; } > "$d/hanzo.yml"
|
||||
out=$(bash "$PUB" "$d/hanzo.yml" 2>&1); rc=$?
|
||||
if [ "$rc" = "$want" ]; then printf 'ok %-56s rc=%s\n' "$name" "$rc"
|
||||
else printf 'FAIL %-56s rc=%s (want %s)\n %s\n' "$name" "$rc" "$want" "$out"; fail=1; fi
|
||||
}
|
||||
|
||||
echo "--- refused: a name that never claimed to be public ---"
|
||||
# The live case. hanzoai/ui declares exactly this, and its value IS publishable
|
||||
# — but nothing outside its own Dockerfile could know that.
|
||||
t "EVENT_INGEST_KEY is refused" 1 '[EVENT_INGEST_KEY]'
|
||||
t "a real credential is refused" 1 '[STRIPE_SECRET_KEY]'
|
||||
t "a token is refused" 1 '[GITHUB_TOKEN]'
|
||||
t "a password is refused" 1 '[DB_PASSWORD]'
|
||||
t "a private key is refused" 1 '[SIGNING_PRIVATE_KEY]'
|
||||
t "one bad name among good ones is refused" 1 '[VITE_GTM_ID, EVENT_INGEST_KEY]'
|
||||
|
||||
echo "--- allowed: the name declares it ---"
|
||||
# These are the fleet's real declarations, verbatim.
|
||||
t "PUBLISHABLE_KEY (hanzoai/docs)" 0 '[PUBLISHABLE_KEY]'
|
||||
t "VITE_MAPBOX_TOKEN (hanzoai/world)" 0 '[VITE_MAPBOX_TOKEN]'
|
||||
t "VITE_SENTRY_DSN (hanzoai/world)" 0 '[VITE_SENTRY_DSN]'
|
||||
t "world's four together" 0 '[VITE_MAPBOX_TOKEN, VITE_SENTRY_DSN, VITE_ANALYTICS_WEBSITE_ID, VITE_GTM_ID]'
|
||||
t "NEXT_PUBLIC_ prefix" 0 '[NEXT_PUBLIC_INGEST_KEY]'
|
||||
t "REACT_APP_ prefix" 0 '[REACT_APP_MAP_KEY]'
|
||||
t "EXPO_PUBLIC_ prefix" 0 '[EXPO_PUBLIC_API_KEY]'
|
||||
t "NUXT_PUBLIC_ prefix" 0 '[NUXT_PUBLIC_API_KEY]'
|
||||
t "PUBLIC_ prefix" 0 '[PUBLIC_ANALYTICS_ID]'
|
||||
t "_PUBLISHABLE suffix" 0 '[STRIPE_PUBLISHABLE]'
|
||||
t "_PUBLIC suffix" 0 '[ANALYTICS_ID_PUBLIC]'
|
||||
|
||||
echo "--- silent: nothing declared, nothing to say ---"
|
||||
# 44 of the fleet's 47 repos are this case and must be byte-for-byte unchanged.
|
||||
t "no build_secrets key at all" 0 ''
|
||||
t "empty build_secrets list" 0 '[]'
|
||||
|
||||
echo "--- a missing file is not a refusal ---"
|
||||
out=$(bash "$PUB" "$tmp/does-not-exist.yml" 2>&1); rc=$?
|
||||
if [ "$rc" = 0 ]; then printf 'ok %-56s rc=0\n' "absent hanzo.yml is silent"
|
||||
else printf 'FAIL %-56s rc=%s\n' "absent hanzo.yml is silent" "$rc"; fail=1; fi
|
||||
|
||||
echo "--- the refusal says what to do about it ---"
|
||||
d="$tmp/msg"; mkdir -p "$d"
|
||||
printf 'images:\n - name: app\n build_secrets: [EVENT_INGEST_KEY]\n' > "$d/hanzo.yml"
|
||||
out=$(bash "$PUB" "$d/hanzo.yml" 2>&1)
|
||||
for pat in "EVENT_INGEST_KEY" "docker history" "PUBLISHABLE_" "cannot be a build_secret"; do
|
||||
if printf '%s' "$out" | grep -qF "$pat"; then printf 'ok %-56s\n' "message names '$pat'"
|
||||
else printf 'FAIL %-56s\n got: %s\n' "message names '$pat'" "$out"; fail=1; fi
|
||||
done
|
||||
|
||||
[ "$fail" = 0 ] && echo "PASS" || echo "FAIL"
|
||||
exit $fail
|
||||
Executable
+238
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env bash
|
||||
# sitedeploy — publish a built static export to the Hanzo PaaS Sites plane.
|
||||
# One implementation, every static site.
|
||||
#
|
||||
# sitedeploy <slug> <dir> # e.g. sitedeploy hanzo-ai out
|
||||
#
|
||||
# WHY THIS IS A SCRIPT AND NOT INLINE SHELL: the same forty lines of enqueue →
|
||||
# upload → complete were about to be pasted into hanzo.ai, hanzo.app, hips,
|
||||
# computer and every static surface after them. Written N times it is right N-1
|
||||
# times and then wrong once — which is exactly how `sha-<short>` became the only
|
||||
# tag eleven repos ever published (see bin/imgver). The plane's contract lives
|
||||
# here, once.
|
||||
#
|
||||
# THE THREE STEPS, and why the bytes never pass through the API:
|
||||
#
|
||||
# POST /v1/projects/<slug>/deploy -> 202 {id, bucket, prefix, upload}
|
||||
# POST <upload.url> per file -> the bytes, straight to S3
|
||||
# POST /v1/projects/<slug>/deployments/<id>/complete
|
||||
#
|
||||
# A real export is large — hanzo.ai is 128 MB across 8403 files — and cloud's
|
||||
# BodyLimit is 16 MiB, so an artifact POST is refused by fasthttp BEFORE any
|
||||
# handler runs, and it fails as an opaque 400 "Error when parsing request" that
|
||||
# reads like a malformed payload rather than a size cap. The git source is the
|
||||
# documented route for exactly this shape.
|
||||
#
|
||||
# NO STANDING S3 CREDENTIAL. The 202 carries `upload`: a presigned POST policy
|
||||
# that is prefix-scoped (starts-with $key "<org>/<slug>/", enforced by S3 itself),
|
||||
# short-lived (30 min) and size-bounded. That replaced handing every repo the
|
||||
# bucket's own long-lived access key — one key for a bucket whose only tenant
|
||||
# separation is the key prefix, so every repo holding it could overwrite EVERY
|
||||
# org's site (cloud apps/projects/grant.go). Do not reintroduce SITES_S3_* here.
|
||||
# HANZO_DEPLOY_TOKEN is the ONE credential this needs.
|
||||
#
|
||||
# DELETION IS THE SERVER'S. The grant authorizes writes only, so CI cannot remove
|
||||
# a file; `keys` in the completion is the manifest cloud reconciles the prefix
|
||||
# against, and it deletes what the build no longer produces. That is where
|
||||
# `aws s3 sync --delete` went. It fails CLOSED on an empty manifest, and so does
|
||||
# this script — a build that enumerated nothing has failed, and honouring it
|
||||
# literally would delete the live site.
|
||||
#
|
||||
# ENV: HANZO_DEPLOY_TOKEN (required) the org-scoped `sk-` key; mint at POST /v1/keys
|
||||
# HANZO_API (https://api.hanzo.ai)
|
||||
# SITEDEPLOY_JOBS (24) parallel uploads
|
||||
# SITEDEPLOY_COMMIT / SITEDEPLOY_BRANCH recorded on the deployment
|
||||
# SITEDEPLOY_PLAN=1 print the manifest and exit; no network. The test seam.
|
||||
set -euo pipefail
|
||||
|
||||
slug="${1:?usage: sitedeploy <slug> <dir>}"
|
||||
dir="${2:?usage: sitedeploy <slug> <dir>}"
|
||||
api="${HANZO_API:-https://api.hanzo.ai}"
|
||||
jobs="${SITEDEPLOY_JOBS:-24}"
|
||||
|
||||
[ -d "$dir" ] || { echo "::error::$dir is not a directory — the build produced no export"; exit 1; }
|
||||
|
||||
# ---- content type -----------------------------------------------------------
|
||||
# The presigned POST carries no Content-Type condition, so whatever CI sends is
|
||||
# what the object stores — and what it stores is what the edge serves. Send
|
||||
# nothing and every page is application/octet-stream, which a browser DOWNLOADS
|
||||
# instead of rendering: a green deploy that serves an unusable site.
|
||||
ctype() {
|
||||
case "${1##*.}" in
|
||||
html|htm) echo 'text/html; charset=utf-8' ;;
|
||||
css) echo 'text/css; charset=utf-8' ;;
|
||||
js|mjs) echo 'text/javascript; charset=utf-8' ;;
|
||||
json) echo 'application/json; charset=utf-8' ;;
|
||||
xml) echo 'application/xml; charset=utf-8' ;;
|
||||
txt) echo 'text/plain; charset=utf-8' ;;
|
||||
svg) echo 'image/svg+xml' ;;
|
||||
png) echo 'image/png' ;;
|
||||
jpg|jpeg) echo 'image/jpeg' ;;
|
||||
gif) echo 'image/gif' ;;
|
||||
webp) echo 'image/webp' ;;
|
||||
avif) echo 'image/avif' ;;
|
||||
ico) echo 'image/x-icon' ;;
|
||||
woff) echo 'font/woff' ;;
|
||||
woff2) echo 'font/woff2' ;;
|
||||
ttf) echo 'font/ttf' ;;
|
||||
otf) echo 'font/otf' ;;
|
||||
wasm) echo 'application/wasm' ;;
|
||||
pdf) echo 'application/pdf' ;;
|
||||
webmanifest) echo 'application/manifest+json' ;;
|
||||
map) echo 'application/json' ;;
|
||||
*) echo 'application/octet-stream' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---- cache control ----------------------------------------------------------
|
||||
# Mirrors cloud's apps/sites.CacheControlFor so a site cached the same whichever
|
||||
# route its bytes took. bin/sitedeploy_test.sh pins these strings; if the server
|
||||
# changes its policy the test is what catches the drift.
|
||||
#
|
||||
# A fingerprinted asset (Vite/Next/webpack emit app.4f3a9c21.js) is immutable: a
|
||||
# new build changes the hash, so the old URL can be cached forever.
|
||||
#
|
||||
# The class is written `[._-]` with the dash LAST and the pattern held in a
|
||||
# variable. Go's regexp spells the same set `[.\-_]`, and transcribing that
|
||||
# literally into `[[ =~ ]]` is a silent defect twice over: a backslash is literal
|
||||
# inside a POSIX bracket expression, so `\-_` reads as the RANGE \…_ and the shell
|
||||
# rejects it as "invalid character range" — and because the `if` merely evaluates
|
||||
# false, every fingerprinted asset quietly fell back to max-age=3600 instead of
|
||||
# immutable. An unquoted inline pattern also has its own quoting hazards; the
|
||||
# variable form is the one that is read as a regex rather than a glob.
|
||||
fingerprint_re='[._-][0-9a-fA-F]{8,}\.[a-z0-9]+$'
|
||||
cachectl() {
|
||||
local k="$1" base="${1##*/}"
|
||||
case "${k##*.}" in
|
||||
html|htm) echo 'public, max-age=60, s-maxage=86400' ;;
|
||||
js|mjs|css|woff|woff2|png|jpg|jpeg|gif|svg|webp|avif|ico|ttf|otf|wasm|data|pck|unityweb|mem)
|
||||
if [[ "$base" =~ $fingerprint_re ]]; then
|
||||
echo 'public, max-age=31536000, immutable'
|
||||
else
|
||||
echo 'public, max-age=3600'
|
||||
fi ;;
|
||||
*) echo 'public, max-age=3600' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---- the manifest -----------------------------------------------------------
|
||||
# Paths RELATIVE to the export root, which is exactly what the completion's
|
||||
# `keys` must carry (cloud reconciles `keep[rel]` against them).
|
||||
#
|
||||
# CNAME does not travel. It is a GitHub Pages artifact that means nothing to S3
|
||||
# and would ship a stale hostname claim into the bucket.
|
||||
manifest=$(cd "$dir" && find . -type f ! -name CNAME | sed 's|^\./||' | LC_ALL=C sort)
|
||||
count=$(printf '%s' "$manifest" | grep -c . || true)
|
||||
[ "$count" -gt 0 ] || { echo "::error::$dir contains no files — refusing to deploy an empty manifest"; exit 1; }
|
||||
|
||||
if [ -n "${SITEDEPLOY_PLAN:-}" ]; then
|
||||
printf 'slug=%s dir=%s files=%s\n' "$slug" "$dir" "$count"
|
||||
while IFS= read -r k; do [ -n "$k" ] && printf '%s\t%s\t%s\n' "$k" "$(ctype "$k")" "$(cachectl "$k")"; done <<< "$manifest"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
: "${HANZO_DEPLOY_TOKEN:?HANZO_DEPLOY_TOKEN is unset. It is the ONE credential this needs; set it on the forge (git.hanzo.ai), which is what reads .hanzo/workflows — not on GitHub. Mint: POST /v1/keys {\"type\":\"secret\"}}"
|
||||
|
||||
# ---- 0. ensure the project exists (self-provision) --------------------------
|
||||
# A brand-new slug has no project, and the enqueue below 404s on a missing one —
|
||||
# which is why every never-before-deployed site failed its first run. Create it
|
||||
# idempotently here instead: 200/201 the first time, 409 every time after, both
|
||||
# success. So a new site needs no out-of-band `POST /v1/projects`; committing the
|
||||
# workflow is enough. The repo is linked because the git-source enqueue requires
|
||||
# a linked project (deployGit rejects an unlinked one); SITEDEPLOY_REPO overrides
|
||||
# the origin for a forge checkout whose remote is not the canonical repo.
|
||||
repo="${SITEDEPLOY_REPO:-$(git remote get-url origin 2>/dev/null || true)}"
|
||||
pcode=$(curl -sS -o /tmp/sd-proj.json -w '%{http_code}' \
|
||||
-X POST "$api/v1/projects" \
|
||||
-H "Authorization: Bearer $HANZO_DEPLOY_TOKEN" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg s "$slug" --arg u "$repo" --arg b "${SITEDEPLOY_BRANCH:-main}" \
|
||||
'{slug:$s, name:$s} + (if $u=="" then {} else {repo:{url:$u, branch:$b}} end)')")
|
||||
case "$pcode" in
|
||||
200|201|409) : ;; # created now, or already there — either is the state we need
|
||||
*) echo "::error::ensure project $slug returned HTTP $pcode"; head -c 400 /tmp/sd-proj.json; echo; exit 1 ;;
|
||||
esac
|
||||
|
||||
# ---- 1. enqueue -------------------------------------------------------------
|
||||
# 202 Accepted is the success code: the deployment is queued, not live. bucket
|
||||
# and prefix come FROM cloud (sitePrefix(org, slug) is server-side) — never guess
|
||||
# them, or the upload lands where nothing is served the moment an org or slug
|
||||
# changes.
|
||||
code=$(curl -sS -o /tmp/sd-enq.json -w '%{http_code}' \
|
||||
-X POST "$api/v1/projects/$slug/deploy" \
|
||||
-H "Authorization: Bearer $HANZO_DEPLOY_TOKEN" -H 'Content-Type: application/json' \
|
||||
-d "{\"source\":\"git\",\"commit\":\"${SITEDEPLOY_COMMIT:-}\",\"branch\":\"${SITEDEPLOY_BRANCH:-main}\"}")
|
||||
if [ "$code" != "202" ]; then
|
||||
echo "::error::enqueue $api/v1/projects/$slug/deploy returned HTTP $code"; head -c 600 /tmp/sd-enq.json; echo; exit 1
|
||||
fi
|
||||
dep=$(jq -r '.id' /tmp/sd-enq.json)
|
||||
prefix=$(jq -r '.prefix' /tmp/sd-enq.json)
|
||||
upload_url=$(jq -r '.upload.url // empty' /tmp/sd-enq.json)
|
||||
if [ -z "$upload_url" ]; then
|
||||
echo "::error::the 202 carried no upload grant, so there is no way to write the bytes."
|
||||
echo " cloud mints one only when presigning is configured (S3_ADMIN_* on the cloud deployment)."
|
||||
exit 1
|
||||
fi
|
||||
jq -c '{id,version,status,bucket,prefix}' /tmp/sd-enq.json
|
||||
|
||||
# A build that dies after this point would leave the deployment "queued" and the
|
||||
# project stuck "building" forever. Report the failure so cloud records an honest
|
||||
# terminal state instead of a lie by omission.
|
||||
fail() {
|
||||
curl -sS -X POST "$api/v1/projects/$slug/deployments/$dep/complete" \
|
||||
-H "Authorization: Bearer $HANZO_DEPLOY_TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"status":"error","message":"CI upload failed"}' >/dev/null 2>&1 || true
|
||||
echo "::error::marked deployment $dep as error"
|
||||
}
|
||||
trap 'fail' ERR
|
||||
|
||||
# ---- 2. the bytes -----------------------------------------------------------
|
||||
# `file` goes LAST: S3 ignores every field after the file part, so a grant field
|
||||
# trailing the body is silently dropped and the signature check fails.
|
||||
#
|
||||
# `key` is DROPPED from the grant's fields and re-sent per object. The grant
|
||||
# carries key="<org>/<slug>/" — the starts-with PLACEHOLDER, not a destination —
|
||||
# and forwarding it verbatim alongside the real key posts `key` twice, which S3
|
||||
# answers 400 for every object. That is the whole of the first end-to-end run:
|
||||
# 8403 files, 8403 400s. Everything else in the map (bucket, policy, x-amz-*) is
|
||||
# covered by the signature and must travel untouched.
|
||||
jq -r '.upload.fields | to_entries[] | select(.key != "key") | "-F\n\(.key)=\(.value)"' /tmp/sd-enq.json > /tmp/sd-fields
|
||||
put() {
|
||||
local rel="$1" args=() line
|
||||
# A read loop, NOT `mapfile`: mapfile is bash 4+, and macOS ships bash 3.2, so
|
||||
# on a dev box it fails as `command not found`, the array stays EMPTY, and every
|
||||
# upload goes out with no policy or signature at all. S3 then rejects it for a
|
||||
# malformed X-Amz-Credential — an error that points at the credential rather
|
||||
# than at the array that never got built.
|
||||
while IFS= read -r line; do args+=("$line"); done < /tmp/sd-fields
|
||||
curl -sS --fail-with-body -o /dev/null \
|
||||
-X POST "$UP_URL" \
|
||||
-F "key=$PREFIX/$rel" "${args[@]}" \
|
||||
-F "Content-Type=$(ctype "$rel")" -F "Cache-Control=$(cachectl "$rel")" \
|
||||
-F "file=@$DIR/$rel" \
|
||||
|| { echo "::error::upload failed: $rel"; return 1; }
|
||||
}
|
||||
export -f put ctype cachectl
|
||||
export UP_URL="$upload_url" PREFIX="$prefix" DIR="$dir" fingerprint_re
|
||||
|
||||
echo "uploading $count files to s3://$(jq -r .bucket /tmp/sd-enq.json)/$prefix ($jobs parallel)"
|
||||
printf '%s\n' "$manifest" | grep . | xargs -P "$jobs" -I{} bash -c 'put "$@"' _ {}
|
||||
|
||||
# ---- 3. flip it live --------------------------------------------------------
|
||||
# `wc -c`, not `stat`: the size flag is spelled -f%z on BSD and -c%s on GNU, and a
|
||||
# `stat -f%z || stat -c%s` fallback does not work — on Linux the first arm fails
|
||||
# INSIDE the pipeline, awk still exits 0, and the `||` never fires, so the byte
|
||||
# count silently reports 0. wc is the one spelling both agree on.
|
||||
bytes=$(cd "$dir" && find . -type f ! -name CNAME -exec wc -c {} + | awk '$2!="total"{s+=$1} END{print s+0}')
|
||||
jq -n --arg c "${SITEDEPLOY_COMMIT:-}" --argjson f "$count" --argjson b "${bytes:-0}" \
|
||||
--args '{status:"live",commit:$c,files:$f,bytes:$b,keys:$ARGS.positional}' \
|
||||
$(printf '%s\n' "$manifest" | grep .) > /tmp/sd-done.json
|
||||
|
||||
code=$(curl -sS -o /tmp/sd-resp.json -w '%{http_code}' \
|
||||
-X POST "$api/v1/projects/$slug/deployments/$dep/complete" \
|
||||
-H "Authorization: Bearer $HANZO_DEPLOY_TOKEN" -H 'Content-Type: application/json' \
|
||||
--data-binary @/tmp/sd-done.json)
|
||||
trap - ERR
|
||||
if [ "$code" != "200" ]; then
|
||||
echo "::error::complete returned HTTP $code"; head -c 600 /tmp/sd-resp.json; echo; fail; exit 1
|
||||
fi
|
||||
jq -c '{status,liveUrl,version,files,bytes}' /tmp/sd-resp.json
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for bin/sitedeploy. Runs OFFLINE: SITEDEPLOY_PLAN=1 stops the script
|
||||
# before the first network call and prints the manifest it would upload, so every
|
||||
# case here is deterministic and needs no token, no bucket and no cluster.
|
||||
# Run: bash bin/sitedeploy_test.sh
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
SD="$PWD/bin/sitedeploy"
|
||||
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
||||
fail=0
|
||||
|
||||
plan() { SITEDEPLOY_PLAN=1 bash "$SD" a-slug "$1" 2>&1; }
|
||||
t() { # t <name> <got> <want>
|
||||
if [ "$2" = "$3" ]; then printf 'ok %-56s -> %s\n' "$1" "$2"
|
||||
else printf 'FAIL %-56s -> %s (want %s)\n' "$1" "$2" "$3"; fail=1; fi
|
||||
}
|
||||
# field <dir> <key> <col> — the ctype (2) or cachectl (3) column for one key
|
||||
field() { plan "$1" | awk -F'\t' -v k="$2" -v c="$3" '$1==k{print $c}'; }
|
||||
|
||||
site="$tmp/site"; mkdir -p "$site/assets" "$site/nested/deep"
|
||||
echo '<h1>hi</h1>' > "$site/index.html"
|
||||
echo 'body{}' > "$site/assets/app.4f3a9c21.css"
|
||||
echo 'x' > "$site/assets/plain.css"
|
||||
echo 'y' > "$site/assets/chunk-AB12CD34.js"
|
||||
echo '{}' > "$site/data.json"
|
||||
echo 'z' > "$site/nested/deep/page.html"
|
||||
echo 'hanzo.ai' > "$site/CNAME"
|
||||
|
||||
# --- the manifest ------------------------------------------------------------
|
||||
# Keys are RELATIVE to the export root: cloud reconciles keep[rel] against them,
|
||||
# so a leading ./ or an absolute path would match nothing and the completion
|
||||
# would prune the entire live site.
|
||||
t "keys are relative, no leading ./" "$(plan "$site" | awk -F'\t' 'NR>1&&$1~/^\.?\//{print "ABS"}' | head -1)" ""
|
||||
t "nested paths keep their subdirs" "$(plan "$site" | awk -F'\t' '$1=="nested/deep/page.html"{print "yes"}')" "yes"
|
||||
# CNAME is a GitHub Pages artifact: it means nothing to S3 and would ship a stale
|
||||
# hostname claim into the bucket.
|
||||
t "CNAME does not travel" "$(plan "$site" | awk -F'\t' '$1=="CNAME"{print "leaked"}')" ""
|
||||
t "file count excludes CNAME" "$(plan "$site" | head -1 | grep -o 'files=[0-9]*')" "files=6"
|
||||
|
||||
# --- content type ------------------------------------------------------------
|
||||
# The presigned POST carries no Content-Type condition, so what CI sends is what
|
||||
# the object stores and what the edge serves. Send nothing and a browser
|
||||
# DOWNLOADS every page instead of rendering it.
|
||||
t "html" "$(field "$site" index.html 2)" "text/html; charset=utf-8"
|
||||
t "css" "$(field "$site" assets/plain.css 2)" "text/css; charset=utf-8"
|
||||
t "js" "$(field "$site" assets/chunk-AB12CD34.js 2)" "text/javascript; charset=utf-8"
|
||||
t "json" "$(field "$site" data.json 2)" "application/json; charset=utf-8"
|
||||
|
||||
# --- cache control: mirrors cloud apps/sites.CacheControlFor -----------------
|
||||
# These strings are the SERVER's policy, pinned here so the two cannot drift
|
||||
# apart silently. If cloud changes CacheControlFor, this is what goes red.
|
||||
t "html is short-lived, long at the edge" "$(field "$site" index.html 3)" "public, max-age=60, s-maxage=86400"
|
||||
t "unfingerprinted asset is an hour" "$(field "$site" assets/plain.css 3)" "public, max-age=3600"
|
||||
# The regression this pins: Go spells the class [.\-_], and transcribing that
|
||||
# into [[ =~ ]] makes the shell reject it as an invalid character range. The `if`
|
||||
# then merely evaluates false, so every hashed asset silently lost `immutable`.
|
||||
t "fingerprinted .hash. is immutable" "$(field "$site" assets/app.4f3a9c21.css 3)" "public, max-age=31536000, immutable"
|
||||
t "fingerprinted -HASH- is immutable" "$(field "$site" assets/chunk-AB12CD34.js 3)" "public, max-age=31536000, immutable"
|
||||
|
||||
# --- fail closed -------------------------------------------------------------
|
||||
# reconcilePrefix deletes whatever the manifest omits, so an empty manifest is a
|
||||
# request to delete the live site. A build that enumerated nothing has failed.
|
||||
empty="$tmp/empty"; mkdir -p "$empty"
|
||||
plan "$empty" >/dev/null 2>&1
|
||||
t "empty export is refused" "$?" "1"
|
||||
only_cname="$tmp/onlycname"; mkdir -p "$only_cname"; echo x > "$only_cname/CNAME"
|
||||
plan "$only_cname" >/dev/null 2>&1
|
||||
t "a dir holding only CNAME is empty too" "$?" "1"
|
||||
plan "$tmp/does-not-exist" >/dev/null 2>&1
|
||||
t "missing export dir is refused" "$?" "1"
|
||||
|
||||
# --- the credential ----------------------------------------------------------
|
||||
# Not in PLAN mode (that is the offline seam), but a real run must refuse to
|
||||
# start rather than enqueue a deployment it cannot complete.
|
||||
out=$(HANZO_DEPLOY_TOKEN= bash "$SD" a-slug "$site" 2>&1); rc=$?
|
||||
t "no token: exits non-zero" "$rc" "1"
|
||||
t "no token: says which secret" "$(printf '%s' "$out" | grep -c HANZO_DEPLOY_TOKEN)" "1"
|
||||
|
||||
[ $fail -eq 0 ] && echo "PASS" || echo "FAIL"
|
||||
exit $fail
|
||||
@@ -59,6 +59,60 @@ test:
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: .github and .hanzo copies are one file ($(wc -l < .github/workflows/build.yml) lines)"
|
||||
- name: gate-runs-before-delegate
|
||||
# `mode: delegate` chooses WHERE the image is built. It must never choose
|
||||
# WHETHER the commit was tested. It did: the delegate step was evaluated
|
||||
# fourth, ahead of every toolchain and ahead of `test:`, and every other step
|
||||
# carried `if: inputs.mode != 'delegate'` — so a delegated run skipped the
|
||||
# gate along with the build and finished green in seconds having proven
|
||||
# nothing. The seam was right and the ORDER was wrong, which is the kind of
|
||||
# defect that reads as correct in every diff that touches one step at a time.
|
||||
#
|
||||
# Two assertions, because either one alone can be satisfied while the gate
|
||||
# still does not run: the delegate POST must come AFTER the test gate, and
|
||||
# the test gate must not be delegate-guarded.
|
||||
run: |
|
||||
set -e
|
||||
f=.github/workflows/build.yml
|
||||
t=$(grep -n '^ - name: Test (per hanzo.yml)$' "$f" | cut -d: -f1)
|
||||
d=$(grep -n '^ - name: Delegate build to platform (mode=delegate)$' "$f" | cut -d: -f1)
|
||||
[ -n "$t" ] && [ -n "$d" ] || { echo "::error::cannot find the test gate ($t) or the delegate step ($d) — a rename broke this assertion, fix the assertion"; exit 1; }
|
||||
[ "$d" -gt "$t" ] || { echo "::error::the delegate step (line $d) runs BEFORE the test gate (line $t) — a delegated build would ship an ungated commit"; exit 1; }
|
||||
if sed -n "${t}p;$((t+1)),$((t+80))p" "$f" | grep -m1 '^ if: ' | grep -q "mode != 'delegate'"; then
|
||||
echo "::error::the test gate is guarded by \`inputs.mode != 'delegate'\` — delegate would skip it"; exit 1
|
||||
fi
|
||||
echo "OK: test gate at line $t, delegate POST at line $d, gate not delegate-guarded"
|
||||
- name: publishable
|
||||
# bin/publishable refuses a `build_secrets` name that has not declared itself
|
||||
# public. That matters because a build_secret is baked in as a --build-arg,
|
||||
# which `docker history` prints to anyone who can pull — so the key is a
|
||||
# publishing decision wearing the word "secret". Until this, the only check
|
||||
# in the fleet lived inside hanzoai/ui's own Dockerfile, for one image.
|
||||
run: bash bin/publishable_test.sh
|
||||
- name: publishable-rule-is-one-rule
|
||||
# The rule is spelled twice on purpose, and this is what keeps it one rule.
|
||||
#
|
||||
# build.yml cannot call bin/publishable, because the tools checkout and the
|
||||
# workflow resolve from DIFFERENT places: a caller pins the workflow at a ref
|
||||
# its own forge resolves, while the tools step derives its ref from
|
||||
# GITHUB_WORKFLOW_REF — which git.hanzo.ai does not set, so it falls back to
|
||||
# `v1` and clones github.com. Measured on run 36473: `derived ref=v1`,
|
||||
# `cloned https://github.com/hanzoai/ci@v1`. So a step calling a NEW file
|
||||
# under $CI_HOME is broken on every forge run until a tag moves on a host the
|
||||
# caller never named. The pipeline has to carry its own rule.
|
||||
#
|
||||
# Which leaves the pattern in two files, which is the same shape as the two
|
||||
# copies of build.yml and gets the same treatment: demand they are identical
|
||||
# and let the gate, not a reader, be the thing that notices.
|
||||
run: |
|
||||
set -e
|
||||
a=$(grep -oE '^[[:space:]]*PUBLISHABLE_\*\|[^)]*\)' .github/workflows/build.yml | tr -d '[:space:]')
|
||||
b=$(grep -oE '^[[:space:]]*PUBLISHABLE_\*\|[^)]*\)' bin/publishable | tr -d '[:space:]')
|
||||
[ -n "$a" ] && [ -n "$b" ] || { echo "::error::publishable rule not found in build.yml ($a) or bin/publishable ($b) — an edit renamed it, fix this assertion"; exit 1; }
|
||||
[ "$a" = "$b" ] || { echo "::error::the publishable rule differs between build.yml and bin/publishable — build.yml is what runs, bin/publishable is what is tested, so a difference means the tested rule is not the enforced one:
|
||||
build.yml: $a
|
||||
bin/publishable: $b"; exit 1; }
|
||||
echo "OK: one publishable rule, two spellings — $a"
|
||||
- name: imgver
|
||||
# bin/imgver decides the version EVERY image in the fleet publishes — this
|
||||
# workflow's build lane calls it, and so does the imgver composite action the
|
||||
|
||||
Reference in New Issue
Block a user