Skip to content

docsy(v1): bump infra for the setup-doc refresh (#1566) #854

docsy(v1): bump infra for the setup-doc refresh (#1566)

docsy(v1): bump infra for the setup-doc refresh (#1566) #854

name: Build and deploy docs
# Part of the gha-docs-build migration: builds the docs via `make dist`
# and pushes the result to a Cloudflare Pages project via Direct Upload.
# Replaces CF Pages' native build runner — CF Pages stays only as the
# static host.
#
# Production-only path (mirrors main). PR previews are handled by the two-stage
# pair `build-pr.yml` (untrusted build, no secrets) + `deploy-pr-preview.yml`
# (trusted workflow_run deploy, has secrets), because GitHub does NOT expose repo
# secrets to pull_request workflows triggered from forks. See DOC-1228.
#
# Triggers:
# - push to v1 — production deploy (project=docs, --branch=v1). The prod path;
# CF auto-deploys are disabled, so GHA owns prod end-to-end.
# - workflow_dispatch — manual run; redeploys the branch's current commit.
on:
push:
branches:
- v1
workflow_dispatch:
# contents: write — the one-merge cut (DOC-1245) materializes + pushes the stable
# tag named by versions.toml as a pre-build step (mirrors main; inert without one).
permissions:
contents: write
pull-requests: write
# Opt in early to the Node.js 24 runtime for JavaScript actions. GitHub forces
# this default on 2026-06-02 and removes Node.js 20 on 2026-09-16. Setting the
# variable now silences the deprecation warning, tests compatibility with our
# pinned action versions (checkout@v4, setup-python@v5, upload-artifact@v4,
# setup-uv@v5, wrangler-action@v3, sticky-pull-request-comment@v2), and gives
# us a controlled window to bump if any break.
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
# Concurrency policy (mirrors main):
# - production deploys use one `prod` group PER LINE, queued in order (never
# cancel), so two merges on this line can't race on the CF Pages project,
# and can't race on cutting the same stable tag (the cut runs in-job, so
# serializing the job serializes the cut).
# - workflow_dispatch: own `manual` group, so manual re-runs never block or
# get blocked by an in-progress production deploy. The ref suffix applies to
# this arm too, so manual runs are per-line as well -- a manual v1 deploy
# cannot evict a manual main one, which is the same guarantee `prod` gets and
# the reason the suffix is unconditional rather than applied to `prod` only.
# This workflow never runs on `pull_request` (see Triggers above), so there is
# no PR arm here. PR previews live in build-pr.yml + deploy-pr-preview.yml.
#
# `github.ref_name` is what makes the group per-line. Without it this file and
# main's evaluate to the SAME group string -- both said "one prod group", each
# reading as though it owned its own queue, and together they formed one shared
# queue for two lines. "Mirrors main" was the trap, not the safeguard.
#
# The failure that produced is silent, and it is documented behaviour rather
# than a quirk: `cancel-in-progress: false` protects a RUNNING job, but per
# GitHub's concurrency docs, "any existing pending job or workflow in the same
# concurrency group will be canceled and the new queued job or workflow will
# take its place" -- newest wins, cancellation setting notwithstanding.
# https://docs.github.com/en/actions/using-jobs/using-concurrency
# So a v1 deploy queued behind a main deploy was discarded the moment a second
# main deploy arrived, reporting `cancelled` rather than `failure` -- nothing
# turned red and this line simply did not ship. Observed 2026-08-18.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && 'manual' || 'prod' }}-${{ github.ref_name }}
cancel-in-progress: false
jobs:
build-and-deploy:
name: "Build and deploy docs"
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
# 0 (was 1): the multi-version assembly + cut checkout tags + v1 into
# isolated worktrees, so it needs full history + all tags.
fetch-depth: 0
- name: Set up Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: '0.161.1'
extended: true
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Print environment
run: |
echo "::group::versions"
hugo version
python --version
uv --version
echo "::endgroup::"
# One-merge cut (DOC-1245), mirrored from main: versions.toml names the stable
# tag /docs/v1 serves. If it doesn't exist yet, THIS merge is the promotion —
# materialize it (guarded) BEFORE the build, so the v1 line assembles from it in
# the same job (no cut↔deploy race). Inert until versioning go-live (no
# versions.toml → skipped), so v1 stays a single static build until then.
- name: Materialize stable tag if needed (cut)
if: github.event_name == 'push'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO_ROOT: ${{ github.workspace }}
run: |
set -euo pipefail
if [ ! -f versions.toml ]; then
echo "no versions.toml → versioning off; skip cut"; exit 0
fi
STABLE=$(sed -n 's/^stable *= *"\(.*\)"/\1/p' versions.toml | head -1)
if [ -z "$STABLE" ]; then echo "versions.toml has no stable → skip"; exit 0; fi
if git rev-parse -q --verify "refs/tags/$STABLE" >/dev/null; then
echo "stable tag $STABLE already cut → no-op"; exit 0
fi
echo "stable $STABLE not yet cut → materializing at this merge"
bash unionai-docs-infra/scripts/cut-docs-version.sh --push
- name: Build dist
run: |
start=$(date +%s)
# One shared CI build step (DOC-1333): `auto` = the versions.toml gate,
# identical to the previous inline logic. build-pr.yml calls the SAME
# script with an explicit mode, so the two build paths cannot drift.
LATEST_REF=origin/v1 bash unionai-docs-infra/scripts/ci-build-dist.sh auto
echo "BUILD_SECONDS=$(( $(date +%s) - start ))" >> "$GITHUB_ENV"
- name: Check generated links
run: |
# The links the GENERATOR writes into dist/. `Check Internal Links`
# reads content/ and cannot see these: v1 had 1,649 of them pointing at
# nothing while that job stayed green (DOC-1525).
# Runs here rather than in its own workflow because it needs the dist
# this job already built, and a second full Hugo build to check links
# is not worth the minutes.
make check-generated-links
- name: Check rendered images
run: |
# The images the BUILT site serves, resolved the way a browser does --
# against the page's URL, not the source file's directory. `Check
# Images` reads content/ and cannot see this: Hugo's render hooks
# rebase an image src, so a path can be correct in the markdown and
# wrong in the HTML. Two v1 tutorials shipped images that 404 on the
# live site while every check passed (DOC-1525, unionai-examples#307).
make check-rendered-images
- name: Emit build provenance
if: success()
run: |
# Write build-info.json at dist/docs/v1/ so it's served at
# www.union.ai/docs/v1/build-info.json (CloudFront routes
# /docs/v1/* to v1.docs-dog.pages.dev). On main the equivalent
# path is dist/docs/build-info.json since CloudFront routes
# /docs/* (precedence 3) to the main docs-dog.pages.dev origin.
mkdir -p dist/docs/v1
cat > dist/docs/v1/build-info.json <<EOF
{
"builder": "github-actions",
"repository": "${{ github.repository }}",
"workflow_file": ".github/workflows/build-and-deploy.yml",
"run_id": "${{ github.run_id }}",
"run_url": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}",
"commit": "${{ github.sha }}",
"ref": "${{ github.ref_name }}",
"event": "${{ github.event_name }}",
"built_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF
cat dist/docs/v1/build-info.json
- name: Build summary
if: always()
run: |
echo "::group::dist tree (top 2 levels)"
find dist -maxdepth 2 -mindepth 1 -print 2>/dev/null | sort || true
echo "::endgroup::"
echo "::group::dist size"
du -sh dist 2>/dev/null || true
du -sh dist/docs/v2 dist/docs/v1 2>/dev/null || true
echo "::endgroup::"
if [ -n "${BUILD_SECONDS:-}" ]; then
echo "Build wall time: ${BUILD_SECONDS}s"
fi
- name: Upload dist artifact
if: success()
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 7
if-no-files-found: error
- name: Sanitize branch name for CF Pages
if: success()
id: branch
run: |
# CF Pages branch names: lowercase alphanumeric + hyphens, max 28 chars.
# Production-only workflow, so `github.ref_name` is always `v1`
# (push) or the dispatched ref. The `pr-<num>-` aliasing lives in
# deploy-pr-preview.yml, which is what handles PR events.
raw="${{ github.ref_name }}"
sanitized=$(echo "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's#[^a-z0-9]+#-#g' | sed -E 's#^-+|-+$##g' | cut -c1-28)
echo "Source branch: $raw → CF Pages branch: $sanitized"
echo "name=$sanitized" >> "$GITHUB_OUTPUT"
- name: Deploy to Cloudflare Pages
if: success()
id: deploy
# Step-level cap: a normal CF Pages deploy is ~1-3 min. Without this a
# hung wrangler upload eats the whole job's timeout-minutes budget
# (incl. the build) before failing — observed once at ~26 min on a large
# API-docs regen. Fail fast so the deploy can be re-run. See DOC-1229.
timeout-minutes: 10
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_PAGES_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
# --commit-dirty: make dist regenerates tracked files (notebooks,
# API docs), so the working tree is always dirty by the time we hit
# wrangler. The warning is noise, not a real condition.
# We don't pass --commit-hash because we use fetch-depth: 1 above
# (shallow checkout), which means the local repo can't resolve the
# SHA wrangler tries to look up and would emit "fatal: bad object".
# wrangler-action records the commit metadata on its own via the
# GITHUB_SHA env var.
command: pages deploy ./dist --project-name=docs --branch=${{ steps.branch.outputs.name }} --commit-dirty=true
- name: Update search index
# After the deploy, not before: the index should describe what is now
# served. Scoped to the slices THIS branch built -- v1 only -- so main
# (v2 + latest) and this branch cannot clobber each other's records.
#
# Skips itself when the secret is absent, so forks and any repo without
# Algolia credentials still deploy normally.
if: success() && github.event_name == 'push'
timeout-minutes: 15
env:
ALGOLIA_DOCS_2_APPLICATION_ID: ${{ secrets.ALGOLIA_DOCS_2_APPLICATION_ID }}
ALGOLIA_DOCS_2_WRITE_API_KEY: ${{ secrets.ALGOLIA_DOCS_2_WRITE_API_KEY }}
run: make index-search
- name: Deploy summary
if: success()
run: |
echo "## Deployment" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "- Project: \`docs\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Branch (CF Pages): \`${{ steps.branch.outputs.name }}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Commit: \`${{ github.sha }}\`" >> "$GITHUB_STEP_SUMMARY"
if [ -n "${{ steps.deploy.outputs.deployment-url }}" ]; then
echo "- Deployment URL: ${{ steps.deploy.outputs.deployment-url }}" >> "$GITHUB_STEP_SUMMARY"
fi