-
Notifications
You must be signed in to change notification settings - Fork 0
236 lines (222 loc) · 12.1 KB
/
Copy pathcd.yml
File metadata and controls
236 lines (222 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
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' }}
# The fleet probe convention (1.6.44 item 4). Every curl in this workflow
# sends it. Before this, these curls sent NO User-Agent at all and arrived
# as `curl/8.x` — which dash-improve-my-llms classifies crawler-lane, and
# which carries no internal token, so every CD verification wrote rows into
# the production ledger it was verifying. The token is what the far side's
# tracker drops at write time; the `curl/8` prefix keeps the probe in the
# same lane a real curl would be in, so the check exercises the document it
# means to. Keep the token byte-identical to `PROBE_UA_SUFFIX` in
# lib/constants.py — tests/test_probe_ua.py asserts these agree, because a
# constant recorded in two places drifts and the copy nothing imports is the
# one that drifts.
PROBE_UA: "curl/8 2plot-internal/probe cd-verify"
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 -A "$PROBE_UA" "$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"
# 1.6.44 item 19, and it is load-bearing rather than housekeeping. The
# `ai_bot_posture` row generates THIS APP's robots.txt in process — via
# the package's own `generate_robots_txt` and the app's registered
# `RobotsConfig` — and diffs it against what the world is served. With
# nothing installed, that import raises, the row SKIPS, and the battery
# reports green having compared nothing: the exact defect note 88 is
# about, in the one job where a real proxy sits in the path. CI's
# battery runs against a container with no edge in front of it and can
# never see an injected block, so this job is the only place the check
# means anything.
- name: Install the app (the battery generates robots.txt in-process)
run: |
pip install -r requirements.txt
pip install --no-deps markdown2dash==0.1.2
# PIN dash-improve-my-llms TO THE VERSION THE WIRE IS SERVING.
# requirements.txt states a `>=` FLOOR, so a fresh install here
# resolves the newest release while the deployed IMAGE runs whatever
# was newest when it was BUILT. `ai_bot_posture` generates this app's
# robots.txt in process and diffs it against the served one, so two
# different package versions make that a comparison between a
# PACKAGE FEATURE and an edge rewrite — indistinguishable, and the
# canary cries wolf. Measured on CD run 34013995781: verify resolved
# 2.10.0, production served 2.9.4, and 2.10.0 emits a
# `Content-Signal:` directive 2.9.4 never wrote -> a RED verify on a
# healthy site. The battery SKIPS on a mismatch rather than failing;
# this step is what stops it skipping forever (item 19's rider).
served="$(curl -fsS -A "$PROBE_UA" "$SITE_URL/healthz" \
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("llms_version") or "")')"
if [ -n "$served" ]; then
echo "::notice::pinning dash-improve-my-llms==$served to match the wire"
pip install "dash-improve-my-llms==$served"
else
echo "::warning::/healthz reports no llms_version — ai_bot_posture will skip"
fi
- 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 -A "$PROBE_UA" "$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."