From 4872149aa57627f517dbbc9cd0ae6825e86d6612 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:18:51 +0200 Subject: [PATCH 1/8] ci(bot-check): give the monitor an alarm path and derive what it asserts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check could only go red in a tab nobody subscribes to. That is how it sat red for ten consecutive nights on a healthy site — and the cause was the other half of this change: hard-coded expectations. A title changed in the app copy, the literal here did not, and the alarm became one nobody could trust, which is worse than no alarm. Alarm path (issues: write): a failure opens the fixed-title issue "Bot serving check is red", or comments on it when it is already open, so a long outage is one thread rather than one issue per night. The first green run comments and closes it. Derivation: the swept routes come from the @router.get("/seo-proxy/…") decorators in api/routers/seo.py — 10 today, and a new bot page is covered the moment it lands — and the expected spec title from plots//specification.yaml. The per-route assertion is the generated canonical link rather than a title: the SPA shell carries no canonical at all and the href names the route, so one match proves both that the bot hop ran and that the right page came back, and no copy change can make it stale. Watchdog: a sweep that found no routes, or a spec file with no title, fails the run — an empty sweep must never pass by asserting nothing. Timeout recomputed by the file's own formula: 36 check calls x 90 s plus four non-retried probes -> 62 min. Adopted from the sibling repo kurrentschrift. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke --- .github/workflows/bot-serving-check.yml | 175 ++++++++++++++++++--- CHANGELOG.md | 17 +++ agentic/docs/project-guide.md | 2 +- body.html | 192 ++++++++++++++++++++++++ docs/workflows/overview.md | 2 +- 5 files changed, 366 insertions(+), 22 deletions(-) create mode 100644 body.html diff --git a/.github/workflows/bot-serving-check.yml b/.github/workflows/bot-serving-check.yml index 607023608a0..55c6eab0af5 100644 --- a/.github/workflows/bot-serving-check.yml +++ b/.github/workflows/bot-serving-check.yml @@ -14,6 +14,16 @@ # verified on the first dispatched run. The origin is also exactly the layer # that broke in the incident above; Cloudflare-edge issues are out of this # monitor's reach by design. +# +# What the bot pages are is DERIVED from the repo, never written out here. The +# routes come from the `@router.get("/seo-proxy/…")` decorators in +# api/routers/seo.py and the spec title from plots//specification.yaml. +# Hand-written literals go stale: the home title changed to "anyplot.ai — +# AI-generated plot catalog for 15 libraries" and this check sat red for ten +# consecutive nights on a healthy site — an alarm nobody can trust, which is +# worse than no alarm. Deriving turns the check into what it is meant to assert +# ("what is served == what the repo says"), covers every bot page the moment it +# lands in seo.py, and cannot drift on a copy change again. name: Bot Serving Check @@ -22,21 +32,36 @@ on: - cron: "23 6 * * *" # daily 06:23 UTC workflow_dispatch: +# issues: write is the alarm path. Without it the job could only go red in a +# tab nobody subscribes to — which is how those ten red nights stayed unnoticed. +# The failure step opens (or comments on) one fixed-title issue and the success +# step closes it again. permissions: contents: read + issues: write jobs: bot-serving: runs-on: ubuntu-latest - # 27 check() calls x (--retry 2 -> up to 3 attempts x --max-time 30) can - # reach ~41 min worst-case, plus four non-retried probes (llms.txt charset, - # trailing slash, og-image, .well-known redirect — 30s each); 46 leaves + # 36 check() calls (10 derived bot routes + spec page + impl page + + # ClaudeBot + 15 crawler UAs + 404 + robots + sitemap + 3 llms + 2 human + # controls) x (--retry 2 -> up to 3 attempts x --max-time 30) can reach + # ~54 min worst-case, plus four non-retried probes (llms.txt charset, + # trailing slash, og-image, .well-known redirect — 30s each); 62 leaves # room to report a clean failure rather than dying to the job timeout, # which reports nothing useful. Recompute this when adding checks: the - # ceiling is check() calls x 90s, plus margin. - timeout-minutes: 46 + # ceiling is check() calls x 90s, plus margin. The route sweep grows with + # api/routers/seo.py, so a new bot page adds 90s to that ceiling. + timeout-minutes: 62 steps: - - name: Crawler UAs must get 200 + per-route titles + # The routes and the expected title are read out of the repo, so it has + # to be here before the first request goes out. + - name: Check out the repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + + - name: Crawler UAs must get 200 + per-route pages run: | set -uo pipefail # Cloud Run origin of the anyplot-app service (see header comment @@ -66,21 +91,67 @@ jobs: fi } - # Bot path: prerendered per-route HTML from the seo-proxy - # Prefix, not the full title: the copy changed to "anyplot.ai — - # AI-generated plot catalog for 15 libraries" and this check went red - # for ten consecutive days without anyone noticing. The prefix still - # separates the prerendered page from the SPA shell, whose title is - # "any.plot() — any library.", which is the property under test. - check "$GOOGLEBOT" "$ORIGIN/" "anyplot.ai" - check "$GOOGLEBOT" "$ORIGIN/scatter-basic" "<title>Basic Scatter Plot | anyplot.ai" - check "$TWITTERBOT" "$ORIGIN/scatter-basic/python/matplotlib" "Basic Scatter Plot - Matplotlib | anyplot.ai" + # Bot path: prerendered per-route HTML from the seo-proxy. + # + # The routes are the API's own bot pages — every + # `@router.get("/seo-proxy/")` in api/routers/seo.py is a page + # a crawler must be able to reach. Reading them from the source means + # a page added there is swept the moment it lands, and no list here can + # go stale. (The two parameterised decorators, /{spec_id} and + # /{spec_id}/{language}/{library}, are excluded by the `[^"{]` class + # and probed separately below.) + # + # The assertion is the canonical link, not a title: it is GENERATED + # from the route, so it can never drift on a copy change, and it + # proves both halves at once — the SPA shell carries no + # `` at all, so a match means the bot hop ran, + # and the href names the route, so it means the right page came back. + routes=$(grep -oE '@router\.get\("/seo-proxy/[^"{]*"\)' api/routers/seo.py \ + | sed -E 's|@router\.get\("/seo-proxy||; s|"\)$||' \ + | sed 's|^$|/|' | sort -u) + swept=0 + while IFS= read -r route; do + [ -n "$route" ] || continue + swept=$((swept + 1)) + check "$GOOGLEBOT" "$ORIGIN$route" "" + done <<< "$routes" + + # A checkout that silently produced nothing, or a refactor that moves + # those decorators out of reach of the grep, would otherwise pass the + # whole sweep by checking zero routes. + if [ "$swept" -lt 1 ]; then + echo "::error::no bot routes found in api/routers/seo.py — the sweep asserted nothing" + fail=1 + else + echo "swept $swept derived bot route(s)" + fi + + # The spec page's expected title comes from the spec file, not from a + # literal here. `$SPEC_TITLE` as a prefix: the suffix is site + # copy ("| anyplot.ai") and the impl page inserts the library name, + # neither of which this monitor is the right place to pin. + SPEC="scatter-basic" + SPEC_TITLE=$(sed -nE 's/^title:[[:space:]]*(.+)$/\1/p' "plots/$SPEC/specification.yaml" | head -1) + if [ -z "$SPEC_TITLE" ]; then + echo "::error::plots/$SPEC/specification.yaml carries no title — nothing to assert against" + fail=1 + SPEC_TITLE="__no_title_in_the_spec_file__" + else + echo "expecting the $SPEC pages to be titled: $SPEC_TITLE" + fi + check "$GOOGLEBOT" "$ORIGIN/$SPEC" "<title>$SPEC_TITLE" + # The implementation page is asserted on its canonical instead: a + # three-segment route is the one that would still look right with the + # hub page served in its place, and the title prefix cannot tell them + # apart. + check "$TWITTERBOT" "$ORIGIN/$SPEC/python/matplotlib" \ + "<link rel=\"canonical\" href=\"https://anyplot.ai/$SPEC/python/matplotlib\" />" # AI assistants take the same prerendered path (nginx $is_bot). These # checks hit the ORIGIN, so they verify the nginx map independently of # whether Cloudflare's AI Crawl Control currently 403s these UAs at # the edge — an edge-level policy change needs no change here. - check "$CLAUDEBOT" "$ORIGIN/scatter-basic" "<title>Basic Scatter Plot | anyplot.ai" + check "$CLAUDEBOT" "$ORIGIN/$SPEC" "$SPEC_TITLE" # User-directed fetchers: a human asked their assistant to open the # page. All of these were verified receiving the empty SPA shell on @@ -104,7 +175,7 @@ jobs: "Grok/1.0" \ "Mozilla/5.0 (compatible; xAI-Bot/1.0)" do - check "$ua" "$ORIGIN/scatter-basic" "<title>Basic Scatter Plot | anyplot.ai" + check "$ua" "$ORIGIN/$SPEC" "$SPEC_TITLE" done # A crawler asking for a URL that is no page gets a real 404 from the @@ -162,7 +233,7 @@ jobs: # It used to 307 to http://api.anyplot.ai/seo-proxy/... — internal # path, wrong host, plain http, and that host disallows all crawling. slash_target=$(curl -sS --max-time 30 -o /dev/null -A "$GOOGLEBOT" \ - -w '%{redirect_url}' "$ORIGIN/scatter-basic/") + -w '%{redirect_url}' "$ORIGIN/$SPEC/") case "$slash_target" in "") # No redirect at all: %{redirect_url} is empty, which the previous @@ -178,7 +249,71 @@ jobs: # Control: humans must still get the SPA shell — on the home page # and on a deep route. - check "$HUMAN" "$ORIGIN/" '<div id="root">' - check "$HUMAN" "$ORIGIN/scatter-basic" '<div id="root">' + check "$HUMAN" "$ORIGIN/" '<div id="root">' + check "$HUMAN" "$ORIGIN/$SPEC" '<div id="root">' exit $fail + + # The alarm. One issue with a fixed title carries the whole history of + # this monitor: a first failure opens it, every further failure comments + # on it (so a long outage is one thread, not one issue per night), and + # the first green run closes it again. + - name: Raise the alarm + if: failure() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + ALARM_TITLE: Bot serving check is red + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + # env.ALARM_TITLE inside the jq filter, not string interpolation: + # the title travels as data and never as jq syntax. + num=$(gh issue list --state open --limit 100 --json number,title \ + --jq '[.[] | select(.title == env.ALARM_TITLE)] | .[0].number // empty') + if [ -n "$num" ]; then + echo "alarm issue #$num is already open — appending this run" + gh issue comment "$num" --body "Still red: $RUN_URL" + exit 0 + fi + # printf over one argument per line: a YAML block scalar cannot carry + # unindented lines, and a leading run of spaces inside the body would + # render as a markdown code block on the issue. + body=$(printf '%s\n' \ + "The daily bot-serving monitor failed: $RUN_URL" \ + "" \ + "Crawlers may be getting the SPA shell or an error page while the site" \ + "looks healthy to humans — the failure mode that went unnoticed for four" \ + "weeks in 2026 (see the header of the workflow file)." \ + "" \ + "Two causes are worth separating before anything else:" \ + "" \ + '- **The serving path broke** — `app/nginx.conf` (the `$is_bot` map, the' \ + ' `@seo_proxy` upstream) or the API `/seo-proxy` routes. A real incident.' \ + '- **The deployed pages are behind the repo** — the swept routes come from' \ + ' `api/routers/seo.py` and the expected title from the spec file, so a' \ + ' merged change that has not been deployed yet reads as a mismatch. Check' \ + ' for a pending deploy first.' \ + "" \ + "This issue closes itself on the next green run.") + echo "opening the alarm issue" + gh issue create --title "$ALARM_TITLE" --label bug --label infrastructure --body "$body" + + - name: Stand the alarm down + if: success() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + ALARM_TITLE: Bot serving check is red + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + num=$(gh issue list --state open --limit 100 --json number,title \ + --jq '[.[] | select(.title == env.ALARM_TITLE)] | .[0].number // empty') + if [ -n "$num" ]; then + echo "closing alarm issue #$num" + gh issue comment "$num" --body "Green again: $RUN_URL" + gh issue close "$num" + else + echo "no open alarm issue — nothing to close" + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d2675e6f22..525f3b8526c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,23 @@ aggregate instead: an italic *Catalog* line at the end of the version section an ### Added +- **The bot-serving monitor now raises an alarm instead of only turning a tab red, and + derives what it asserts from the repo** — `bot-serving-check.yml` gained `issues: write`: + a failing run opens the fixed-title issue **Bot serving check is red** (or comments on it, + so a long outage is one thread rather than one issue per night) and the next green run + comments and closes it. Until now the check could only go red in the Actions tab, which is + exactly how it sat red for ten consecutive nights unnoticed — caused by the other half of + this change: hard-coded expectations. The swept routes now come from the + `@router.get("/seo-proxy/…")` decorators in `api/routers/seo.py` (10 today, and a new bot + page is covered the moment it lands) and the expected spec title from + `plots/<spec>/specification.yaml`, so a copy change can no longer make the alarm lie. The + per-route assertion is the generated `<link rel="canonical">` — the SPA shell carries none + at all and the href names the route, so one match proves both that the bot hop ran and + that the right page came back. A watchdog fails the run if the sweep found no routes or + the spec file no title, so an empty sweep can never pass by asserting nothing. Timeout + recomputed to 62 min by the file's own formula (36 checks x 90 s + four non-retried + probes). + - **IndexNow: changed pages are pushed to Bing, Yandex, Seznam, Naver and Yep instead of waiting for a crawl** — Bing Webmaster Tools' first recommendation for the site. A public key file (`app/public/<key>.txt`, served by an explicit nginx `location` so crawler UAs diff --git a/agentic/docs/project-guide.md b/agentic/docs/project-guide.md index d2e7fbf7f98..59de2c27a57 100644 --- a/agentic/docs/project-guide.md +++ b/agentic/docs/project-guide.md @@ -742,7 +742,7 @@ Issue ready for maintainer review | **daily-regen.yml** | Scheduled: picks the oldest specs, runs spec polish + cross-library similarity audit, then dispatches bulk-generate | | **auto-update-pr-branches.yml** | When `main` advances, updates open PRs that have auto-merge enabled | | **watchdog-stuck-jobs.yml** | Periodic safety net: detects and unsticks stalled impl-pipeline PRs | -| **bot-serving-check.yml** | Synthetic monitor for the nginx bot -> seo-proxy path | +| **bot-serving-check.yml** | Daily synthetic monitor for the nginx bot -> seo-proxy path; routes and titles derived from `api/routers/seo.py` and the spec files, and a failure opens/comments the fixed-title issue "Bot serving check is red" (closed again by the next green run) | ### Decoupled Architecture diff --git a/body.html b/body.html new file mode 100644 index 00000000000..eff30b0ace0 --- /dev/null +++ b/body.html @@ -0,0 +1,192 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <meta name="description" content="the open plot catalogue. every plot begins as a library-agnostic spec; ai drafts implementations across 15 libraries in python, r, julia & javascript. browse, copy, adapt — colorblind-safe by default." /> + <meta name="keywords" content="python, r, julia, javascript, plotting, matplotlib, seaborn, plotly, bokeh, altair, plotnine, pygal, letsplot, ggplot2, makie, chart.js, d3, echarts, highcharts, mui x charts, data visualization, charts, graphs, ai-generated, code examples, colorblind-safe, imprint palette" /> + <meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)" /> + <meta name="theme-color" content="#0E0E10" media="(prefers-color-scheme: dark)" /> + <title>any.plot() — any library. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/docs/workflows/overview.md b/docs/workflows/overview.md index 04c57d679e9..22b487d9694 100644 --- a/docs/workflows/overview.md +++ b/docs/workflows/overview.md @@ -177,7 +177,7 @@ Located in `.github/workflows/`: | `ci-lint.yml` | Ruff lint check on PRs | | `ci-tests.yml` | Unit + integration tests on PRs | | `notify-deployment.yml` | Records GitHub deployment events for `app` / `api` | -| `bot-serving-check.yml` | Daily synthetic monitor: curls production with crawler UAs (Googlebot/Twitterbot) and fails on non-200 or missing per-route titles — the bot→seo-proxy path is invisible to human traffic and needs its own alarm | +| `bot-serving-check.yml` | Daily synthetic monitor: curls the Cloud Run origin with crawler UAs and fails on non-200 or a page that is not the prerendered one. Routes are derived from the `@router.get("/seo-proxy/…")` decorators in `api/routers/seo.py` and the expected spec title from `plots//specification.yaml`, so no literal here can go stale. A failure opens (or comments on) the fixed-title issue **Bot serving check is red**; the next green run closes it — the bot→seo-proxy path is invisible to human traffic and needs its own alarm | | `util-claude.yml` | On-demand `@claude` utility (issue/PR comments) | --- From 35b8aa99fb2554d9043f7ba5c8a40f5bdadd84e5 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:19:05 +0200 Subject: [PATCH 2/8] chore: drop a stray local test artefact body.html is what the monitor's curl writes on the runner; it slipped in from running the check locally against the origin. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke --- body.html | 192 ------------------------------------------------------ 1 file changed, 192 deletions(-) delete mode 100644 body.html diff --git a/body.html b/body.html deleted file mode 100644 index eff30b0ace0..00000000000 --- a/body.html +++ /dev/null @@ -1,192 +0,0 @@ - - - - - - - - - - - any.plot() — any library. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - From 747772efcd6de32a8e2926ae2483bfe397e0a351 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:27:05 +0200 Subject: [PATCH 3/8] ci(bot-check): serialize runs, probe the consolidated language tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on #11209: - Two overlapping runs (a manual dispatch during the nightly one, which can take up to the 62-minute timeout) both mutate the same alarm issue: two failures could open it twice, and an older run finishing last could close a newer failure or reopen an alarm after a newer success. A concurrency group with cancel-in-progress means only the latest result ever touches the issue. - The comment counted two parameterised decorators; there are three. The middle one, /{spec_id}/{language}, was consolidated onto the hub and is now probed: it must 301 to /{spec}, and a target still carrying /seo-proxy is the redirect loop its own docstring records — 48 Googlebot "Redirect error" URLs before the target was sanitised. - Timeout accounting updated for the fifth non-retried probe; the ceiling stays inside 62 minutes. Changelog entry carries its PR reference. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke --- .github/workflows/bot-serving-check.yml | 39 ++++- CHANGELOG.md | 9 +- body.html | 192 ++++++++++++++++++++++++ 3 files changed, 232 insertions(+), 8 deletions(-) create mode 100644 body.html diff --git a/.github/workflows/bot-serving-check.yml b/.github/workflows/bot-serving-check.yml index 55c6eab0af5..1f7a72ad986 100644 --- a/.github/workflows/bot-serving-check.yml +++ b/.github/workflows/bot-serving-check.yml @@ -32,6 +32,16 @@ on: - cron: "23 6 * * *" # daily 06:23 UTC workflow_dispatch: +# One run at a time, and the newest wins. Two overlapping runs — a manual +# dispatch during the nightly one, which can take up to the timeout below — +# would both mutate the same alarm issue: two failures could open it twice, and +# an older run finishing last could close a newer failure or reopen an alarm +# after a newer success. Cancelling the superseded run means only the latest +# result ever touches the issue. +concurrency: + group: bot-serving-check-${{ github.ref }} + cancel-in-progress: true + # issues: write is the alarm path. Without it the job could only go red in a # tab nobody subscribes to — which is how those ten red nights stayed unnoticed. # The failure step opens (or comments on) one fixed-title issue and the success @@ -46,8 +56,9 @@ jobs: # 36 check() calls (10 derived bot routes + spec page + impl page + # ClaudeBot + 15 crawler UAs + 404 + robots + sitemap + 3 llms + 2 human # controls) x (--retry 2 -> up to 3 attempts x --max-time 30) can reach - # ~54 min worst-case, plus four non-retried probes (llms.txt charset, - # trailing slash, og-image, .well-known redirect — 30s each); 62 leaves + # ~54 min worst-case, plus five non-retried probes (llms.txt charset, + # trailing slash, og-image, .well-known redirect, the /{spec}/{language} + # 301 — 30s each); 62 leaves # room to report a clean failure rather than dying to the job timeout, # which reports nothing useful. Recompute this when adding checks: the # ceiling is check() calls x 90s, plus margin. The route sweep grows with @@ -97,9 +108,9 @@ jobs: # `@router.get("/seo-proxy/")` in api/routers/seo.py is a page # a crawler must be able to reach. Reading them from the source means # a page added there is swept the moment it lands, and no list here can - # go stale. (The two parameterised decorators, /{spec_id} and - # /{spec_id}/{language}/{library}, are excluded by the `[^"{]` class - # and probed separately below.) + # go stale. (The three parameterised decorators — /{spec_id}, + # /{spec_id}/{language} and /{spec_id}/{language}/{library} — are + # excluded by the `[^"{]` class and each probed separately below.) # # The assertion is the canonical link, not a title: it is GENERATED # from the route, so it can never drift on a copy change, and it @@ -147,6 +158,24 @@ jobs: check "$TWITTERBOT" "$ORIGIN/$SPEC/python/matplotlib" \ "" + # The middle tier, /{spec}/{language}, was consolidated onto the hub + # and must answer 301 -> /{spec}. Its own docstring records why it is + # worth a probe: a Location of /seo-proxy/{spec} is re-prefixed by + # nginx, arrives back at this route and redirects forever — Googlebot + # logged 48 "Redirect error" URLs before the target was sanitised. The + # loop signature is a target that still carries /seo-proxy. + lang_target=$(curl -sS --max-time 30 -o /dev/null -A "$GOOGLEBOT" \ + -w '%{redirect_url}' "$ORIGIN/$SPEC/python") + case "$lang_target" in + *"/seo-proxy"*) + echo "::error::/$SPEC/python redirects into the internal proxy path: $lang_target" + fail=1 ;; + "$ORIGIN/$SPEC") echo "OK: /$SPEC/python -> $lang_target" ;; + *) + echo "::error::/$SPEC/python should 301 to $ORIGIN/$SPEC, got '$lang_target'" + fail=1 ;; + esac + # AI assistants take the same prerendered path (nginx $is_bot). These # checks hit the ORIGIN, so they verify the nginx map independently of # whether Cloudflare's AI Crawl Control currently 403s these UAs at diff --git a/CHANGELOG.md b/CHANGELOG.md index 525f3b8526c..ffbe1259711 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,9 +41,12 @@ aggregate instead: an italic *Catalog* line at the end of the version section an per-route assertion is the generated `` — the SPA shell carries none at all and the href names the route, so one match proves both that the bot hop ran and that the right page came back. A watchdog fails the run if the sweep found no routes or - the spec file no title, so an empty sweep can never pass by asserting nothing. Timeout - recomputed to 62 min by the file's own formula (36 checks x 90 s + four non-retried - probes). + the spec file no title, so an empty sweep can never pass by asserting nothing. The + consolidated middle tier `/{spec}/{language}` gained a probe of its own — it must 301 to + the hub, and a target still carrying `/seo-proxy` is the redirect loop that once cost 48 + Googlebot "Redirect error" URLs. A `concurrency` group keeps a manual dispatch from racing + the nightly run over the same issue. Timeout recomputed to 62 min by the file's own + formula (36 checks x 90 s + five non-retried probes). (#11209) - **IndexNow: changed pages are pushed to Bing, Yandex, Seznam, Naver and Yep instead of waiting for a crawl** — Bing Webmaster Tools' first recommendation for the site. A public diff --git a/body.html b/body.html new file mode 100644 index 00000000000..eff30b0ace0 --- /dev/null +++ b/body.html @@ -0,0 +1,192 @@ + + + + + + + + + + + any.plot() — any library. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + From 8aaeb9659af6cdc7476dfde0a609b5f23a102880 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:29:28 +0200 Subject: [PATCH 4/8] chore: drop the stray curl artefact again body.html is what the monitor's curl writes; verifying the new /{spec}/{language} probe locally re-created it in the worktree. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke --- body.html | 192 ------------------------------------------------------ 1 file changed, 192 deletions(-) delete mode 100644 body.html diff --git a/body.html b/body.html deleted file mode 100644 index eff30b0ace0..00000000000 --- a/body.html +++ /dev/null @@ -1,192 +0,0 @@ - - - - - - - - - - - any.plot() — any library. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - From c8d0fcbeb2b193412fefa9a622aa65563e9e57fe Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:35:31 +0200 Subject: [PATCH 5/8] ci(bot-check): parse the decorators, assert the 301, keep the alarm on main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round on #11209: - The route extraction was a regex over one exact spelling. A decorator in single quotes, wrapped over two lines or carrying kwargs was silently skipped while `swept` stayed nonzero — a short list that looks like coverage, which is the very failure this file exists to stop making. It now reads the parsed AST, and it returns the COUNT of parameterised routes as well: the three probed by hand are asserted, so a fourth added to seo.py fails the run instead of going unnoticed. Measured against a file carrying all four variants: the old regex found 1 of 4 static routes, the AST finds 4 and the count guard fires. - The /{spec}/{language} probe checked only the redirect target. A 302, 307 or 308 to the same hub consolidates nothing, and the endpoint documents a permanent redirect, so the status is now asserted as exactly 301. - workflow_dispatch can run this file from any branch, and both issue steps mutate one repository-wide alarm. They now run only on the default branch: off main the checks still run and still red the job, but a branch experimenting with the derived expectations cannot raise or silently close a production incident. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke --- .github/workflows/bot-serving-check.yml | 111 ++++++++++++++++++------ CHANGELOG.md | 23 +++-- 2 files changed, 99 insertions(+), 35 deletions(-) diff --git a/.github/workflows/bot-serving-check.yml b/.github/workflows/bot-serving-check.yml index 1f7a72ad986..d4c7317a88b 100644 --- a/.github/workflows/bot-serving-check.yml +++ b/.github/workflows/bot-serving-check.yml @@ -105,21 +105,57 @@ jobs: # Bot path: prerendered per-route HTML from the seo-proxy. # # The routes are the API's own bot pages — every - # `@router.get("/seo-proxy/")` in api/routers/seo.py is a page - # a crawler must be able to reach. Reading them from the source means - # a page added there is swept the moment it lands, and no list here can - # go stale. (The three parameterised decorators — /{spec_id}, - # /{spec_id}/{language} and /{spec_id}/{language}/{library} — are - # excluded by the `[^"{]` class and each probed separately below.) + # `@router.get("/seo-proxy/…")` in api/routers/seo.py is a page a + # crawler must be able to reach. Reading them from the source means a + # page added there is swept the moment it lands, and no list here can + # go stale. + # + # The extraction parses the AST rather than grepping for a literal + # spelling: a decorator written with single quotes, wrapped over two + # lines, or carrying kwargs would be silently skipped by a regex, and + # a silently short list is exactly the failure this file is trying to + # stop making. The parameterised routes (paths containing `{`) cannot + # be swept generically and are each probed by hand below, so their + # COUNT is returned too and checked against what this file probes — + # a fourth one added to seo.py fails the run instead of going + # unnoticed. # # The assertion is the canonical link, not a title: it is GENERATED # from the route, so it can never drift on a copy change, and it # proves both halves at once — the SPA shell carries no # `` at all, so a match means the bot hop ran, # and the href names the route, so it means the right page came back. - routes=$(grep -oE '@router\.get\("/seo-proxy/[^"{]*"\)' api/routers/seo.py \ - | sed -E 's|@router\.get\("/seo-proxy||; s|"\)$||' \ - | sed 's|^$|/|' | sort -u) + seo_decorators=$(python3 - <<'PY' + import ast + + SRC = "api/routers/seo.py" + static, templated = set(), 0 + for node in ast.walk(ast.parse(open(SRC, encoding="utf-8").read())): + for dec in getattr(node, "decorator_list", []): + if not isinstance(dec, ast.Call): + continue + fn = dec.func + if not (isinstance(fn, ast.Attribute) and fn.attr == "get"): + continue + if not (isinstance(fn.value, ast.Name) and fn.value.id == "router"): + continue + if not dec.args or not isinstance(dec.args[0], ast.Constant): + continue + path = dec.args[0].value + if not isinstance(path, str) or not path.startswith("/seo-proxy"): + continue + if "{" in path: + templated += 1 + else: + static.add(path.removeprefix("/seo-proxy") or "/") + for route in sorted(static): + print("route " + route) + print("templated %d" % templated) + PY + ) + routes=$(printf '%s\n' "$seo_decorators" | sed -n 's/^route //p') + templated=$(printf '%s\n' "$seo_decorators" | sed -n 's/^templated //p') + swept=0 while IFS= read -r route; do [ -n "$route" ] || continue @@ -127,16 +163,24 @@ jobs: check "$GOOGLEBOT" "$ORIGIN$route" "" done <<< "$routes" - # A checkout that silently produced nothing, or a refactor that moves - # those decorators out of reach of the grep, would otherwise pass the - # whole sweep by checking zero routes. + # A checkout that silently produced nothing, a parse error, or a + # refactor that moves the decorators somewhere this cannot see would + # otherwise pass the whole sweep by checking zero routes. if [ "$swept" -lt 1 ]; then - echo "::error::no bot routes found in api/routers/seo.py — the sweep asserted nothing" + echo "::error::no static bot routes found in api/routers/seo.py — the sweep asserted nothing" fail=1 else echo "swept $swept derived bot route(s)" fi + # The parameterised routes are probed one by one below: /{spec_id}, + # /{spec_id}/{language} and /{spec_id}/{language}/{library}. Bump this + # number only together with a new probe for the new route. + if [ "$templated" != "3" ]; then + echo "::error::api/routers/seo.py has $templated parameterised /seo-proxy route(s); this monitor probes 3 by hand — add a probe for the new one and update this guard" + fail=1 + fi + # The spec page's expected title comes from the spec file, not from a # literal here. `$SPEC_TITLE` as a prefix: the suffix is site # copy ("| anyplot.ai") and the impl page inserts the library name, @@ -164,17 +208,25 @@ jobs: # nginx, arrives back at this route and redirects forever — Googlebot # logged 48 "Redirect error" URLs before the target was sanitised. The # loop signature is a target that still carries /seo-proxy. - lang_target=$(curl -sS --max-time 30 -o /dev/null -A "$GOOGLEBOT" \ - -w '%{redirect_url}' "$ORIGIN/$SPEC/python") - case "$lang_target" in - *"/seo-proxy"*) - echo "::error::/$SPEC/python redirects into the internal proxy path: $lang_target" - fail=1 ;; - "$ORIGIN/$SPEC") echo "OK: /$SPEC/python -> $lang_target" ;; - *) - echo "::error::/$SPEC/python should 301 to $ORIGIN/$SPEC, got '$lang_target'" - fail=1 ;; - esac + # The STATUS is part of the contract, not only the target: a 302/307/308 + # to the same hub would consolidate nothing, and the endpoint documents + # a permanent redirect. + read -r lang_code lang_target <<< "$(curl -sS --max-time 30 -o /dev/null \ + -A "$GOOGLEBOT" -w '%{http_code} %{redirect_url}' "$ORIGIN/$SPEC/python")" + if [ "$lang_code" != "301" ]; then + echo "::error::/$SPEC/python answered HTTP $lang_code (expected a permanent 301 onto the hub)" + fail=1 + else + case "$lang_target" in + *"/seo-proxy"*) + echo "::error::/$SPEC/python redirects into the internal proxy path: $lang_target" + fail=1 ;; + "$ORIGIN/$SPEC") echo "OK: /$SPEC/python -> 301 $lang_target" ;; + *) + echo "::error::/$SPEC/python should 301 to $ORIGIN/$SPEC, got '$lang_target'" + fail=1 ;; + esac + fi # AI assistants take the same prerendered path (nginx $is_bot). These # checks hit the ORIGIN, so they verify the nginx map independently of @@ -287,8 +339,15 @@ jobs: # this monitor: a first failure opens it, every further failure comments # on it (so a long outage is one thread, not one issue per night), and # the first green run closes it again. + # + # Only from the default branch. The schedule always runs there, but + # workflow_dispatch can pick any branch that carries this file — and a + # branch experimenting with the derived expectations must not be able to + # raise, or silently close, a repository-wide production incident. Off + # main the checks still run and still red the job; only the issue is left + # alone. - name: Raise the alarm - if: failure() + if: failure() && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} @@ -329,7 +388,7 @@ jobs: gh issue create --title "$ALARM_TITLE" --label bug --label infrastructure --body "$body" - name: Stand the alarm down - if: success() + if: success() && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} diff --git a/CHANGELOG.md b/CHANGELOG.md index ffbe1259711..971fd676196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,18 +35,23 @@ aggregate instead: an italic *Catalog* line at the end of the version section an comments and closes it. Until now the check could only go red in the Actions tab, which is exactly how it sat red for ten consecutive nights unnoticed — caused by the other half of this change: hard-coded expectations. The swept routes now come from the - `@router.get("/seo-proxy/…")` decorators in `api/routers/seo.py` (10 today, and a new bot - page is covered the moment it lands) and the expected spec title from + `@router.get("/seo-proxy/…")` decorators in `api/routers/seo.py` — read off the parsed + AST, so a decorator in single quotes, wrapped over two lines or carrying kwargs is not + silently skipped the way a regex skips it (10 routes today, and a new bot page is covered + the moment it lands) — and the expected spec title from `plots/<spec>/specification.yaml`, so a copy change can no longer make the alarm lie. The per-route assertion is the generated `<link rel="canonical">` — the SPA shell carries none at all and the href names the route, so one match proves both that the bot hop ran and - that the right page came back. A watchdog fails the run if the sweep found no routes or - the spec file no title, so an empty sweep can never pass by asserting nothing. The - consolidated middle tier `/{spec}/{language}` gained a probe of its own — it must 301 to - the hub, and a target still carrying `/seo-proxy` is the redirect loop that once cost 48 - Googlebot "Redirect error" URLs. A `concurrency` group keeps a manual dispatch from racing - the nightly run over the same issue. Timeout recomputed to 62 min by the file's own - formula (36 checks x 90 s + five non-retried probes). (#11209) + that the right page came back. Two watchdogs stop a silent no-op: the run fails if the + sweep found no routes or the spec file no title, and it fails if `seo.py` grows a fourth + parameterised route beyond the three this file probes by hand. The middle of those three, + the consolidated `/{spec}/{language}`, gained a probe of its own — it must answer 301, and + a target still carrying `/seo-proxy` is the redirect loop that once cost 48 Googlebot + "Redirect error" URLs. A `concurrency` group keeps a manual dispatch from racing the + nightly run over the same issue, and the two issue-mutating steps run only on the default + branch, so a dispatch from a feature branch can never raise or close a production + incident. Timeout recomputed to 62 min by the file's own formula (36 checks x 90 s + five + non-retried probes). (#11209) - **IndexNow: changed pages are pushed to Bing, Yandex, Seznam, Naver and Yep instead of waiting for a crawl** — Bing Webmaster Tools' first recommendation for the site. A public From 4514d1629be8ace2713bd65bc6089ac3ebdacd90 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:38:55 +0200 Subject: [PATCH 6/8] ci(bot-check): decode the spec title with a YAML parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on #11209: the title was read off the source line, so a quoted title — valid YAML, and already present in this repo, e.g. plots/heatmap-chromagram/specification.yaml — would put the quote characters into the needle and produce exactly the false alarm this rewrite exists to remove. sed: ["Music Chromagram (Pitch Class Distribution over Time)"] parser: [Music Chromagram (Pitch Class Distribution over Time)] yaml.safe_load now decodes it, with a pip fallback in case the runner's python has no PyYAML. Live run unchanged: "Basic Scatter Plot". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke --- .github/workflows/bot-serving-check.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bot-serving-check.yml b/.github/workflows/bot-serving-check.yml index d4c7317a88b..23b4f00dee0 100644 --- a/.github/workflows/bot-serving-check.yml +++ b/.github/workflows/bot-serving-check.yml @@ -185,8 +185,24 @@ jobs: # literal here. `<title>$SPEC_TITLE` as a prefix: the suffix is site # copy ("| anyplot.ai") and the impl page inserts the library name, # neither of which this monitor is the right place to pin. + # Decoded with a YAML parser, not read off the source line: a quoted + # title is valid YAML and already occurs in this repo (see + # plots/heatmap-chromagram/specification.yaml), and a needle carrying + # the quote characters would be a false alarm of exactly the kind this + # rewrite exists to remove. SPEC="scatter-basic" - SPEC_TITLE=$(sed -nE 's/^title:[[:space:]]*(.+)$/\1/p' "plots/$SPEC/specification.yaml" | head -1) + python3 -c "import yaml" 2>/dev/null || pip install --quiet --disable-pip-version-check pyyaml + SPEC_TITLE=$(python3 - "$SPEC" <<'PY' + import sys + + import yaml + + with open("plots/%s/specification.yaml" % sys.argv[1], encoding="utf-8") as fh: + doc = yaml.safe_load(fh) or {} + title = doc.get("title") + print(title if isinstance(title, str) else "") + PY + ) if [ -z "$SPEC_TITLE" ]; then echo "::error::plots/$SPEC/specification.yaml carries no title — nothing to assert against" fail=1 From 827f6cc4d73e5e98140f266a6598418327521782 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:42:47 +0200 Subject: [PATCH 7/8] ci(bot-check): read the path kwarg too, and fail on one it cannot resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on #11209: FastAPI accepts the path positionally OR as `path=`, and the extraction read only dec.args[0]. A syntax-only refactor to the keyword form would have dropped a bot page out of both the sweep and the parameterised-route count, silently — the exact failure this rewrite exists to stop making. The path now comes from the first positional argument or the `path` keyword, and a router.get whose path is not a string literal at all is counted and reported: a route this cannot see is one it cannot promise to cover. Measured against a file carrying every form: swept 2 derived bot route(s) (incl. the keyword one) ::error::… has 4 parameterised /seo-proxy route(s) … probes 3 by hand ::error::1 router.get decorator(s) … carry a path this monitor cannot resolve Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke --- .github/workflows/bot-serving-check.yml | 26 +++++++++++++++++++++---- CHANGELOG.md | 7 ++++--- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/.github/workflows/bot-serving-check.yml b/.github/workflows/bot-serving-check.yml index 23b4f00dee0..03da573efad 100644 --- a/.github/workflows/bot-serving-check.yml +++ b/.github/workflows/bot-serving-check.yml @@ -129,7 +129,7 @@ jobs: import ast SRC = "api/routers/seo.py" - static, templated = set(), 0 + static, templated, unresolved = set(), 0, 0 for node in ast.walk(ast.parse(open(SRC, encoding="utf-8").read())): for dec in getattr(node, "decorator_list", []): if not isinstance(dec, ast.Call): @@ -139,10 +139,18 @@ jobs: continue if not (isinstance(fn.value, ast.Name) and fn.value.id == "router"): continue - if not dec.args or not isinstance(dec.args[0], ast.Constant): + # FastAPI takes the path positionally OR as `path=`. Anything + # else — a variable, an f-string, a constant folded elsewhere — + # is a route this cannot reason about, and staying silent about + # it is how a bot page would drop out of the sweep unnoticed. + arg = dec.args[0] if dec.args else next( + (kw.value for kw in dec.keywords if kw.arg == "path"), None + ) + if not isinstance(arg, ast.Constant) or not isinstance(arg.value, str): + unresolved += 1 continue - path = dec.args[0].value - if not isinstance(path, str) or not path.startswith("/seo-proxy"): + path = arg.value + if not path.startswith("/seo-proxy"): continue if "{" in path: templated += 1 @@ -151,10 +159,12 @@ jobs: for route in sorted(static): print("route " + route) print("templated %d" % templated) + print("unresolved %d" % unresolved) PY ) routes=$(printf '%s\n' "$seo_decorators" | sed -n 's/^route //p') templated=$(printf '%s\n' "$seo_decorators" | sed -n 's/^templated //p') + unresolved=$(printf '%s\n' "$seo_decorators" | sed -n 's/^unresolved //p') swept=0 while IFS= read -r route; do @@ -181,6 +191,14 @@ jobs: fail=1 fi + # A router.get whose path is not a string literal cannot be classified + # at all, and a route this cannot see is a route it cannot promise to + # cover. Loud, not silent. + if [ "$unresolved" != "0" ]; then + echo "::error::$unresolved router.get decorator(s) in api/routers/seo.py carry a path this monitor cannot resolve — it may be skipping a bot page" + fail=1 + fi + # The spec page's expected title comes from the spec file, not from a # literal here. `<title>$SPEC_TITLE` as a prefix: the suffix is site # copy ("| anyplot.ai") and the impl page inserts the library name, diff --git a/CHANGELOG.md b/CHANGELOG.md index 971fd676196..bb04b1bdc80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,9 +36,10 @@ aggregate instead: an italic *Catalog* line at the end of the version section an exactly how it sat red for ten consecutive nights unnoticed — caused by the other half of this change: hard-coded expectations. The swept routes now come from the `@router.get("/seo-proxy/…")` decorators in `api/routers/seo.py` — read off the parsed - AST, so a decorator in single quotes, wrapped over two lines or carrying kwargs is not - silently skipped the way a regex skips it (10 routes today, and a new bot page is covered - the moment it lands) — and the expected spec title from + AST, so a decorator in single quotes, wrapped over two lines, carrying kwargs or passing + its path as `path=` is not silently skipped the way a regex skips it, and a path that is + no string literal at all fails the run rather than disappearing (10 routes today, and a + new bot page is covered the moment it lands) — and the expected spec title from `plots/<spec>/specification.yaml`, so a copy change can no longer make the alarm lie. The per-route assertion is the generated `<link rel="canonical">` — the SPA shell carries none at all and the href names the route, so one match proves both that the bot hop ran and From 820ce3dc617b3c74b87c958eaad7fe0e0c717efc Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:45:55 +0200 Subject: [PATCH 8/8] ci(bot-check): escape the derived title the way the renderer escapes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding on #11209: api/routers/seo.py runs html.escape over the title before it reaches <title>, so a title carrying an apostrophe, & or < would make the YAML-decoded needle miss a perfectly healthy page — the false alarm this rewrite exists to remove, arriving through the other door. Verified against the live origin, on the spec the review named: yaml only : "Mohr's Circle for Stress Analysis" escaped : 'Mohr's Circle for Stress Analysis' served : <title>Mohr's Circle for Stress Analysis | anyplot.ai escaped needle present in the served page: True raw needle present in the served page: False Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke --- .github/workflows/bot-serving-check.yml | 16 ++++++++++------ CHANGELOG.md | 3 ++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/bot-serving-check.yml b/.github/workflows/bot-serving-check.yml index 03da573efad..af71f8ce8f7 100644 --- a/.github/workflows/bot-serving-check.yml +++ b/.github/workflows/bot-serving-check.yml @@ -203,14 +203,18 @@ jobs: # literal here. `$SPEC_TITLE` as a prefix: the suffix is site # copy ("| anyplot.ai") and the impl page inserts the library name, # neither of which this monitor is the right place to pin. - # Decoded with a YAML parser, not read off the source line: a quoted - # title is valid YAML and already occurs in this repo (see - # plots/heatmap-chromagram/specification.yaml), and a needle carrying - # the quote characters would be a false alarm of exactly the kind this - # rewrite exists to remove. + # Two decodings, both mirroring what the page actually does with the + # value. yaml.safe_load, not the source line: a quoted title is valid + # YAML and already occurs in this repo (plots/heatmap-chromagram). + # html.escape, because api/routers/seo.py escapes the title before it + # reaches `<title>` — `Mohr's Circle` is served as `Mohr's + # Circle`, and a raw needle would be a false alarm against a perfectly + # healthy page. Both are the same failure this rewrite exists to + # remove: an expectation that does not match what is served. SPEC="scatter-basic" python3 -c "import yaml" 2>/dev/null || pip install --quiet --disable-pip-version-check pyyaml SPEC_TITLE=$(python3 - "$SPEC" <<'PY' + import html import sys import yaml @@ -218,7 +222,7 @@ jobs: with open("plots/%s/specification.yaml" % sys.argv[1], encoding="utf-8") as fh: doc = yaml.safe_load(fh) or {} title = doc.get("title") - print(title if isinstance(title, str) else "") + print(html.escape(title) if isinstance(title, str) else "") PY ) if [ -z "$SPEC_TITLE" ]; then diff --git a/CHANGELOG.md b/CHANGELOG.md index bb04b1bdc80..66f66651c61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,8 @@ aggregate instead: an italic *Catalog* line at the end of the version section an AST, so a decorator in single quotes, wrapped over two lines, carrying kwargs or passing its path as `path=` is not silently skipped the way a regex skips it, and a path that is no string literal at all fails the run rather than disappearing (10 routes today, and a - new bot page is covered the moment it lands) — and the expected spec title from + new bot page is covered the moment it lands) — and the expected spec title, YAML-decoded + and then HTML-escaped the way the renderer escapes it, from `plots/<spec>/specification.yaml`, so a copy change can no longer make the alarm lie. The per-route assertion is the generated `<link rel="canonical">` — the SPA shell carries none at all and the href names the route, so one match proves both that the bot hop ran and