One build can publish a site per brand

A white-labelled product is one codebase and several hosts, and a site keys on
its HOST — so a brand per host is a site per host. The lane could publish one,
which left every such product serving its brands from containers: an image to
build, a registry to push, a Deployment to reconcile and replicas to schedule,
so a directory of files could be read over HTTP. The brand arrived at runtime,
baked into the image for one and mounted over /public from a ConfigMap for the
next, and both need a pod to hold them.

`sites:` is a list of slug and dir; `site:` still means one, and every caller
using it is untouched. Both resolve to the same slug=dir lines before anything
acts on them, so there is one path through the rest.

bin/sitedeploy and bin/sitepublish stay single-site. A publish IS one site;
WHICH sites to publish is orchestration, and orchestration is what the action
and this workflow are. Neither gets a second copy of what a publish means.

The branch gate is the list's, so every brand of one product goes live together.
A rollout that reached one brand and not another is the drift this lane exists
to remove.

The pair list is read from a file rather than a pipe. The loop body runs curl,
curl inherits stdin, and a piped list would be swallowed after the first entry —
publishing one site and reporting success for all of them.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-14 08:46:00 -07:00
co-authored by Hanzo Dev
parent 0dcc3ad641
commit 951df85357
2 changed files with 75 additions and 14 deletions
+33 -3
View File
@@ -29,10 +29,21 @@ description: Publish a built static export to the Hanzo PaaS Sites plane.
inputs:
slug:
description: The project slug on the Sites plane (POST /v1/projects/<slug>/deployments)
required: true
required: false
default: ''
dir:
description: The built export directory
required: true
required: false
default: ''
sites:
description: >-
Several sites from one build, one `slug=dir` pair per line. A white-labelled
product is one codebase and several hosts, and a site keys on its HOST — so
a brand per host is a site per host, and one build writes an export per
brand. bin/sitedeploy stays single-site: a publish IS one site, and which
sites to publish is orchestration, which is what this file is.
required: false
default:
api:
description: Cloud API base
required: false
@@ -101,4 +112,23 @@ runs:
SITEDEPLOY_BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
bash "$RUNNER_TEMP/sitedeploy-ci/bin/sitedeploy" '${{ inputs.slug }}' '${{ inputs.dir }}'
pairs='${{ inputs.sites }}'
[ -n "${pairs//[[:space:]]/}" ] || pairs='${{ inputs.slug }}=${{ inputs.dir }}'
if [ "${pairs//[[:space:]]/}" = "=" ]; then
echo "::error::nothing to publish: pass slug: and dir:, or sites: with one slug=dir per line."
exit 1
fi
# A file, not a pipe. The loop body runs curl, curl inherits stdin, and
# it would swallow the rest of the list — publishing the first site and
# reporting success for all of them.
printf '%s\n' "$pairs" > "$RUNNER_TEMP/site-pairs"
n=0
while IFS= read -r pair; do
[ -n "${pair//[[:space:]]/}" ] || continue
case "$pair" in *=*) ;; *) echo "::error::sites: entry '$pair' is not slug=dir"; exit 1 ;; esac
echo "::group::publish ${pair%%=*}"
bash "$RUNNER_TEMP/sitedeploy-ci/bin/sitedeploy" "${pair%%=*}" "${pair#*=}"
echo "::endgroup::"
n=$((n + 1))
done < "$RUNNER_TEMP/site-pairs"
[ "$n" -gt 0 ] || { echo "::error::sites: named no site"; exit 1; }
+42 -11
View File
@@ -1888,24 +1888,46 @@ jobs:
REF="${{ github.ref_name }}"
# A caller with no `site:` ships no static export, and reading
# .site.slug off null would abort the step instead of skipping it.
if [ "$(yq -r '.site | type' hanzo.yml 2>/dev/null || echo '!!null')" != '!!map' ]; then
echo "::notice::no site: in hanzo.yml — nothing to publish"; exit 0
#
# `site:` is one, `sites:` is several. A white-labelled product builds
# once and ships an export per brand, and a site keys on its HOST, so a
# brand per host is a site per host. Both spellings land on the same
# slug=dir lines, so everything after this is one path.
SITE_T=$(yq -r '.site | type' hanzo.yml 2>/dev/null || echo '!!null')
SITES_T=$(yq -r '.sites | type' hanzo.yml 2>/dev/null || echo '!!null')
if [ "$SITE_T" != '!!map' ] && [ "$SITES_T" != '!!seq' ]; then
echo "::notice::no site:/sites: in hanzo.yml — nothing to publish"; exit 0
fi
if [ "$SITE_T" = '!!map' ] && [ "$SITES_T" = '!!seq' ]; then
echo "::error::hanzo.yml declares BOTH site: and sites:. One list, one spelling."; exit 1
fi
# Same branch gate as deploy.on, including the tag case: a tagged release
# publishes, a branch publishes only if it is named.
ON=$(yq -o=json -I=0 '.site.on // []' hanzo.yml)
# publishes, a branch publishes only if it is named. The gate is the
# LIST's, so every brand of one product goes live together — a rollout
# that reached one brand and not another is the drift this lane removes.
if [ "$SITES_T" = '!!seq' ]; then
ON=$(yq -o=json -I=0 '.sites[0].on // []' hanzo.yml)
else
ON=$(yq -o=json -I=0 '.site.on // []' hanzo.yml)
fi
IS_TAG=$([ "${{ github.ref_type }}" = "tag" ] && echo 1 || echo 0)
if [ "$IS_TAG" != 1 ] && ! echo "$ON" | jq -e --arg b "$REF" 'index($b)' >/dev/null; then
echo "branch $REF not in site.on — skipping publish"; exit 0
fi
SLUG=$(yq -r '.site.slug // ""' hanzo.yml)
DIR=$(yq -r '.site.dir // ""' hanzo.yml)
BUILD=$(yq -r '.site.build // ""' hanzo.yml)
if [ -z "$SLUG" ] || [ -z "$DIR" ]; then
echo "::error::site: needs both slug: (the /v1/sites project) and dir: (the built export)"; exit 1
if [ "$SITES_T" = '!!seq' ]; then
PAIRS=$(yq -r '.sites[] | "\(.slug)=\(.dir)"' hanzo.yml)
BUILD=$(yq -r '.["site-build"] // ""' hanzo.yml)
else
SLUG=$(yq -r '.site.slug // ""' hanzo.yml)
DIR=$(yq -r '.site.dir // ""' hanzo.yml)
BUILD=$(yq -r '.site.build // ""' hanzo.yml)
PAIRS="$SLUG=$DIR"
fi
if printf '%s\n' "$PAIRS" | grep -qE '^=|=$'; then
echo "::error::every site needs both slug: (the /v1/sites project) and dir: (the built export)"; exit 1
fi
if [ -n "$BUILD" ]; then
echo "::group::build $SLUG ($BUILD)"
echo "::group::build ($BUILD)"
sh -c "$BUILD"
echo "::endgroup::"
fi
@@ -1919,6 +1941,15 @@ jobs:
# Slug grammar, index.html, the server's file/byte caps and the edge
# BodyLimit are all checked inside sitepublish, before anything is sent,
# so each refusal names its own number instead of arriving as a 4xx.
HANZO_API="$API" "$CI_HOME/bin/sitepublish" "$SLUG" "$DIR"
# One publish per site, from the one implementation. A file, not a
# pipe: sitepublish runs curl, and curl inherits stdin — it would eat
# the rest of the list and report success for sites it never sent.
printf '%s\n' "$PAIRS" > "$RUNNER_TEMP/site-pairs"
while IFS= read -r pair; do
[ -n "${pair//[[:space:]]/}" ] || continue
echo "::group::publish ${pair%%=*}"
HANZO_API="$API" "$CI_HOME/bin/sitepublish" "${pair%%=*}" "${pair#*=}"
echo "::endgroup::"
done < "$RUNNER_TEMP/site-pairs"