Skip to content

docsy(v2): document tracked local runs (--tracked / --tracked-strict)… #862

docsy(v2): document tracked local runs (--tracked / --tracked-strict)…

docsy(v2): document tracked local runs (--tracked / --tracked-strict)… #862

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. 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 (so the deploy
# here fails on fork PRs). See DOC-1228.
#
# Triggers:
# - push to main — production deploy (project=docs, --branch=main).
# This is the prod path; CF auto-deploys on the `docs` project are
# disabled, so GHA owns prod end-to-end.
# - workflow_dispatch — manual run; redeploys whatever commit the branch
# is at to the docs project (useful for re-runs after a flake).
on:
push:
branches:
- main
workflow_dispatch:
# contents: write — the one-merge cut (DOC-1245) materializes + pushes the stable
# tag named by versions.toml as a pre-build step (see "Materialize stable tag").
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:
# - production deploys use one `prod` group PER LINE, queued in order (never
# cancel), so two merges on the same line can't race on the CF Pages
# project — and, crucially, 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.
#
# `github.ref_name` is what makes the group per-line, and it is not cosmetic.
# This policy was written when main was the only branch that deployed, so one
# global `prod` group was the same thing as one group per line. Go-live
# (DOC-1245) made `v1` a deploying line too and nothing revisited the group,
# which left main and v1 sharing one queue.
#
# The failure that produces is silent, and it follows 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 is discarded the moment any second
# main deploy arrives, and it reports `cancelled`, not `failure`: nothing turns
# red and nobody is told. Observed 2026-08-18, where
# v1's deploy queued at 10:20:02 and was evicted at 10:20:17 by a main deploy
# created one second earlier; the v1 line silently did not ship.
#
# Same-line eviction stays, deliberately. Two pushes to the same branch are
# ordered, so the newer commit already contains the older one and superseding
# is correct. Only CROSS-line eviction is wrong, and that is exactly what
# keying on the ref removes.
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 checks out cut tags + main 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): versions.toml names the stable tag /docs/v2 serves.
# If that tag doesn't exist yet, THIS merge is the promotion — materialize it
# (guarded: PyPI-published SDK + versions.toml agrees with the resolver) BEFORE
# the build, so /docs/v2 assembles from it in the same job (no cut↔deploy race).
# Idempotent: an already-cut stable is a no-op. Inert until versioning go-live
# (no versions.toml → skipped), matching the Build-dist feature gate below.
- name: Materialize stable tag if needed (cut)
if: github.event_name == 'push'
env:
# cut-docs-version.sh reads flyteorg/flyte releases for the backend row.
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 (multi-version assembly when
# present — /docs/latest, /docs/v2, pins — else single-version).
# build-pr.yml calls the SAME script with an explicit mode, so the two
# build paths cannot silently drift again (that drift is how previews
# spent months building a different artifact class than production).
LATEST_REF=origin/main 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: 202 of them pointed at nothing
# while that job stayed green (DOC-1499, DOC-1511, infra#291).
# 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. unionai-docs#1529 shipped an image that 404s on
# its own preview while all 15 checks passed (DOC-1515, infra#295).
# Same reasoning as the link check above: it needs the dist this job
# already built.
make check-rendered-images
- name: Emit build provenance
if: success()
run: |
# Write build-info.json at dist/docs/ so it's served at
# docs.union.ai/docs/build-info.json (CloudFront routes /docs/*
# to the CF Pages origin). Lets us verify GHA is what's
# actually serving prod after Phase 4 cutover, and gives
# incident response a quick "what version is live?" probe.
mkdir -p dist/docs
cat > dist/docs/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/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.
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 (main: v2 + latest;
# v1 branch: v1), so the two lines 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