mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-24 20:09:02 +00:00
ci: one trigger publishes, and the gate belongs to the recipe
docs.hanzo.ai could not be republished. This makes every path that could do it real or explicitly named, and moves the one check that matters somewhere every path passes through. The export gate is now in the Dockerfile. This site fails by exporting NOTHING: the build succeeds, the layer is valid, the push succeeds, the host answers 404, and no builder notices because from a builder's point of view nothing went wrong. It used to be gated between build and push in deploy.yml, which only works for deploy.yml — buildx builds and pushes in a single invocation with no step in between to hold, and hanzo.yml's `test:` block runs AFTER that push, so neither could have held it back. scripts/check-export.sh states what a site is, once; apps/<app>/export.require names the sections that vanish without breaking anything else. A failed gate means no image exists, so no lane can push past it. deploy.yml keeps its own call of the same script against the finished image, which answers the one question the build stage cannot: did the export land in the IMAGE. Tested against seven exports — healthy, empty, nav-less, under the page floor, section missing, absent directory, default require-file resolution — under both dash and busybox ash. docs/studio/ is the case that motivates the require file. It is a submodule, and hanzoai/ci checks out without recursing, so on that lane the section silently disappears while page count and nav both stay green. .github/workflows/cicd.yml is seven lines importing hanzoai/ci, configured by the root hanzo.yml. It is workflow_dispatch-only: give this repo two push-triggered builders for one image and one commit yields two images under two tag schemes, and the pin names one of them by luck. It names our own runner pool, the same label the forge lane answers to. It cannot schedule yet, and the reason is not Actions — Actions is on and the repo is public; every runner we own is registered to git.hanzo.ai, and GitHub-hosted runners are not something we build on. RELEASE.md is the runbook: four builders, the credential each needs, and the exact edit that makes an image live. The finding worth repeating here is that universe pins a digest beside the tag and the chart renders repo:tag@digest — so the digest is what gets pulled, and moving the tag alone is a silent no-op. Verified against the live systems rather than the docs: the pin resolves to the digest the values file names; no image exists for current main in ANY of the three tag shapes, so all lanes are cold rather than one quietly working; the forge repo answers 404 on /actions while another repo on the same forge answers 200; /v1/runner answers 401 with the token configured on the far side, so it is armed and one credential away. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
# The GitHub lane: hanzoai/ci, driven by this repo's root hanzo.yml.
|
||||
#
|
||||
# Manual only, and that is deliberate. `.hanzo/workflows/deploy.yml` is the
|
||||
# builder that fires by itself; two push-triggered builders for one image means
|
||||
# one commit produces two images under two different tag schemes, and the pin in
|
||||
# hanzoai/universe then names one of them by luck. So exactly one of the two is
|
||||
# ever armed, and this is the one a human arms.
|
||||
#
|
||||
# It also cannot schedule today, and the reason is not Actions. Actions is ON for
|
||||
# this repo (`{"enabled":true,"allowed_actions":"all"}`) and the repo is public,
|
||||
# so a run dispatches fine — it just has nowhere to run. Every runner we own is
|
||||
# an act_runner registered to git.hanzo.ai, not to github.com; arcd, which was
|
||||
# the GitHub-side pool, is retired, and the one leftover registration on this org
|
||||
# (hanzo-docs-build-linux-amd64-5dzd2-runner-dcm4t) is offline and advertises no
|
||||
# labels. A run started now sits pending against a runner GitHub cannot see.
|
||||
#
|
||||
# The fix is to register one of our runners against this org, NOT to reach for
|
||||
# ubuntu-latest. GitHub-hosted runners are the one thing this fleet does not
|
||||
# build on, and a workflow that names one executes immediately and quietly makes
|
||||
# that untrue. The runner label below is the same one the forge lane already
|
||||
# answers to, so the two lanes never disagree about where a build belongs.
|
||||
name: CI/CD
|
||||
on:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
cicd:
|
||||
uses: hanzoai/ci/.github/workflows/build.yml@v1
|
||||
with:
|
||||
runner: '["hanzo-docs-build-linux-amd64"]'
|
||||
secrets: inherit
|
||||
+40
-21
@@ -1,14 +1,27 @@
|
||||
name: deploy
|
||||
|
||||
# Builds docs.hanzo.ai and publishes the image the operator runs. A build never
|
||||
# deploys itself: the tag that is live is named by hanzoai/universe
|
||||
# infra/k8s/operator/crs/docs.yaml, set by a human.
|
||||
# deploys itself: what is live is named by hanzoai/universe
|
||||
# charts/app/values/hanzo/docs.yaml, set by a human. That file carries
|
||||
# `cd.automated: true`, so the commit that edits it IS the rollout.
|
||||
#
|
||||
# The predecessor did deploy itself — it patched `app docs` with kubectl and
|
||||
# waited on the rollout — and it built the image from a heredoc Dockerfile
|
||||
# pinned to static:0.4.1 while the repo's own Dockerfile, the one the fabric
|
||||
# actually builds, pinned v0.5.1. Two builders for one image is one too many, so
|
||||
# the recipe lives in the Dockerfile alone and this file only runs it.
|
||||
#
|
||||
# THIS FILE CANNOT RUN TODAY. git.hanzo.ai/hanzo-docs/docs is a pull mirror with
|
||||
# no Actions unit (/actions 404s; hanzoai/cloud on the same forge answers 200),
|
||||
# and a mirror sync moves refs without firing a push event, so neither trigger
|
||||
# below can reach a runner. It is kept, not deleted, because it is the only
|
||||
# definition of the tag hanzoai/universe already pins — the full 40-char SHA.
|
||||
# See RELEASE.md for every path that can publish today and what each one needs.
|
||||
#
|
||||
# It is also the ONLY builder here that fires by itself. .github/workflows/
|
||||
# cicd.yml is workflow_dispatch-only for exactly that reason: give this repo two
|
||||
# push-triggered builders for one image and the same commit produces two images
|
||||
# under two tag schemes, and the pin in universe names one of them by luck.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -40,29 +53,23 @@ jobs:
|
||||
- name: build
|
||||
run: docker build -t "docs:${GITHUB_SHA}" .
|
||||
|
||||
# A broken export must never reach the registry, so the gate runs between
|
||||
# the build and the push. It reads the image rather than a host-side
|
||||
# directory: what serves is what is checked. hanzoai/static is FROM scratch
|
||||
# and has no shell, so /public comes out with docker cp.
|
||||
- name: validate export
|
||||
# The gate itself runs inside the Dockerfile, so `docker build` above has
|
||||
# already refused a broken export and this step cannot be the thing that
|
||||
# saves us — which is the point: every builder gets it, not just this one.
|
||||
#
|
||||
# What is left for this step is the one question the build stage cannot
|
||||
# answer: did the export actually land in the IMAGE. That is a different
|
||||
# claim from "the export was good", and it is the claim that matters to a
|
||||
# registry. Same script, same rules, read out of the finished image —
|
||||
# hanzoai/static is FROM scratch and has no shell, so /public comes out
|
||||
# with docker cp.
|
||||
- name: validate export in the image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cid="$(docker create "docs:${GITHUB_SHA}")"
|
||||
trap 'docker rm -f "${cid}" >/dev/null' EXIT
|
||||
rm -rf out && docker cp "${cid}:/public" out
|
||||
|
||||
fail=0
|
||||
for f in index.html docs/index.html docs/services/index.html; do
|
||||
[ -s "out/$f" ] || { echo "::error::missing or empty /public/$f"; fail=1; }
|
||||
done
|
||||
n="$(find out -name '*.html' | wc -l)"
|
||||
echo "html pages exported: ${n}"
|
||||
[ "$n" -ge 50 ] || { echo "::error::only ${n} html pages — the export looks broken"; fail=1; }
|
||||
# trailingSlash: /docs must be docs/index.html, not docs.html, which
|
||||
# hanzoai/static answers with a 404.
|
||||
grep -q 'href="/docs/' out/docs/index.html 2>/dev/null \
|
||||
|| { echo "::error::/docs carries no nav — shell-only render"; fail=1; }
|
||||
exit "$fail"
|
||||
sh scripts/check-export.sh out apps/docs/export.require
|
||||
|
||||
- name: push
|
||||
env:
|
||||
@@ -73,8 +80,20 @@ jobs:
|
||||
echo "::error::REGISTRY_TOKEN is unset, so nothing was published. Add the org secret."
|
||||
exit 1
|
||||
fi
|
||||
# The FULL 40-char SHA. universe already pins exactly this shape; see
|
||||
# RELEASE.md for what every other builder tags instead.
|
||||
image="ghcr.io/hanzoai/docs:${GITHUB_SHA}"
|
||||
echo "${REGISTRY_TOKEN}" | docker login ghcr.io -u hanzo-dev --password-stdin
|
||||
docker tag "docs:${GITHUB_SHA}" "${image}"
|
||||
docker push "${image}"
|
||||
echo "published ${image} — set spec.image.tag in universe crs/docs.yaml to ${GITHUB_SHA} to make it live"
|
||||
# The digest is what a pin actually PULLS: the chart renders
|
||||
# repo:tag@digest and the digest wins, so moving the tag beside a stale
|
||||
# digest changes nothing at all. Print both, in the shape they are pasted.
|
||||
# Never fail the step over this — the image is already published, and a
|
||||
# missing digest line is a paste-it-yourself, not a failed release.
|
||||
digest="$(docker inspect --format='{{range .RepoDigests}}{{println .}}{{end}}' "${image}" \
|
||||
| grep "^ghcr.io/hanzoai/docs@" | head -1 | cut -d@ -f2 || true)"
|
||||
echo "published ${image}"
|
||||
echo "to make it live — hanzoai/universe charts/app/values/hanzo/docs.yaml:"
|
||||
echo " tag: ${GITHUB_SHA}"
|
||||
echo " digest: ${digest:-<crane digest ${image}>}"
|
||||
|
||||
+11
@@ -34,6 +34,17 @@ ENV NEXT_EXPORT=1 \
|
||||
ARG APP=docs
|
||||
RUN pnpm build --filter="${APP}"
|
||||
|
||||
# The export gate, INSIDE the recipe — so it is not a property of one builder.
|
||||
#
|
||||
# A static-export build fails by exporting nothing: the layer is valid, the push
|
||||
# succeeds, and the host answers 404. Gating here means the failure happens
|
||||
# before an image exists, for every builder, without any of them knowing this
|
||||
# repo is special — buildx `--push` builds and pushes in one invocation and has
|
||||
# no step in between to hold, and an in-cluster BuildKit job has none either.
|
||||
# scripts/check-export.sh states what "a site" means; apps/${APP}/export.require
|
||||
# names the sections this app must not silently lose.
|
||||
RUN sh scripts/check-export.sh "apps/${APP}/out"
|
||||
|
||||
FROM ghcr.io/hanzoai/static:v0.5.1
|
||||
# Re-declared: an ARG is scoped to the stage that names it.
|
||||
ARG APP=docs
|
||||
|
||||
@@ -4,34 +4,58 @@ Fork of [Hanzo Docs](https://github.com/hanzoai/docs) with all packages renamed
|
||||
|
||||
## How this ships
|
||||
|
||||
One way, and it runs on our own stack:
|
||||
**Full runbook: [`RELEASE.md`](./RELEASE.md)** — every path that can publish, the
|
||||
credential each one needs, and the exact edit that makes an image live. What
|
||||
follows is the shape; that file is the detail.
|
||||
|
||||
push -> github.com/hanzoai/docs (a mirror)
|
||||
.github/workflows/sync.yml carries refs onward
|
||||
-> git.hanzo.ai/hanzoai/docs CANONICAL
|
||||
push -> github.com/hanzo-docs/docs origin
|
||||
.github/workflows/cicd.yml hanzoai/ci, workflow_dispatch only
|
||||
-> git.hanzo.ai/hanzo-docs/docs a PULL mirror
|
||||
.hanzo/workflows/lint.yml oxfmt, tsc, oxlint
|
||||
.hanzo/workflows/test.yml vitest
|
||||
.hanzo/workflows/release.yml publishes the npm packages
|
||||
.hanzo/workflows/sync-zen-pricing.yml the daily pricing commit
|
||||
.hanzo/workflows/deploy.yml builds ghcr.io/hanzoai/docs
|
||||
-> hanzoai/universe crs/docs.yaml names the tag that is live
|
||||
-> hanzoai/operator reconciles the App
|
||||
-> POST /v1/runner the fabric that built what is live
|
||||
-> hanzoai/universe charts/app/values/hanzo/docs.yaml
|
||||
names what is live; cd.automated
|
||||
-> hanzoai/static behind hanzoai/ingress serves docs.hanzo.ai
|
||||
|
||||
**git.hanzo.ai is canonical; GitHub is a mirror.** `.github/workflows/` holds
|
||||
exactly one file, `sync.yml`, and its only job is getting refs to the forge. Every
|
||||
build, check, publish and deploy is a workflow under `.hanzo/workflows/`, which the
|
||||
forge reads. `.hanzo/workflows` uses GitHub Actions syntax, so a workflow moves
|
||||
between the two by changing directory and nothing else — which is how all of these
|
||||
got here, `runs-on: hanzo-docs-build-linux-amd64` and all. The forge's runner
|
||||
answers that label.
|
||||
Every build, check, publish and deploy is a workflow under `.hanzo/workflows/`,
|
||||
which the forge reads. `.hanzo/workflows` uses GitHub Actions syntax, so a
|
||||
workflow moves between the two directories and nothing else changes — which is how
|
||||
all of these got here, `runs-on: hanzo-docs-build-linux-amd64` and all.
|
||||
|
||||
**None of them can run today.** `git.hanzo.ai/hanzo-docs/docs` is a pull mirror
|
||||
with **no Actions unit at all** — `/actions` on it 404s while `hanzoai/cloud` on
|
||||
the same forge answers 200 — and a mirror sync moves refs without firing a push
|
||||
event, so even with a unit the `on: push` triggers would not fire. The refs
|
||||
themselves are fine: the mirror's `main` matches GitHub's exactly.
|
||||
|
||||
`.github/workflows/cicd.yml` is the other lane: seven lines importing hanzoai/ci,
|
||||
configured by the root `hanzo.yml`. It is `workflow_dispatch`-only, because two
|
||||
push-triggered builders for one image means one commit yields two images under two
|
||||
tag schemes. It also cannot schedule yet — Actions is enabled and the repo is
|
||||
public, but every runner we own answers to git.hanzo.ai and not to github.com,
|
||||
and GitHub-hosted runners are not something we build on.
|
||||
|
||||
`deploy.yml` builds and pushes, and stops. Its predecessor patched `app docs` with
|
||||
kubectl and waited on the rollout, and it built the image from a heredoc pinned to
|
||||
`static:0.4.1` while this repo's `Dockerfile` — the one the fabric builds — pinned
|
||||
`v0.5.1`. The recipe now lives in the `Dockerfile` alone; the workflow runs it,
|
||||
gates the export by reading `/public` out of the built image, and pushes only if
|
||||
the gate passes. A human then sets `spec.image.tag` in universe.
|
||||
`v0.5.1`. The recipe lives in the `Dockerfile` alone now; a workflow only runs it.
|
||||
|
||||
**The export gate is in the Dockerfile**, not in any workflow. This site fails by
|
||||
exporting nothing — a valid layer, a valid image, a 404 — and no builder can
|
||||
notice that. `scripts/check-export.sh` says what a site is and
|
||||
`apps/<app>/export.require` names the sections that must not silently vanish
|
||||
(`docs/studio/` is a submodule; a checkout that does not recurse drops it while
|
||||
page count and nav stay green). A failed gate means no image exists, so no lane
|
||||
can push past it.
|
||||
|
||||
The tag that goes live is set by hand in `hanzoai/universe`
|
||||
`charts/app/values/hanzo/docs.yaml`, which carries `cd.automated: true` — so that
|
||||
commit IS the rollout. Note that the file pins a `digest:` beside the `tag:`, and
|
||||
the digest is what actually gets pulled: moving the tag alone changes nothing.
|
||||
|
||||
### The nine sibling sites still on Cloudflare Pages
|
||||
|
||||
@@ -45,7 +69,13 @@ stale for a day.
|
||||
They are not done. To finish one:
|
||||
|
||||
1. `docker build --build-arg APP=<app> .` — the root `Dockerfile` takes `APP`, so
|
||||
no new Dockerfile is needed for any of them.
|
||||
no new Dockerfile is needed for any of them. Two things are true of `apps/docs`
|
||||
and of none of the siblings yet, and both stop this build: only
|
||||
`apps/docs/next.config.mts` honours `NEXT_EXPORT` with `output: 'export'`, so a
|
||||
sibling writes no `out/` at all; and the export gate's floor of 50 pages is the
|
||||
hub's shape, which a seven-page sibling will not clear. Give the sibling
|
||||
`output: 'export'` first, then reconcile the floor —
|
||||
`scripts/check-export.sh` is where it is stated, once, for everything.
|
||||
2. Add `infra/k8s/operator/crs/<name>.yaml` in hanzoai/universe, copying
|
||||
`hips.yaml`: `containerPort 3000` / `servicePort 80`, `HANZO_STATIC_CSP`,
|
||||
`imagePullSecrets: ghcr-secret`, **empty tag**, and **not** listed in
|
||||
@@ -113,8 +143,9 @@ at docs.hanzo.ai/docs/contributing/docs-architecture). Summary:
|
||||
of: ≈150+ pages or fast OSS-upstream churn, independent versioning, direct
|
||||
audience. Standalone runs its own copy of this framework; the hub links out,
|
||||
never copies.
|
||||
- **Serving:** CF Pages `hanzo-docs` (token from KMS, never hard-coded) or
|
||||
`ghcr.io/hanzoai/docs` behind hanzoai/ingress. No nginx/caddy.
|
||||
- **Serving:** `ghcr.io/hanzoai/docs` behind hanzoai/ingress for docs.hanzo.ai.
|
||||
The nine sibling hosts below are still CF Pages `hanzo-docs` (token from KMS,
|
||||
never hard-coded). No nginx/caddy.
|
||||
|
||||
**Known dedup debt (rollout, not done):** the `apps/*-docs` legacy apps
|
||||
(base-docs, bootnode-docs, bot-docs, cloud, dev-docs, dns-docs, flow, gui-docs,
|
||||
@@ -125,7 +156,9 @@ app.
|
||||
|
||||
## Branch Convention
|
||||
|
||||
- **`main`** — Production branch. CF Pages deploys docs.hanzo.ai from here. All Hanzo work lands here.
|
||||
- **`main`** — Production branch. docs.hanzo.ai is built from here and served
|
||||
in-cluster from `ghcr.io/hanzoai/docs`; landing on `main` does not publish, a
|
||||
build plus a pin in universe does (`RELEASE.md`). All Hanzo work lands here.
|
||||
- **`dev`** — Tracks upstream `Hanzo Docs/dev`. Used for upstream sync merges only.
|
||||
- **`upstream`** remote — points to `hanzoai/docs`
|
||||
|
||||
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
# Publishing docs.hanzo.ai
|
||||
|
||||
Everything a publish needs, and what each path is waiting on. Every fact below
|
||||
was read off the live systems, not off a design doc; where something could not be
|
||||
checked, it says so.
|
||||
|
||||
## Publishing is two acts
|
||||
|
||||
1. build + push ghcr.io/hanzoai/docs:<tag>
|
||||
2. move the pin hanzoai/universe charts/app/values/hanzo/docs.yaml
|
||||
|
||||
Act 2 is the deploy. That file carries `cd.automated: true`, which makes the
|
||||
GitOps plane the sole writer of the workload and self-heal anything else that
|
||||
touches it — so the commit that edits it IS the rollout, and conversely a
|
||||
`kubectl patch` or a `deploy:` block in `hanzo.yml` would be reverted on the next
|
||||
sync. Nothing goes live because it was built; something goes live because that
|
||||
file names it.
|
||||
|
||||
## Where things stand
|
||||
|
||||
`ghcr.io/hanzoai/docs` is anonymously pullable, so this is checkable by anyone:
|
||||
|
||||
| tag | shape | exists |
|
||||
|---|---|---|
|
||||
| `e8fb4369c925e75b1277d5a4a1c61e16a9bd93be` | full SHA — the live pin | yes |
|
||||
| `latest` | floating | yes, and it is a **different** image from the pin |
|
||||
| `f2ad2c477311dc1acfc8fbb725e313777b6dd0f0` | full SHA — current `main` | no |
|
||||
| `f2ad2c477311dc1acfc8fbb725e313777b6dd0f0-amd64-docs` | platform webhook shape | no |
|
||||
| `sha-f2ad2c4-amd64` | hanzoai/ci shape | no |
|
||||
|
||||
The live pin resolves to `sha256:2b589f95…`, which is byte-for-byte the `digest:`
|
||||
in the values file — the pin mechanism is sound. The last three rows are the
|
||||
useful ones: **no builder has produced an image for current `main`**, in any of
|
||||
the three tag shapes, so all lanes are genuinely cold rather than one of them
|
||||
quietly working. `main` is 2 commits ahead of what is serving.
|
||||
|
||||
## Act 1 — the four ways to build
|
||||
|
||||
### 1. The runner fabric — `POST /v1/runner`
|
||||
|
||||
The only path that is one credential away from working. The Dockerfile names it
|
||||
as the real builder, and it is what produced the image now serving.
|
||||
|
||||
```
|
||||
POST https://platform.hanzo.ai/v1/runner
|
||||
Authorization: Bearer $PLATFORM_BUILD_CALLBACK_TOKEN
|
||||
Content-Type: application/json
|
||||
|
||||
{"repo":"hanzo-docs/docs",
|
||||
"sha":"<40-char sha>",
|
||||
"image":"ghcr.io/hanzoai/docs:<40-char sha>"}
|
||||
```
|
||||
|
||||
or, the same request with the recipe read for you:
|
||||
|
||||
```
|
||||
hanzo build hanzo-docs/docs --sha <40-char sha> \
|
||||
--image ghcr.io/hanzoai/docs:<40-char sha>
|
||||
```
|
||||
|
||||
**Credential: `PLATFORM_BUILD_CALLBACK_TOKEN`**, in KMS. Unauthenticated the
|
||||
route answers `401 {"message":"Invalid enqueue token"}` — a 401, not a 403, and
|
||||
not the `500 PLATFORM_BUILD_CALLBACK_TOKEN is not configured` it returns when the
|
||||
server lacks the token. So the endpoint is armed and correct on the far side; the
|
||||
only missing piece is the bearer.
|
||||
|
||||
Two things to know before using it:
|
||||
|
||||
- **`hanzo build` will not fall back to your login.** The CLI resolves the bearer
|
||||
as `--build-token` → `HANZO_BUILD_TOKEN` → `PLATFORM_BUILD_CALLBACK_TOKEN` →
|
||||
the stored build token → *your IAM access token*, and its help says an IAM
|
||||
login authorizes a build. The deployed route disagrees: it compares the bearer
|
||||
against `PLATFORM_BUILD_CALLBACK_TOKEN` and nothing else, so an IAM token gets
|
||||
the same 401 as no token. Set `HANZO_BUILD_TOKEN` to the enqueue token.
|
||||
- **It builds and pushes; it does not deploy.** Rollout is driven by the repo's
|
||||
`deploy:` block, and this repo deliberately has none, so the job records
|
||||
`rolloutStatus: skipped`. Act 2 is still yours.
|
||||
|
||||
The image tag is whatever you put in `image:` — the route takes it verbatim. Pass
|
||||
the full 40-char SHA and the pin needs no reconciling at all.
|
||||
|
||||
### 2. The forge — `.hanzo/workflows/deploy.yml`
|
||||
|
||||
Blocked, and not on billing or a secret.
|
||||
|
||||
`git.hanzo.ai/hanzo-docs/docs` is a **pull mirror with no Actions unit**:
|
||||
`/actions` on it answers 404 while `git.hanzo.ai/hanzoai/cloud/actions` on the
|
||||
same instance answers 200. Even with a unit, a mirror sync moves refs without
|
||||
firing a push event, so the `on: push` trigger would not fire either. (The sync
|
||||
itself is healthy — the mirror's `main` matches GitHub's exactly.)
|
||||
|
||||
The neighbouring `git.hanzo.ai/hanzoai/docs` *does* have an Actions unit, but it
|
||||
is a different, stale mirror sitting on an old commit, so it is not a way in.
|
||||
|
||||
**Credential when it does run: `REGISTRY_TOKEN`** (forge secret, GHCR write on
|
||||
the `hanzoai` org). The workflow already refuses to publish without it rather
|
||||
than silently skipping the push. **Tag: the full 40-char SHA** — this is the lane
|
||||
universe's pin shape was written for.
|
||||
|
||||
To unblock: give `hanzo-docs/docs` on the forge an Actions unit, and make the
|
||||
mirror a real push target rather than a pull mirror. Then disarm one of the two
|
||||
builders — see the note at the top of `deploy.yml`.
|
||||
|
||||
### 3. GitHub — `.github/workflows/cicd.yml` → hanzoai/ci
|
||||
|
||||
Landed, correct, and unable to schedule. The blocker is a runner, not Actions.
|
||||
|
||||
- Actions is **on** (`{"enabled":true,"allowed_actions":"all"}`) and the repo is
|
||||
public, so runs dispatch normally. There were simply no workflow files: the
|
||||
last thirteen moved to `.hanzo/workflows/` and the final one, the mirror-sync
|
||||
nudge, was deleted as superseded.
|
||||
- Every runner we own is an `act_runner` registered to **git.hanzo.ai**, not to
|
||||
github.com. `arcd`, the pool that used to answer on the GitHub side, is
|
||||
retired. The single registration left on this org
|
||||
(`hanzo-docs-build-linux-amd64-5dzd2-runner-dcm4t`) is offline and advertises
|
||||
no labels.
|
||||
|
||||
So a dispatched run sits pending against a runner GitHub cannot see. The workflow
|
||||
is `workflow_dispatch:` only, which keeps that from happening on every push and
|
||||
keeps it from racing the forge lane; it names our own pool label, deliberately —
|
||||
**do not point it at a GitHub-hosted runner.** That is the one thing this fleet
|
||||
does not build on, and a workflow that names one starts working immediately,
|
||||
which is exactly what makes the rule easy to break by accident.
|
||||
|
||||
**Credentials, all already present as repo secrets:** `KMS_CLIENT_ID` and
|
||||
`KMS_CLIENT_SECRET` — hanzoai/ci uses them to pull the org's GHCR write token
|
||||
(`buildx-ghcr-auth`) at run time. Without them it falls back to the job's own
|
||||
`GITHUB_TOKEN`, which can only write a package linked to *this* repo and will 403
|
||||
against `ghcr.io/hanzoai/docs`; an org `GH_PAT` is the other way to satisfy it and
|
||||
is **not** set here. This lane has never run, so that fallback chain is reasoned
|
||||
from the workflow source, not observed.
|
||||
|
||||
**Tags: `sha-<sha7>-amd64` and `latest`** — neither is the shape universe pins,
|
||||
and `latest` currently points at an image unrelated to what is live, so a run here
|
||||
moves a floating tag that other pulls see.
|
||||
|
||||
### 4. The platform webhook
|
||||
|
||||
`/v1/github-webhook` derives builds from this `hanzo.yml` on a push event via the
|
||||
Hanzo GitHub App. It needs no work here and no credential from us. Its tag shape
|
||||
is `{{git.sha}}-amd64-<tag-suffix>`, i.e.
|
||||
`ghcr.io/hanzoai/docs:<40-char sha>-amd64-docs`.
|
||||
|
||||
Whether the App is installed for this repo could not be read with the token
|
||||
available. What *can* be said is that no image exists in that tag shape for
|
||||
current `main`, so it is not currently publishing this repo.
|
||||
|
||||
### Not a path: building on a workstation
|
||||
|
||||
`docker build .` produces the same image — that is the whole point of the recipe
|
||||
living in the Dockerfile. It is still not how this ships: images are built by the
|
||||
fabric for every org/arch, not on a laptop.
|
||||
|
||||
## Act 2 — the pin
|
||||
|
||||
`hanzoai/universe`, `charts/app/values/hanzo/docs.yaml`:
|
||||
|
||||
```yaml
|
||||
image:
|
||||
repository: ghcr.io/hanzoai/docs
|
||||
tag: e8fb4369c925e75b1277d5a4a1c61e16a9bd93be
|
||||
digest: sha256:2b589f957d50f2c14ce2114d6c75281ed426b2b39e79c757791914e8e570b25a
|
||||
```
|
||||
|
||||
**`digest:` is the line that changes what runs.** The chart renders
|
||||
`repository:tag@digest` when both are set, and a reference carrying a digest is
|
||||
resolved by the digest — the tag is a label a human reads. Move `tag:` alone and
|
||||
docs.hanzo.ai keeps serving exactly what it was serving, with a values file that
|
||||
says otherwise. That is a silent no-op, and it is the failure this section exists
|
||||
to prevent.
|
||||
|
||||
So the edit is: **`digest:` to make it live, `tag:` in the same commit so the file
|
||||
does not lie.** The digest of what you just pushed:
|
||||
|
||||
```
|
||||
crane digest ghcr.io/hanzoai/docs:<tag>
|
||||
```
|
||||
|
||||
Per builder, `tag:` becomes:
|
||||
|
||||
| builder | `tag:` |
|
||||
|---|---|
|
||||
| `POST /v1/runner` | the tag you named in `image:` (use the full 40-char SHA) |
|
||||
| forge `deploy.yml` | the full 40-char SHA |
|
||||
| platform webhook | `<40-char SHA>-amd64-docs` |
|
||||
| hanzoai/ci | `sha-<first 7 of the SHA>-amd64` |
|
||||
|
||||
`digest:` is the same shape in all four rows, which is the point — the digest is
|
||||
the identity, and the four tag schemes are four names for it.
|
||||
|
||||
Deleting the `digest:` line reduces the whole thing to a one-line `tag:` edit and
|
||||
is a real option, but it trades an immutable pin for a mutable one: a tag can be
|
||||
repointed, and then the file names an image that is no longer the image.
|
||||
|
||||
Committing that file to `main` on universe is the rollout. There is no second
|
||||
step and nothing to trigger.
|
||||
|
||||
## The export gate
|
||||
|
||||
This site fails by exporting **nothing**. The build succeeds, the layer is valid,
|
||||
the push succeeds, the host answers 404, and no builder notices, because from a
|
||||
builder's point of view nothing went wrong.
|
||||
|
||||
So the gate is inside the Dockerfile, which is the one thing every builder passes
|
||||
through:
|
||||
|
||||
scripts/check-export.sh what "a site" means, in one place
|
||||
apps/<app>/export.require the sections this app must not lose
|
||||
|
||||
A build that fails it never produces an image, so no lane can push past it — which
|
||||
matters most for the GitHub lane, where `docker buildx --push` builds and pushes
|
||||
in a single invocation and there is no step in between to hold, and where
|
||||
`hanzo.yml`'s `test:` block runs *after* the push and so cannot gate it either.
|
||||
|
||||
It checks: `index.html` and `docs/index.html` are non-empty; at least 50 HTML
|
||||
pages exported; `/docs` actually rendered its nav (a shell-only render produces
|
||||
both files and no links); and every page named in `export.require`.
|
||||
|
||||
That last one is for the failures that leave everything else green. `docs/studio/`
|
||||
is a git submodule — a checkout that does not recurse drops the section and the
|
||||
page count and nav checks both still pass. hanzoai/ci checks out **without**
|
||||
`submodules: recursive`, so on that lane this is not hypothetical.
|
||||
|
||||
Anything added to `export.require` is enforced on every lane at once, which is
|
||||
the only way a section stays required.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **Nothing gates a pull request.** The forge holds `lint.yml` and `test.yml` and
|
||||
cannot run them; the GitHub lane is dispatch-only, and could not be otherwise —
|
||||
hanzoai/ci has no test-without-build mode for a repo that declares `images:`, so
|
||||
a PR trigger would build and push an image, including moving `latest`, for every
|
||||
PR.
|
||||
- **`hanzo build`'s IAM fallback does not work against the deployed route** (see
|
||||
path 1). The CLI help promises it; the route only accepts the enqueue token.
|
||||
- **`latest` is not what the pin serves.** Nothing we run reads it, but anyone
|
||||
doing a bare `docker pull ghcr.io/hanzoai/docs` gets it, and the GitHub lane
|
||||
would move it.
|
||||
- **The GitHub lane has never executed.** Its credential chain is read from the
|
||||
workflow source. The first real run is the first evidence.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Pages this app must have exported, checked by scripts/check-export.sh.
|
||||
# One path per line, relative to the export root (apps/docs/out).
|
||||
#
|
||||
# These are the sections that go missing WITHOUT breaking anything else, so a
|
||||
# page count and a nav check both stay green while the section is gone:
|
||||
#
|
||||
# docs/services/ generated at build time by scripts/gen-services-nav.ts from
|
||||
# @hanzo/products. When that package does not resolve the
|
||||
# script warns and keeps the committed meta.json, so a broken
|
||||
# catalog is a warning in a log nobody reads.
|
||||
# docs/studio/ a git submodule (hanzo-docs/studio-docs). A checkout that
|
||||
# does not recurse leaves the directory empty and the section
|
||||
# simply does not exist in the output.
|
||||
docs/services/index.html
|
||||
docs/studio/index.html
|
||||
@@ -1,17 +1,24 @@
|
||||
# Canonical CI config for hanzo-docs/docs — read by the hanzoai/ci reusable
|
||||
# (.hanzo/workflows/) and by platform.hanzo.ai.
|
||||
# Canonical CI config for hanzo-docs/docs — read by hanzoai/ci (the GitHub lane,
|
||||
# .github/workflows/cicd.yml) and by platform.hanzo.ai (the webhook lane).
|
||||
#
|
||||
# The repo builds ONE image: the Fumadocs monorepo exported static and served by
|
||||
# hanzoai/static. The recipe lives in the Dockerfile and nowhere else — APP
|
||||
# selects the workspace, so every static site here is built the same way. This
|
||||
# file only declares WHAT is built; it does not restate the recipe.
|
||||
#
|
||||
# The image tag hanzoai/universe pins (charts/app/values/hanzo/docs.yaml) is the
|
||||
# FULL commit sha, which is what .hanzo/workflows/deploy.yml pushes. A builder
|
||||
# that tags differently (hanzoai/ci publishes `sha-<short>` and `latest`) needs
|
||||
# that values file pointed at its scheme in the same change — the two are one
|
||||
# contract, and moving one alone leaves docs.hanzo.ai on whatever it was
|
||||
# already serving.
|
||||
# THE TAG CONTRACT. hanzoai/universe pins this image in
|
||||
# charts/app/values/hanzo/docs.yaml, and the four builders that can produce it do
|
||||
# not agree on what to call it:
|
||||
#
|
||||
# .hanzo/workflows/deploy.yml <sha40> ← what universe pins
|
||||
# POST /v1/runner whatever the caller names (convention: <sha40>)
|
||||
# platform webhook, this file <sha40>-amd64-docs ({{git.sha}}-amd64-<tag-suffix>)
|
||||
# hanzoai/ci, this file sha-<sha7>-amd64 + latest
|
||||
#
|
||||
# Whichever one publishes, the pin has to be told its name — and the line that
|
||||
# actually decides what runs is `digest:`, not `tag:`, because the chart renders
|
||||
# repo:tag@digest and a digest beside a tag is what gets pulled. RELEASE.md
|
||||
# writes out the exact edit for each of the four.
|
||||
images:
|
||||
- name: docs
|
||||
context: .
|
||||
@@ -28,10 +35,27 @@ test:
|
||||
# does not exist.
|
||||
run: pnpm install --frozen-lockfile && pnpm test
|
||||
|
||||
# NOTE — the export gate is NOT here, deliberately. A static-export site fails
|
||||
# by exporting nothing, which no image build can notice: the layer is valid and
|
||||
# the site is empty. .hanzo/workflows/deploy.yml therefore gates BETWEEN build
|
||||
# and push — it creates the container, copies /public out, and requires
|
||||
# index.html, docs/index.html, a non-empty nav and >=50 pages before the image
|
||||
# may reach the registry. Any builder that publishes this repo must run that
|
||||
# check, or it will happily ship a 404.
|
||||
# NOTE — the export gate is not declared here, and not because it was forgotten.
|
||||
#
|
||||
# A static-export site fails by exporting NOTHING, which no image build can
|
||||
# notice: the layer is valid and the site is empty. So the check has to happen
|
||||
# somewhere every builder passes through, and `test:` is not it — hanzoai/ci
|
||||
# runs `test:` AFTER `docker buildx --push`, and buildx builds and pushes in one
|
||||
# invocation with no step in between to hold. A gate declared here would report a
|
||||
# broken export that was already in the registry.
|
||||
#
|
||||
# The gate is therefore inside the Dockerfile (scripts/check-export.sh, plus
|
||||
# apps/docs/export.require naming the sections that must not vanish). A failing
|
||||
# export fails the build, so no builder reaches a push — including this one.
|
||||
#
|
||||
# That placement also covers the one thing the GitHub lane gets wrong on its own:
|
||||
# hanzoai/ci checks out without `submodules: recursive`, and apps/docs/content/
|
||||
# docs/studio is a submodule (hanzo-docs/studio-docs). Without it that section
|
||||
# simply does not exist in the output while page count and nav stay green. It is
|
||||
# named in export.require, so the build fails loudly instead of shipping a hole.
|
||||
|
||||
# No `deploy:` — ON PURPOSE, and this is not the usual "a human should review it".
|
||||
# docs.hanzo.ai is delivered by the hanzo/app chart with `cd.automated: true`, so
|
||||
# Argo is the sole writer of those objects and self-heals anything else that
|
||||
# touches them. A `deploy:` block here would have the platform patch a workload
|
||||
# that gets reverted on the next sync. The rollout IS the commit to universe.
|
||||
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/bin/sh
|
||||
# Is this static export a site?
|
||||
#
|
||||
# A static-export build fails by exporting NOTHING. The layer is valid, the image
|
||||
# is valid, the push succeeds, and the host answers 404 — no builder notices,
|
||||
# because from a builder's point of view nothing went wrong. So the question has
|
||||
# to be asked explicitly, and it has to be asked by every builder, which means it
|
||||
# is asked HERE and nowhere else.
|
||||
#
|
||||
# Called from:
|
||||
# Dockerfile build stage, before the export is copied into the
|
||||
# serving image. This is the universal call site:
|
||||
# every builder runs the Dockerfile, so a failure
|
||||
# here aborts the build BEFORE any push, whether
|
||||
# that push came from `docker buildx --push`, an
|
||||
# in-cluster BuildKit job, or a plain docker build.
|
||||
# .hanzo/workflows/deploy.yml on /public copied out of the FINISHED image, so
|
||||
# the forge lane also proves the copy landed.
|
||||
#
|
||||
# Usage: check-export.sh <export-dir> [require-file]
|
||||
#
|
||||
# <require-file> lists extra pages this particular app must have exported, one
|
||||
# path per line, relative to <export-dir>; blank lines and # comments ignored.
|
||||
# Default: <export-dir>/../export.require, which is how `apps/<app>/out` finds
|
||||
# `apps/<app>/export.require` with nothing passed. An app that declares no file
|
||||
# is checked against the universal rules alone.
|
||||
set -eu
|
||||
|
||||
dir="${1:?usage: check-export.sh <export-dir> [require-file]}"
|
||||
req="${2:-$(dirname "$dir")/export.require}"
|
||||
fail=0
|
||||
|
||||
say() { echo "export gate: $*" >&2; }
|
||||
|
||||
[ -d "$dir" ] || { say "FAIL no such export directory: $dir"; exit 1; }
|
||||
|
||||
# 1-2. The two pages every site in this monorepo has: the landing page and the
|
||||
# root of the docs tree. `trailingSlash: true` means /docs is docs/index.html —
|
||||
# a docs.html would be a 404 from the static server.
|
||||
for f in index.html docs/index.html; do
|
||||
[ -s "$dir/$f" ] || { say "FAIL missing or empty $f"; fail=1; }
|
||||
done
|
||||
|
||||
# 3. A shell that renders but generates no pages still produces both files above.
|
||||
# The page count is what separates a site from a shell.
|
||||
n=$(find "$dir" -name '*.html' | wc -l | tr -d ' ')
|
||||
say "html pages exported: $n"
|
||||
[ "$n" -ge 50 ] || { say "FAIL only $n html pages — the export looks broken"; fail=1; }
|
||||
|
||||
# 4. A page whose nav did not render is a page nothing else is reachable from.
|
||||
grep -q 'href="/docs/' "$dir/docs/index.html" 2>/dev/null \
|
||||
|| { say "FAIL /docs carries no nav — shell-only render"; fail=1; }
|
||||
|
||||
# 5. Whole sections vanish silently: a submodule that was not checked out drops
|
||||
# its pages and every other page still builds. Each app names the sections it
|
||||
# must not lose, so losing one is a failed build rather than a quiet hole.
|
||||
if [ -f "$req" ]; then
|
||||
say "required pages from $req"
|
||||
while IFS= read -r line; do
|
||||
case "$line" in ''|\#*) continue ;; esac
|
||||
[ -s "$dir/$line" ] || { say "FAIL missing or empty $line"; fail=1; }
|
||||
done < "$req"
|
||||
fi
|
||||
|
||||
[ "$fail" -eq 0 ] || { say "REFUSING this export"; exit 1; }
|
||||
say "ok"
|
||||
Reference in New Issue
Block a user