nav: one registry drives the sidebar; the battery names its lane (ite… #16
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: CD | |
| # Deploys llms.2plot.dev, then checks the live site. | |
| # | |
| # ======================= THE LIVE HALF IS DORMANT ========================= | |
| # Everything that touches a running host — the build-match wait and the whole | |
| # `verify` job — is gated on the repository variable `SITE_URL`. While it is | |
| # unset those steps skip with a notice and CD is effectively "CI plus an | |
| # optional deploy trigger". | |
| # | |
| # This is not caution for its own sake. The template shipped | |
| # SITE_URL=https://boilerplate.2plot.dev hard-coded, so an unguarded run here | |
| # would poll ANOTHER SITE's /healthz waiting for a commit it will never | |
| # serve, then battery it and report red — a fork failing CD on day one over a | |
| # host it does not own. (Same class as the excalidraw finding: inherited | |
| # workflows reference things that do not exist in the fork and deploy hosts | |
| # that are not yours.) The guard is a recorded divergence — see | |
| # DIVERGENCES.md — and it stays. | |
| # | |
| # STATUS 2026-08-26: the cutover HAPPENED. https://llms.2plot.dev serves this | |
| # app (healthz `app: "llms"`, build == HEAD, verified by hand every round), | |
| # so the remaining condition is met and the variable is simply not set yet. | |
| # | |
| # TO UNGUARD — one repository setting, no workflow edit: | |
| # Settings → Secrets and variables → Actions → Variables → | |
| # SITE_URL = https://llms.2plot.dev | |
| # The build-match wait and the whole verify job come alive on the next push, | |
| # and hand-verification stops being the only proof this repo has. | |
| # ========================================================================== | |
| # | |
| # HOW THIS REPO DEPLOYS (1.6.35): Render watches the `release` branch, and | |
| # `release` is written by exactly one thing — the `deploy` job below, which | |
| # fast-forwards it to the run's own sha after `needs: [test]` is green. A | |
| # push to main is therefore NOT a deploy; it is a candidate. The old shape | |
| # POSTed to a Render deploy hook, which shipped whatever main happened to | |
| # hold whether or not the matrix agreed. Nothing here holds a deploy secret | |
| # any more: the mechanism is a ref and the platform's own autoDeploy. | |
| # | |
| # `build == HEAD` in the traps now means HEAD of `release`. `main` ahead of | |
| # `release` is an uncertified push pending, never drift and never a hand | |
| # deploy. | |
| on: | |
| push: | |
| branches: [main] | |
| workflow_dispatch: | |
| inputs: | |
| target_url: | |
| description: Site to verify (skips the deploy when set to another host) | |
| required: false | |
| type: string | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: cd-production | |
| cancel-in-progress: false | |
| env: | |
| PIP_DISABLE_PIP_VERSION_CHECK: "1" | |
| # No hard-coded fallback, deliberately: an empty SITE_URL is the signal | |
| # that no service exists yet, and every live step keys off it. A default | |
| # here would silently re-point this workflow at somebody else's host. | |
| SITE_URL: ${{ inputs.target_url || vars.SITE_URL }} | |
| jobs: | |
| test: | |
| name: ci | |
| uses: ./.github/workflows/ci.yml | |
| deploy: | |
| name: deploy to render | |
| needs: [test] | |
| runs-on: ubuntu-latest | |
| # Long enough for the build-match wait below (up to 100 × 15s = 25 | |
| # minutes) and no longer. Without it the job inherits GitHub's six-hour | |
| # default, which is how a platform that never comes back healthy holds | |
| # the `cd-production` concurrency group all day. Sized for the WORST | |
| # build, not the median: a floor bump busts the Docker dependency cache | |
| # by design, so this pipeline's most important deploy is also Render's | |
| # slowest — dash-email's wait timed out on exactly that build class | |
| # (2026-08-23) with the old 60 × 15s window. | |
| timeout-minutes: 30 | |
| environment: | |
| name: production | |
| # Restore `url: ${{ inputs.target_url || vars.SITE_URL }}` when | |
| # SITE_URL is set. Omitted while dormant rather than evaluated to an | |
| # empty string — the deployment panel simply shows no link rather than | |
| # this repo's runs hinging on how GitHub treats a blank environment | |
| # URL. | |
| # `contents: write` on THIS job only — it is the one thing in the | |
| # workflow that writes to the repository (the `release` ref). The | |
| # workflow-level grant above stays `read`. | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| # FULL history, not the default depth-1 shallow clone. A shallow | |
| # HEAD pushed onto an EXISTING `release` is rejected as | |
| # non-fast-forward ("fetch first") because the clone cannot show | |
| # the remote that release's tip is an ancestor. The template's | |
| # FIRST promote (ea4e104, 2026-08-29) succeeded only because | |
| # `release` did not exist yet; its second (747d8b3) failed in one | |
| # second. tests/test_cd_promotes_release.py pins the depth. | |
| fetch-depth: 0 | |
| - name: Say plainly that the live half is dormant | |
| if: env.SITE_URL == '' | |
| run: | | |
| echo "::warning::The SITE_URL repo variable is unset, so the build-match wait and the live batteries are SKIPPED — this run certifies nothing about production. The site is live at https://llms.2plot.dev; set SITE_URL to it (Settings → Variables) to bring the live half up. See this workflow's header." | |
| - name: Promote to release | |
| # Only a push to main promotes. A workflow_dispatch that names | |
| # another host (target_url) is a verify-only run against that host | |
| # and must not move this repo's release ref. The hook step this | |
| # replaced had no such guard. | |
| if: github.event_name == 'push' || inputs.target_url == '' | |
| run: | | |
| # Fast-forward push of the run's own sha. `needs: [test]` is the | |
| # gate: a red matrix never reaches this step, so `release` cannot | |
| # receive an uncertified commit by construction. On the FIRST run | |
| # `release` does not exist yet and this push creates it. | |
| # | |
| # NOT --force, on purpose: a non-fast-forward means somebody wrote | |
| # `release` by hand (a rollback, a hotfix). This job then FAILS | |
| # and says so; a force here would silently undo that person. | |
| # | |
| # GITHUB_TOKEN pushes trigger no workflows — correct: `on:` is | |
| # main-only, and it is Render, not Actions, that reacts to | |
| # `release` (render.yaml `branch: release`, autoDeploy on). | |
| git push origin "HEAD:refs/heads/release" | |
| echo "::notice::release → ${GITHUB_SHA} (fast-forward)" | |
| - name: Wait for THIS build to serve traffic | |
| # Skips while SITE_URL is unset — see the header. Without this guard | |
| # the loop below polls a host this repo does not own for twenty-five | |
| # minutes and then fails the run. | |
| if: env.SITE_URL != '' | |
| # Render deploys `release`, which the step above just set to this | |
| # run's sha, so the wait is unchanged: hold until /healthz reports | |
| # THIS build. The old shape skipped this wait and ran the battery | |
| # three seconds later against the PREVIOUS release — invisible for | |
| # the workflow's whole life (the old build always already passed | |
| # the old battery) until a new surface made the race lose; found on | |
| # muicharts, 2026-08-21, fleet-wide class. | |
| run: | | |
| # A bare 200 proves nothing about WHICH build answered: /healthz | |
| # now reports the running instance's commit (RENDER_GIT_COMMIT), | |
| # and this loop holds until it equals the SHA that triggered this | |
| # run. Fallback: a build that predates the field gets the old | |
| # sustained-health wait, with a warning naming what it can't tell | |
| # — that fallback fires exactly once, on the deploy carrying the | |
| # field for the first time. | |
| want="${GITHUB_SHA}" | |
| matched=0 | |
| ok=0 | |
| for _ in $(seq 1 100); do | |
| body="$(curl -fsS "$SITE_URL/healthz" 2>/dev/null || true)" | |
| if [ -n "$body" ]; then | |
| build="$(printf '%s' "$body" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("build",""))' 2>/dev/null || true)" | |
| if [ "$build" = "$want" ]; then | |
| matched=$((matched + 1)) | |
| [ "$matched" -ge 3 ] && break | |
| elif [ -z "$build" ]; then | |
| ok=$((ok + 1)) | |
| fi | |
| fi | |
| sleep 15 | |
| done | |
| if [ "$matched" -ge 3 ]; then | |
| echo "::notice::live /healthz reports build $want — verifying the artifact this run shipped." | |
| elif [ "$ok" -ge 5 ]; then | |
| echo "::warning::live /healthz predates the build field — cannot prove WHICH build is serving; verified sustained health only. This warning should appear exactly once." | |
| else | |
| echo "::error::$SITE_URL never served this run's build ($want) and never became reliably healthy" | |
| exit 1 | |
| fi | |
| verify: | |
| name: verify the live site | |
| needs: [deploy] | |
| # ONLY after a SUCCESSFUL deploy (1.6.35 fix-forward, ops finding on the | |
| # template's run 33262495272): the old `!= 'cancelled' && != 'skipped'` | |
| # admitted 'failure', so when the promote step failed this job ran | |
| # anyway and reported GREEN — it had smoke-tested the PREVIOUS build. A | |
| # verify that passes when nothing deployed must not exist. (muicharts' | |
| # 2026-08-23 `!= 'skipped'` half is subsumed: skipped is not success.) | |
| # | |
| # The SITE_URL clause is this fork's own (DIVERGENCES.md 4) and STAYS: | |
| # the whole job is dormant until the variable names a real service, and | |
| # `vars.SITE_URL` rather than `env.SITE_URL` because a job-level `if` is | |
| # evaluated before the job's env context exists. | |
| if: >- | |
| needs.deploy.result == 'success' | |
| && (inputs.target_url || vars.SITE_URL) != '' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - uses: actions/setup-python@v7 | |
| with: | |
| # The fleet Python — tests/test_python_version.py pins this literal | |
| # against the Dockerfile's FROM tag. | |
| python-version: "3.14" | |
| - name: The live build IS this run's sha | |
| # Belt to the `if:` above's braces, and stronger: it also catches a | |
| # promote that succeeded while Render's build did not, or a later | |
| # run that promoted past us between deploy and verify. Skipped on a | |
| # workflow_dispatch against another host (its shas are not ours). | |
| if: github.event_name == 'push' || inputs.target_url == '' | |
| run: | | |
| build="$(curl -fsS "$SITE_URL/healthz" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("build",""))')" | |
| if [ "$build" != "$GITHUB_SHA" ]; then | |
| echo "::error::verify refuses: $SITE_URL serves build '${build:-<none>}', this run is $GITHUB_SHA — nothing this run shipped is on the wire to verify" | |
| exit 1 | |
| fi | |
| echo "::notice::verifying build $build (this run's sha)" | |
| # The network battery first: it is the same script, with the same check | |
| # names, that CI ran against the container this deploy shipped. A name | |
| # that passed in CI and fails here isolates the fault to the deploy. | |
| - name: Network smoke battery | |
| run: python scripts/network_smoke.py --base-url "$SITE_URL" | |
| # Then the satellite-specific checks the battery does not make: every | |
| # canonical, every crawler body, and every peer llms.txt in the | |
| # directory actually resolving. | |
| - name: Smoke-test the deployment | |
| run: python scripts/smoke_live.py "$SITE_URL" | |
| - name: Report | |
| if: failure() | |
| run: | | |
| echo "::error::Live verification failed for $SITE_URL. Every failure these check for is silent in production: a site identity that fell back to a framework default, a stale dash-improve-my-llms artifact, a canonical on the wrong host, a page serving the JavaScript stub, a missing network directory, and dead peer llms.txt links." |