Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6de211881 | ||
|
|
a830325d31 | ||
|
|
d1e6edf6d1 | ||
|
|
2c036d3a61 | ||
|
|
11fcb7b145 | ||
|
|
5e0e4f906c |
+133
-73
@@ -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:
|
||||
@@ -222,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.
|
||||
@@ -335,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 }}
|
||||
@@ -375,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 }}" ;;
|
||||
@@ -391,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
|
||||
@@ -601,7 +589,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
|
||||
@@ -615,7 +603,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 || {
|
||||
@@ -640,12 +628,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)
|
||||
@@ -657,7 +645,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)
|
||||
@@ -876,7 +864,7 @@ jobs:
|
||||
# `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: inputs.mode != 'delegate' && hashFiles('go.mod') != ''
|
||||
if: hashFiles('go.mod') != ''
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||
@@ -927,7 +915,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)'
|
||||
@@ -956,6 +944,78 @@ 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
|
||||
|
||||
+133
-73
@@ -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:
|
||||
@@ -222,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.
|
||||
@@ -335,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 }}
|
||||
@@ -375,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 }}" ;;
|
||||
@@ -391,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
|
||||
@@ -601,7 +589,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
|
||||
@@ -615,7 +603,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 || {
|
||||
@@ -640,12 +628,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)
|
||||
@@ -657,7 +645,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)
|
||||
@@ -876,7 +864,7 @@ jobs:
|
||||
# `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: inputs.mode != 'delegate' && hashFiles('go.mod') != ''
|
||||
if: hashFiles('go.mod') != ''
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
|
||||
@@ -927,7 +915,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)'
|
||||
@@ -956,6 +944,78 @@ 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
|
||||
|
||||
@@ -23,5 +23,15 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
gate:
|
||||
uses: hanzoai/ci/.hanzo/workflows/build.yml@v2
|
||||
uses: hanzoai/ci/.hanzo/workflows/build.yml@v1.0.38
|
||||
secrets: inherit
|
||||
# PROOF BRANCH — not for main. `delegate` used to be evaluated fourth, ahead
|
||||
# of every toolchain and ahead of `test:`, so a delegated run skipped the gate
|
||||
# along with the build and finished green having proven nothing. This asks for
|
||||
# the delegated build on purpose, pinned at the version tag that carries the
|
||||
# reorder. @v1 and @v2 both still resolve to the pre-reorder pipeline, and
|
||||
# v1.0.37 predates the inline publishable rule, so it is v1.0.38 or nothing.
|
||||
# The run must show `Test (per hanzo.yml)` EXECUTED, with the delegate POST
|
||||
# after it.
|
||||
with:
|
||||
mode: delegate
|
||||
|
||||
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
|
||||
@@ -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