mirror of
https://github.com/hanzo-apps/cms.git
synced 2026-10-30 14:47:33 +00:00
The build tagged sha-<sha7> and stopped, so every rollout needed a hand-written pin and the version a human reads was a hash they cannot order or roll back to. The next patch now comes from the REGISTRY — what is published is the only authority on what has been used — is proven unused before anything is pushed, and the run ends by calling universe/charts/app/pin.sh, the one place a deploy is decided. The image also carries its revision, so it can be traced to a commit.
209 lines
10 KiB
YAML
209 lines
10 KiB
YAML
# Build the image and push it. Nothing else.
|
|
#
|
|
# REWRITTEN 2026-07-27. The previous version could not work on this fleet, in two
|
|
# independent ways, and produced no image for weeks while looking merely idle:
|
|
#
|
|
# 1. `buildctl-daemonless.sh: command not found` (exit 127). Jobs here run in
|
|
# catthehacker/ubuntu:act-24.04 (see GIT_RUNNER_LABELS on the git-runner
|
|
# StatefulSet) and that image has no buildctl. It also read
|
|
# `secrets.GIT_CLONE_TOKEN`, which exists on neither the repo nor the org.
|
|
# 2. The `kubectl patch app` step failed with `kubectl: command not found` for
|
|
# the same reason — and it was futile regardless: cd.hanzo.ai's selfHeal
|
|
# restores the App CR from the pin in hanzoai/universe on its next poll.
|
|
# Rollout is a reviewed tag pin there, never a CI side effect.
|
|
#
|
|
# CI now ends by moving the pin, via universe/charts/app/pin.sh. The note here
|
|
# used to argue the opposite — that CI should stop at "the tag exists" because
|
|
# choosing what runs is a reviewed change. That reasoning left releases sitting
|
|
# in the registry with nothing pointing at them, which is the failure pin.sh was
|
|
# written for; it applies the review as rules on every release instead of hoping
|
|
# someone reads the log, refusing a non-semver version, an image the registry
|
|
# cannot serve, a version older than the current pin, and a repository the caller
|
|
# chose rather than the one the values file declares.
|
|
#
|
|
# SEMVER, NEVER SHA. This built `sha-<sha7>` and stopped, so every deploy needed
|
|
# a hand-written pin — and a sha cannot be read, ordered, or rolled back to by a
|
|
# human. THE NEXT PATCH COMES FROM THE REGISTRY, NOT FROM `git tag`: what is
|
|
# published is the only authority on what has been used, and deriving from git
|
|
# can re-emit a live tag and leave two digests behind one name.
|
|
#
|
|
# This service publishes WITHOUT a leading v (3.86.3), so the whole step works in
|
|
# that shape and pin.sh carries the spelling through from the values file.
|
|
#
|
|
# This is the shape proven on hanzoai/playground, whose build job is the only
|
|
# one of these that has ever gone green on this fleet. GHCR_USER / GHCR_TOKEN /
|
|
# GH_PAT all exist as hanzoai org secrets on git.hanzo.ai.
|
|
name: deploy
|
|
on:
|
|
push:
|
|
branches: [hanzo-cms]
|
|
workflow_dispatch:
|
|
|
|
concurrency:
|
|
group: deploy-cms-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
build:
|
|
# Must name a label a runner ACTUALLY advertises. The four online
|
|
# git-runners offer hanzo-build-linux-amd64; a label nothing offers does not
|
|
# fail, it queues until the 24h timeout — silence, not an error.
|
|
runs-on: [hanzo-build-linux-amd64]
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
# Derived from what is PUBLISHED, then proven unused before anything is
|
|
# pushed. The first half stops us colliding with a tag git does not know
|
|
# about; the second stops us overwriting one at all.
|
|
- name: Next patch semver
|
|
id: meta
|
|
env:
|
|
GHCR_USER: ${{ secrets.GHCR_USER }}
|
|
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
TOKEN=$(curl -fsSL -u "$GHCR_USER:$GHCR_TOKEN" \
|
|
"https://ghcr.io/token?scope=repository:hanzoai/cms:pull&service=ghcr.io" | jq -r .token)
|
|
|
|
# No leading v in this repo's series, and sha-*/floating tags are not
|
|
# versions — the filter keeps only x.y.z.
|
|
LAST=$(curl -fsSL -H "Authorization: Bearer $TOKEN" \
|
|
"https://ghcr.io/v2/hanzoai/cms/tags/list?n=10000" \
|
|
| jq -r '.tags[]? | select(test("^[0-9]+\\.[0-9]+\\.[0-9]+$"))' \
|
|
| sort -V | tail -1)
|
|
|
|
# Fail loudly rather than guessing. A silent fallback would eventually
|
|
# re-emit a live tag, which is what this step exists to prevent.
|
|
if [ -z "${LAST:-}" ]; then
|
|
echo "::error::could not read published semver tags from GHCR — refusing to guess the next patch"
|
|
exit 1
|
|
fi
|
|
|
|
IFS=. read -r MAJ MIN PAT <<<"$LAST"
|
|
NEXT="$MAJ.$MIN.$((PAT + 1))"
|
|
|
|
# 404 is the ONLY acceptable answer: anything else means the tag is
|
|
# taken (200) or we cannot tell (network/auth), and pushing on either
|
|
# risks a second digest behind an existing name.
|
|
CODE=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $TOKEN" \
|
|
-H 'Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.docker.distribution.manifest.v2+json' \
|
|
"https://ghcr.io/v2/hanzoai/cms/manifests/$NEXT")
|
|
if [ "$CODE" != "404" ]; then
|
|
echo "::error::refusing to push $NEXT — manifest probe returned $CODE, expected 404 (tag taken, or existence unverifiable)"
|
|
exit 1
|
|
fi
|
|
|
|
echo "tag=$NEXT" >> "$GITHUB_OUTPUT"
|
|
echo "last published $LAST -> building $NEXT"
|
|
|
|
- uses: docker/setup-buildx-action@v3
|
|
with:
|
|
driver: docker-container
|
|
driver-opts: network=host
|
|
|
|
# Without this the push is unauthenticated and 403s at the registry.
|
|
- uses: docker/login-action@v3
|
|
with:
|
|
registry: ghcr.io
|
|
username: ${{ secrets.GHCR_USER }}
|
|
password: ${{ secrets.GHCR_TOKEN }}
|
|
|
|
- uses: docker/build-push-action@v6
|
|
with:
|
|
context: .
|
|
file: ./Dockerfile
|
|
platforms: linux/amd64
|
|
push: true
|
|
provenance: false
|
|
tags: ghcr.io/hanzoai/cms:${{ steps.meta.outputs.tag }}
|
|
# Without these an image cannot be traced back to the commit that built
|
|
# it: `crane config` answers with no revision and the only way left to
|
|
# ask "what is running" is to guess from dates.
|
|
labels: |
|
|
org.opencontainers.image.revision=${{ github.sha }}
|
|
org.opencontainers.image.version=${{ steps.meta.outputs.tag }}
|
|
org.opencontainers.image.source=https://git.hanzo.ai/hanzo-apps/cms
|
|
secrets: |
|
|
GIT_AUTH_TOKEN=${{ secrets.GH_PAT }}
|
|
|
|
# build-push-action can exit 0 before the manifest resolves, so a green run
|
|
# could still mean a future ImagePullBackOff. Prove it pulls.
|
|
- name: Verify the pushed image resolves
|
|
run: |
|
|
set -euo pipefail
|
|
img="ghcr.io/hanzoai/cms:${{ steps.meta.outputs.tag }}"
|
|
for i in 1 2 3 4 5 6; do
|
|
if docker buildx imagetools inspect "$img" >/dev/null 2>&1; then
|
|
echo "resolved $img"; exit 0
|
|
fi
|
|
sleep 5
|
|
done
|
|
echo "::error::pushed image never resolved: $img"; exit 1
|
|
|
|
# THE DEPLOY. Everything above publishes an image; this is what makes
|
|
# production run it. cd.hanzo.ai watches hanzo/universe — NOT
|
|
# hanzoai/universe, a separate repo with the same layout that deploys
|
|
# nothing — so an image nothing points at is just bytes in ghcr.
|
|
- name: Ship it
|
|
env:
|
|
KMS_CLIENT_ID: ${{ secrets.KMS_CLIENT_ID }}
|
|
KMS_CLIENT_SECRET: ${{ secrets.KMS_CLIENT_SECRET }}
|
|
KMS_ENDPOINT: ${{ vars.KMS_ENDPOINT || 'https://kms.hanzo.ai' }}
|
|
KMS_SECRET_ENV: ${{ vars.KMS_SECRET_ENV || 'prod' }}
|
|
# pin.sh probes the registry before it moves anything; cms is a private
|
|
# package, so without these the read falls back to anonymous and 401s.
|
|
GHCR_USER: ${{ secrets.GHCR_USER }}
|
|
GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
VERSION="${{ steps.meta.outputs.tag }}"
|
|
|
|
# Secrets come from KMS, never from a file or a repo variable.
|
|
KMS_TOKEN=$(curl -fsS "${KMS_ENDPOINT}/v1/kms/auth/login" \
|
|
-H 'Content-Type: application/json' \
|
|
-d "{\"clientId\":\"${KMS_CLIENT_ID}\",\"clientSecret\":\"${KMS_CLIENT_SECRET}\"}" \
|
|
| jq -r '.accessToken // empty')
|
|
[ -n "$KMS_TOKEN" ] || { echo "::error::KMS login failed at ${KMS_ENDPOINT}"; exit 1; }
|
|
|
|
# The org is carried by the token, not the path — an `/orgs/<org>/`
|
|
# segment here answers 404.
|
|
PIN_TOKEN=$(curl -fsS \
|
|
"${KMS_ENDPOINT}/v1/kms/secrets/deploy/UNIVERSE_PIN_TOKEN?env=${KMS_SECRET_ENV}" \
|
|
-H "Authorization: Bearer ${KMS_TOKEN}" | jq -r '.value // empty')
|
|
[ -n "$PIN_TOKEN" ] || { echo "::error::UNIVERSE_PIN_TOKEN missing in KMS at deploy (env ${KMS_SECRET_ENV})"; exit 1; }
|
|
echo "::add-mask::${PIN_TOKEN}"
|
|
|
|
# Shallow: universe is large and this needs one file from the tip.
|
|
git clone --quiet --depth 1 \
|
|
"https://x-access-token:${PIN_TOKEN}@git.hanzo.ai/hanzo/universe" /tmp/universe
|
|
|
|
# Another service can land its own pin between our clone and our push.
|
|
# That is a normal race, not a failure — but a shallow clone has no
|
|
# common history to rebase onto, so RE-APPLY rather than merge: reset to
|
|
# the new tip and pin again. Pinning is idempotent and derives
|
|
# everything from the file it finds, so the retry also re-checks the
|
|
# monotonic guard against whatever just landed.
|
|
for attempt in 1 2 3 4 5; do
|
|
/tmp/universe/charts/app/pin.sh cms "$VERSION"
|
|
|
|
if err=$(git -C /tmp/universe push --quiet origin HEAD:main 2>&1); then
|
|
echo "pinned cms to ${VERSION}"; exit 0
|
|
fi
|
|
|
|
# Only a non-fast-forward is a race. A bad or under-scoped PIN_TOKEN,
|
|
# a protected branch or a read-only remote cannot clear by retrying.
|
|
case "$err" in
|
|
*non-fast-forward*|*"fetch first"*|*"Updates were rejected"*)
|
|
echo "universe moved under us (attempt ${attempt}/5) — re-applying on the new tip"
|
|
git -C /tmp/universe fetch --quiet --depth 1 origin main
|
|
git -C /tmp/universe reset --hard --quiet FETCH_HEAD
|
|
;;
|
|
*)
|
|
echo "::error::pushing the pin to universe failed, and retrying cannot fix it:"
|
|
printf '%s\n' "$err"
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
echo "::error::could not push the pin after 5 attempts"; exit 1
|