docsy(v1): record retired pins, and deploy their redirects from that … #803
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: 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 |