mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-24 20:09:02 +00:00
content/docs/projects/ was gitignored and held thousands of tracked files at the same time, and every build re-mirrored it from hundreds of upstream checkouts. Only the shipping image opted out, so the image compiled the committed snapshot while a person's build compiled a fresh mirror — one local build deleted 521 committed files and modified 444 more. One mirrored upstream imports a partial mid-page, which no alias resolves, and the build failed with 54 unresolved specifiers after silently skipping 119 further pages. The site had not built in a while. The snapshot is the source: the ignore line is gone, the mirror leaves the build and becomes a refresh run on purpose and read as a diff, and the env knobs that let one command mean two different sites are deleted. The section is named in the export gate, so losing it fails rather than shipping a hole. The generator's YAML escape emitted a backslash before a colon, which is not an escape — the parser rejected the whole frontmatter block and those pages shipped with no title, icon or description.
129 lines
6.9 KiB
Docker
129 lines
6.9 KiB
Docker
# The ONE Fumadocs build for this monorepo, exported static and served by
|
|
# hanzoai/static (the house static server; no nginx anywhere in the stack).
|
|
#
|
|
# APP selects which workspace to export, so every static site here is built the
|
|
# same way and the recipe exists once:
|
|
#
|
|
# docker build . # docs.hanzo.ai
|
|
# docker build --build-arg APP=gui-docs . # any sibling
|
|
#
|
|
# The siblings still reach Cloudflare Pages through their own workflows in
|
|
# .hanzo/workflows/. This is the path they move onto, one host at a time, as each
|
|
# gets a verified App CR in hanzoai/universe (see LLM.md).
|
|
#
|
|
# Built by the platform-native fabric (POST /v1/runner → buildkit Job with this
|
|
# repo as the git context) and deployed as the docs-landing Service CR at
|
|
# docs.hanzo.ai. Mirrors the pnpm recipe the retired GitHub workflow ran:
|
|
# NEXT_EXPORT=1 (output:'export'). Ported project docs and the openapi specs are
|
|
# committed snapshots that the build reads and never refetches, here or anywhere
|
|
# — this stage used to say so with HANZO_DOCS_SYNC=0, which meant the image and
|
|
# a person's `pnpm --filter docs build` compiled different sites.
|
|
# build:pre runs `bun ./scripts/*.ts` (gen-services-nav, pre-build). node:alpine has
|
|
# no bun, so the build stage produced an empty export → an empty /public → 404s.
|
|
# Bring the musl bun binary in from the official image.
|
|
FROM oven/bun:1-alpine AS bun
|
|
|
|
# Prune the workspace to what ${APP} actually needs, BEFORE anything is installed.
|
|
#
|
|
# This repo is 21 apps and 36 packages; apps/docs depends on 11 of them. Installing
|
|
# the whole workspace put node_modules for all 57 on the runner's 38Gi emptyDir,
|
|
# alongside .next and out, and the kubelet evicts the runner over that line — after
|
|
# a build that had already completed every one of its tasks.
|
|
#
|
|
# `turbo prune --docker` emits a workspace containing only ${APP} and its
|
|
# transitive internal deps. It is the same source and the same lockfile, so the
|
|
# image is unchanged; what changes is how much of the monorepo ever lands on disk.
|
|
FROM public.ecr.aws/docker/library/node:22-alpine AS pruner
|
|
RUN corepack enable && corepack prepare [email protected] --activate
|
|
WORKDIR /src
|
|
COPY . .
|
|
ARG APP=docs
|
|
RUN pnpm dlx [email protected] prune "${APP}" --docker
|
|
|
|
FROM public.ecr.aws/docker/library/node:22-alpine AS build
|
|
COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
|
|
RUN apk add --no-cache git libstdc++ libgcc && corepack enable && corepack prepare [email protected] --activate
|
|
WORKDIR /src
|
|
COPY --from=pruner /src/out/json/ ./
|
|
COPY --from=pruner /src/out/full/ ./
|
|
# turbo prune emits the workspace, not the repo: root-level scripts are not in it
|
|
# and the export gate below runs one. Copied explicitly so the gate still holds.
|
|
COPY --from=pruner /src/scripts/ ./scripts/
|
|
ENV NEXT_EXPORT=1 \
|
|
NEXT_TELEMETRY_DISABLED=1 \
|
|
NODE_OPTIONS=--max-old-space-size=24576
|
|
ARG APP=docs
|
|
# The ingest key, gated HERE for the same reason the export gate is here: this is
|
|
# the one thing every builder passes through. A guard in deploy.yml protects one
|
|
# of six lanes, and the lane that produced the last live image was not that one,
|
|
# so the guard never fired and the bundle shipped keyless.
|
|
#
|
|
# PUBLISHABLE_KEY is the name in KMS and on the --build-arg; NEXT_PUBLIC_ is
|
|
# added here because that prefix is what makes Next inline it.
|
|
#
|
|
# Fail closed: an empty key builds, serves and looks correct while cloud files
|
|
# every pageview under $public, which this org cannot read, and ingest answers
|
|
# 200 either way. Refuse the artifact instead.
|
|
ARG PUBLISHABLE_KEY=
|
|
ENV NEXT_PUBLIC_PUBLISHABLE_KEY=$PUBLISHABLE_KEY
|
|
RUN case "$PUBLISHABLE_KEY" in \
|
|
pk-*) : ;; \
|
|
'') echo "PUBLISHABLE_KEY is empty - pass --build-arg PUBLISHABLE_KEY=<pk-...> (KMS deploy/PUBLISHABLE_KEY, env prod)" >&2; exit 1 ;; \
|
|
*) echo "PUBLISHABLE_KEY is not a publishable key (expected a pk- prefix)" >&2; exit 1 ;; \
|
|
esac
|
|
# Install, build, and drop every intermediate IN ONE LAYER. Only `out/` survives.
|
|
#
|
|
# The single `&&` chain is the whole point, and it is a disk budget rather than
|
|
# tidiness. A RUN is a layer, and a layer stores what existed when it ended — so
|
|
# installing in one RUN and deleting in a later one frees NOTHING: node_modules
|
|
# is still in the earlier layer, on disk, for the rest of the build. Only files
|
|
# that never outlive their own layer cost nothing.
|
|
#
|
|
# What that saves here: node_modules for a 21-app / 36-package workspace, plus
|
|
# .next (~6.9G), plus the turbo cache (~2.4G), plus the pnpm store. The runner's
|
|
# docker storage is a 38Gi emptyDir and this build wanted more than all of it.
|
|
#
|
|
# Over that line the kubelet EVICTS the runner pod --
|
|
# Evicted: Usage of EmptyDir volume "docker-storage" exceeds the limit "38Gi"
|
|
# -- which looks nothing like a build failure. The pod restarts, so dockerd
|
|
# restarts, so the job log truncates with no error and the run sits in_progress.
|
|
# It reads exactly like an OOM and is not one; MemoryPressure stays False and
|
|
# node memory sits near 7%. Check `kubectl -n hanzo get events | grep -i evict`
|
|
# before believing anything the build log implies about why it stopped.
|
|
#
|
|
# Splitting this chain back into separate RUNs will reintroduce the eviction
|
|
# without changing a line of application code.
|
|
#
|
|
# Nothing downstream needs the toolchain: the export gate reads `out/` with sh,
|
|
# and the serving stage copies `out/` alone.
|
|
RUN pnpm install --frozen-lockfile \
|
|
&& pnpm build --filter="${APP}" \
|
|
&& rm -rf node_modules apps/*/node_modules packages/*/node_modules \
|
|
"apps/${APP}/.next" .turbo \
|
|
/root/.cache /root/.local/share/pnpm /root/.npm
|
|
|
|
# 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"
|
|
|
|
# v0.5.1 had no content-encoding path at all, so this site shipped every byte
|
|
# identity-encoded: 1,527,061 bytes of HTML on the index alone, a 454 KB
|
|
# sitemap and a 1 MB RSC prefetch, on every cold load, for a document that is
|
|
# ~14% entropy. hanzoai/static gained gzip in 900bccf and released it as
|
|
# v0.5.6. Measured by running the v0.5.6 binary over this site's own index:
|
|
# 1,527,061 -> 213,911 bytes with Vary: Accept-Encoding, the PNG untouched,
|
|
# and Accept-Encoding: gzip;q=0 correctly served identity.
|
|
FROM ghcr.io/hanzoai/static:v0.5.6
|
|
# Re-declared: an ARG is scoped to the stage that names it.
|
|
ARG APP=docs
|
|
COPY --from=build /src/apps/${APP}/out /public
|
|
EXPOSE 3000
|
|
ENTRYPOINT ["/static", "-port", "3000", "-root", "/public"]
|