0ag: keep info_panel, publish the helper sources once #33
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 leaflet.2plot.dev, then checks the live site. | |
| # | |
| # THE ROAD (template 1.6.35, owner decision A of 2026-08-29): Render | |
| # auto-deploys the `release` branch, and ONLY this workflow writes `release` — | |
| # a fast-forward push of the run's own sha, after the CI matrix is green. A | |
| # push to `main` is therefore not a deploy; it is a candidate. The measured | |
| # reason, on the template: a commit pushed to main was built by Render inside | |
| # the minute, its CD run went red a minute later with the deploy job skipped, | |
| # and /healthz served the red build for ~6 minutes. CI cannot stop a deploy | |
| # while the platform watches the branch CI is still judging. | |
| # | |
| # The Render deploy-hook step is GONE from this file; the repository secret it | |
| # read (the `…_DEPLOY_HOOK_URL` one) is inert and safe to delete — its name is | |
| # deliberately not spelled here, because sync item 13's detect greps this file | |
| # for it. `main` ahead of `release` means an uncertified push is pending (its | |
| # run red or still running) — never "drift", never a reason to deploy by hand. | |
| 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" | |
| SITE_URL: ${{ inputs.target_url || 'https://leaflet.2plot.dev' }} | |
| 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 60 × 15s) 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. | |
| timeout-minutes: 20 | |
| environment: | |
| name: production | |
| url: https://leaflet.2plot.dev | |
| # `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@v4 | |
| 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. Measured on the template's SECOND | |
| # promote, which failed in one second; the first passed only because | |
| # `release` did not exist yet — so a fork does not see this until its | |
| # second push. tests/test_cd_promotes_release.py pins it. | |
| fetch-depth: 0 | |
| - 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; the wait below never did either, and still spins against | |
| # whatever SITE_URL says.) | |
| 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 | |
| # Render deploys `release`, which the step above just set to this run's | |
| # sha, so the wait itself is unchanged: hold until /healthz reports | |
| # THIS build. The shape it replaced ran the battery seconds later | |
| # against the PREVIOUS release whenever the deploy trigger was skipped | |
| # — invisible for as long as the old build kept passing the old | |
| # battery, which is until the first run where a new surface makes the | |
| # race lose (found on muicharts, 2026-08-21, fleet-wide class). | |
| run: | | |
| # A bare 200 proves nothing about WHICH build answered: Render | |
| # swaps instances rather than restarting in place, so the old build | |
| # serves /healthz right up to the swap. /healthz now reports the | |
| # running instance's commit (RENDER_GIT_COMMIT, live here since | |
| # 3ebd507), 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 cannot tell. | |
| # On this repo that fallback should never fire — the field shipped | |
| # before this step did — so if it appears, the image is stale and | |
| # the dependency layer was cached. | |
| want="${GITHUB_SHA}" | |
| matched=0 | |
| ok=0 | |
| for _ in $(seq 1 60); 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): the old | |
| # `always() && != 'cancelled'` 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. ('skipped' is not success either, so muicharts' guard is subsumed.) | |
| if: needs.deploy.result == 'success' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-python@v5 | |
| with: | |
| 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. Peer failures warn; this host's fail. | |
| - 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." |