Skip to content

Prepare for use from tornado's release process - #7

Merged
bdarnell merged 12 commits into
tornadoweb:mainfrom
bdarnell:claude/elegant-cori-x2ogv3
Sep 19, 2026
Merged

bdarnell merged 12 commits into
tornadoweb:mainfrom
bdarnell:claude/elegant-cori-x2ogv3

Conversation

@bdarnell

Copy link
Copy Markdown
Member

No description provided.

The testbed is about to be called from tornado's release build, so a green run
has to mean "the requested tornado passed" rather than "some tornado passed".
Several paths did not meet that bar:

- A failed `uv pip install --upgrade ${TORNADO_SPEC}` was only a warning, so a
  typo'd spec or a dead git ref left the downstream resolver's own tornado in
  place and the package still reported PASS. It is now TORNADO_INSTALL_FAIL.
- Nothing verified which tornado ended up in the venv. The version and
  __file__ are now recorded in results/<name>.txt, and TORNADO_EXPECT_VERSION
  (set by the caller when the spec names a version, e.g. a wheel filename)
  makes the check an assertion.
- Several test commands install more packages mid-run, so a transitive
  `tornado<X` pin could quietly downgrade the thing under test after it was
  verified. The version is re-probed after the run and drift fails the package.
- `uv venv` was unchecked, so on failure the following `source activate` failed
  too and the suite ran against the system python. Both are checked now.
- ci.sh always exited 0. It now exits non-zero unless every package passed,
  gated by FAIL_ON_REGRESSION=1 so report-only runs are still available. The
  summary, step summary and failing-log tails are all written first, so a
  failing gate still leaves a full report behind.
- ci.sh now clears results/ and logs/ first. Otherwise a stale result file from
  an earlier run counts toward the gate, which bites hardest on ONLY= runs.

Also collapses the three duplicated result-writing blocks into one writer so
every exit path emits the same schema, and retries once on TIMEOUT only -- the
documented ZeroMQ deadlock class. Genuine failures are not retried; re-running
red tests to see if they go green is how a gate loses its meaning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDiwCvRXgWijEDDsbuiT3g
common.sh enables `set -e`, which the previous commit's `run_tests; rc=$?`
refactor did not account for (the code it replaced had an explicit
`set +e` / `set -e` pair around the same call). The effect was worse than the
bug it was part of fixing: the first failing suite aborted run_one.sh before
any result file was written, so run_all.sh reported NORESULT and ci.sh's new
gate -- which iterates the result files that exist -- never saw the package at
all. A red package was invisible.

Verified against all of run_one.sh's exit paths: PASS, FAIL (a suite exiting
non-zero now writes status=FAIL and returns its own code),
TORNADO_INSTALL_FAIL, and TORNADO_MISMATCH in both the expected-version and
mid-run-drift forms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDiwCvRXgWijEDDsbuiT3g
The manifest was 14 KB, of which only about six short fields per package were
actually data (repo, ref, subdir, install_method, pypi_spec, name). The rest was
in the wrong container: ~8.3 KB of prose in `notes` and `reason`, and ~2.5 KB of
shell in `test_cmd` and `setup_extra`, escaped into JSON one-liners.
jupyter_server alone carried a 370-character command and 1.8 KB of notes
describing that command from a distance.

Each package is now packages/<name>/ holding requirements.txt (the pin),
package.env (metadata as strict key=value), test.sh, an optional setup.sh, and
a README.md. The downstream sources move to checkouts/, freeing packages/ for
the definitions.

What this buys beyond tidiness:

- The reason a test is deselected now sits on the line above the deselect.
  In a harness whose whole premise is "red means a Tornado regression", that
  rationale is the thing needing the most upkeep, and it was living in a
  separate field.
- shellcheck covers the test commands, which were invisible to it inside JSON.
- The git ref is derived from the pin via tag_template instead of stored beside
  it, so `ref` and `pypi_spec` can no longer disagree about which version is
  under test.
- pkg_field is gone, along with its ~6 python3 spawns per package and its
  interpolation of the lookup key into python source. scripts/pkglib.py is now
  the single parser, shared by bash and by validate_packages.py.
- Adding a package is a mkdir and four files, with no conflicts in a shared
  manifest.

Behaviour-preserving, checked before any pin was touched: the derived refs match
the old manifest exactly for all ten packages, and a full run at tornado 6.5.5
reproduces the committed snapshot for eight of them (statuses and comparable
timings; flower's "162 passed, 2 skipped" and the rest unchanged).

The two that differ are pre-existing rot, not regressions from this change:
jupyter_server 2.14.2 and notebook 7.2.2 no longer build under current
hatchling, which a bare `uv pip install -e .` in their checkouts reproduces with
no harness code involved. Refreshing those pins is the next commit. (panel also
failed once on a PyPI metadata timeout and passes on retry.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDiwCvRXgWijEDDsbuiT3g
ci.sh carried the summary renderer, the failing-log tails and the exit-code
decision inline. The job matrix added in the next commit needs all three too,
and two implementations of "did this run pass?" would eventually disagree --
which for a release gate is the expensive kind of bug.

scripts/report.sh now owns rendering the markdown summary, echoing each failing
package's log tail, publishing total/passed/failed, and choosing the exit code.
Both paths call it: ci.sh execs it at the end, and the matrix's report job runs
it over the downloaded per-package artifacts.

To make that second case work, common.sh lets RESULTS_DIR, LOGS_DIR and
CHECKOUTS_DIR be overridden, so the report can be pointed at a directory of
collected artifacts rather than a local run's output. (It previously assigned
them unconditionally, which silently ignored any override.)

ci.sh also now validates the package definitions before doing anything, so a
malformed definition fails in the first second rather than twenty minutes in,
and skips the HTML coverage build when COVERAGE=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDiwCvRXgWijEDDsbuiT3g
The workflow was workflow_dispatch only, so using it meant a human remembering
to. It now also accepts workflow_call, which is how tornadoweb/tornado's
build.yml will gate on it: that workflow already runs on exactly the right
events (pushes to release branches and v* tags) and already builds the
artifacts we want to test.

The artifact path is the reason to prefer a reusable workflow over a cross-repo
dispatch. A called workflow runs as jobs inside the *caller's* run, so
actions/download-artifact resolves against that run and the testbed can install
the exact wheel or sdist the release build just produced, rather than a
re-resolved git ref. resolve_tornado_artifact.sh picks the artifact, prefers a
wheel over an sdist, and reads the version out of the filename so run_one.sh can
assert that version is what ends up installed. It fails loudly on an artifact
with no tornado build in it, because falling back to PyPI's tornado would make
the entire run meaningless.

Packages now run as a job matrix rather than serially:

- The old single job had a 60-minute limit against a worst case of 10 x 900s.
  A slow run was killed by GitHub with no summary at all; now each package has
  its own budget.
- fail-fast is off, so one red package does not hide the others, and a single
  flaky package can be re-run from the Actions UI.
- It also isolates the orphaned-pytest-process contention in REPORT.md.

A weekly schedule runs it against master. Without that, a release run would be
where we discover downstream drift has broken the baseline -- which is how gates
get ignored. This is not hypothetical: two pins had already rotted past
installing.

Coverage in the matrix path uploads the raw .coverage files only. Per-package
HTML needs each package's venv, which does not survive a matrix job, so HTML
reports stay with the serial ci.sh path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDiwCvRXgWijEDDsbuiT3g
Every one of the ten pins had drifted, several by years: distributed
2024.10.0 against 2026.8.0, ipykernel 6.29.5 against 7.3.0, streamlit 1.40.0
against 1.64.0. Two had drifted far enough to stop installing at all. Testing
two-year-old downstream code against a new Tornado loses most of the signal,
since downstream may already have fixed whatever would have broken.

Rather than write a pin checker, this uses the tool that already does the job.
The obstacle was that the harness installs from a git checkout at a tag --
because the test suites are not in the wheels -- and dependabot only understands
package-manager files. The tag_template indirection added with the per-package
directories is the bridge: dependabot manages the version in requirements.txt,
and the harness derives the tag from it.

One directory per package is deliberate. A single shared requirements file would
ask dependabot to resolve streamlit, distributed and jupyterhub into one
environment, which cannot succeed.

The pins workflow then runs the package a bump actually changed, against the
latest stable Tornado, so a bump needing its deselects re-curated says so on its
own pull request instead of surfacing during a release build. It also runs
validate_packages.py --check-refs, which catches the case where dependabot
raises a version whose git tag does not exist -- again on the bump's PR rather
than during a release.

What dependabot cannot do is re-curate deselects or follow a downstream major
that moved its test layout. That stays human work, by design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDiwCvRXgWijEDDsbuiT3g
Prep for the repo becoming org infrastructure rather than a personal one-off.

LICENSE is Apache 2.0, byte-identical to tornado's. The repo was unlicensed,
which is a blocker for adopting it into the release process.

tests/harness_test.sh covers the harness's own behaviour -- 37 checks, each
building a throwaway repo root out of the real scripts plus synthetic packages,
so nothing touches the real definitions and paths resolve as they do in a real
checkout. It covers every status run_one.sh can produce, including the ones that
exist because a run had silently tested the wrong Tornado, and the definition
shapes validate_packages.py must reject. One case is there specifically as a
regression test: a failing suite must still write its result file, because when
it did not, a red package was invisible to the gate.

The run_one.sh cases need to install tornado, so they skip with a message when
that is not possible rather than failing ten times over.

lint.yml runs validate_packages.py, shellcheck, zizmor and the self-tests.
shellcheck is clean, which the previous layout could not have been: the test
commands were strings inside JSON and invisible to it. zizmor is clean too,
under a policy file mirroring tornado's, with matrix values passed through the
environment rather than expanded into run blocks.

CONTRIBUTING.md states the rule everything else follows from -- a red package
must mean a Tornado regression -- and CLAUDE.md collects the traps, including
that common.sh enables `set -e` and that editing a script mid-run corrupts the
running shell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDiwCvRXgWijEDDsbuiT3g
jupyter_server and notebook both failed `uv pip install -e .` outright, which
looked like their 2024-era pins having rotted past usability. It was not: a
build-backend release had broken them, and bumping the pins did not help.

hatchling 1.32.1 gave BuildHookInterface a second type parameter
(Generic[BuilderConfigBound, PluginManagerBound]). hatch_jupyter_builder 0.9.1,
the latest release, still subscribes it with one, so every source build in the
Jupyter stack dies with "TypeError: Too few arguments for BuildHookInterface;
actual 1, expected 2". hatchling 1.32.0 is the last release with one parameter.

Packages do not pin their own build backends, so nothing downstream protects
against this, and the harness had no opinion about its build environment at all.
It does now: UV_BUILD_CONSTRAINT points every `uv pip install` in a run --
including those inside a package's test.sh -- at build-constraints.txt. This
constrains build-time (PEP 518) dependencies only; it does not touch what a
package installs at runtime, and it cannot touch the Tornado under test.

With the constraint in place, jupyter_server 2.14.2 installs and passes again at
its original pin, so this was never about pin age.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDiwCvRXgWijEDDsbuiT3g
The change that makes this testbed part of tornado's release build lives in
tornadoweb/tornado, which needs someone with push access there, so it ships
here as an applyable patch plus the reasoning behind its shape.

It adds a `downstream` job to build.yml calling this repo's testbed.yml as a
reusable workflow, and adds `tornadoweb/*: ref-pin` to tornado's zizmor policy,
without which its workflow lint rejects a `uses:` that is not hash-pinned.
Verified by applying both to a tornado checkout: the workflows parse and
`uvx zizmor .github/workflows` reports no findings.

The job is deliberately not in upload_pypi's `needs` -- a flaky downstream suite
must not be able to wedge a release upload -- and is skipped on forks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDiwCvRXgWijEDDsbuiT3g
Every pin had drifted, several by years. Bumping them turned up one finding that
changed the set and one that changed what a green run means.

streamlit is removed. It migrated to Starlette/uvicorn in 1.57.0: 1.64.0
declares no dependency on Tornado and contains no tornado imports at all, so it
failed the testbed's first selection criterion. It was rank 1 and the single
highest-coverage package (45%), but a pin frozen at 1.56.0 could never move
again and would have gone on reporting coverage for code the ecosystem is
leaving behind. Merged coverage only fell from 61% to 59% -- the other web-layer
packages already covered most of the same ground. Ranks are renumbered 1-9 so
the rank selector stays contiguous.

jupyter_server moves to 2.21.1 (1136 tests, up from 970) and with it the testbed
loses its tornado#3724 detector: all four affected tests now pass on 6.5.9,
because 2.21.1 changed FileFindHandler enough not to trip the bug. Tracking
current downstream code is the point, so this is the right trade, but it means a
green run is no longer self-evidently proof the gate works. The replay is now a
documented manual procedure that pins the package back to 2.14.2, in that
package's README, with the caveat repeated in CLAUDE.md so nobody runs it
against the current pin and concludes from green that all is well.

The three bumps that did not come up green:

- bokeh 3.10 requires Python >=3.12. PYTHON_VERSION now defaults to 3.13,
  matching tornado's own default_python_minor and the workflow input, rather
  than 3.11.
- flower's broker tests failed with "NameError: name 'Retry' is not defined":
  broker.py imports it inside a try/except ImportError and redis was not
  installed. A setup.sh hook installs it, which beats deselecting 22 tests for
  a reason unrelated to Tornado -- 251 pass now, up from 162.
- panel's suite uses pytest.mark.xdist_group without pytest-xdist installed, and
  its filterwarnings=error turns the unknown-mark warning into a collection
  error. pytest-xdist is now installed to register the mark, not to parallelize.

Also fixes a bug the self-tests caught in the build-constraints change: uv errors
on a constraints file that is not present, so setting UV_BUILD_CONSTRAINT
unconditionally turned every install in a checkout without that file into an
INSTALL_FAIL. It is now set only when the file exists, with a test covering it.
gen_reports.sh additionally prunes report directories for packages that no
longer exist, which is how a stale streamlit coverage page survived its removal.

9/9 green against tornado 6.5.10 on Python 3.13. 38/38 harness self-tests,
shellcheck and zizmor clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDiwCvRXgWijEDDsbuiT3g
All nine matrix jobs computed an identical setup-uv cache key, so they raced to
reserve one entry and eight logged "Unable to reserve cache". setup-uv builds
the key as
setup-uv-<v>-<arch>-<platform>-<os>-<python><pruned><glob-hash><suffix>, and
with no cache-suffix nothing in it varied per package.

Each job now carries cache-suffix: <package>-py<python_version>. Per-job caches
rather than one shared cache is the right shape here: the nine packages install
completely disjoint dependency trees, so a shared entry would be the union of
all of them and mostly irrelevant to any single job, and sharing one "properly"
would need a warm-up job that serialises the matrix behind it.

The default cache-dependency-glob was a second problem: it matches
**/*requirements*.txt and **/*constraints*.txt, so every job hashed all nine
packages' pins plus build-constraints.txt, and one dependabot bump invalidated
all nine caches at once. Each job now keys on its own pin and the shared build
constraints.

The interpreter is in the suffix rather than passed as setup-uv's python-version
input, so UV_PYTHON stays unset and run_one.sh remains the only thing choosing
an interpreter, while a python_version change still busts the caches.

lint.yml gets cache-suffix: lint so it cannot collide with a matrix job running
at the same time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDiwCvRXgWijEDDsbuiT3g
Coverage resolved to 0 on every automatic path. `schedule` runs have no inputs
so IN_COVERAGE was empty; neither pins.yml nor tornado's release gate passed it.
Only a hand-run dispatch turned it on, and even then the matrix uploaded nine
opaque coverage-<pkg> artifacts that the report job never downloaded, merged or
rendered -- gen_reports.sh was not invoked in the matrix path at all.

It now runs on the weekly schedule and on pin-update pull requests, and stays
off for the release gate, whose job is pass/fail.

gen_reports.sh no longer needs the per-package venvs. That was a limitation of
the script rather than a real one: the [paths] remap already collapses every
package's own site-packages tornado onto one canonical path, so all the script
really needed was a venv with coverage and a tornado source tree to annotate.
It now builds a report venv holding coverage plus the tornado under test when no
package venv is around, and renders each package through combine-then-report so
the remap applies. Verified by deleting every checkout and rebuilding all ten
reports from nothing but the .coverage files: identical numbers.

The floor is the part that matters. A passing run is not evidence that anything
was tested: when a new downstream release moves or renames the files a test.sh
names, the command keeps exiting 0 while exercising almost none of Tornado, and
no other signal here would notice. Each package now declares min_coverage in its
package.env, and a run that measures coverage fails as COVERAGE_LOW when it
comes in under that floor. Arming it on pin-update PRs is the point -- that is
exactly when this happens, and dependabot never touches those values, so a bump
that guts a test command fails on its own pull request.

Implemented as a status rather than a bespoke workflow step so report.sh's
existing "anything that is not PASS fails the run" applies unchanged, and the
serial and matrix paths cannot disagree. It is checked only when coverage was
requested and only when the suite passed: with COVERAGE=0 every package that
declares a floor would otherwise fail the release gate, and on a failing run the
low coverage is explained by the failure, so reporting COVERAGE_LOW there would
bury the real one. Both are covered by tests.

Floors start ~20% under measured, so drift does not trip them and a collapse
does. The report prints a reminder when a package runs well clear of its own,
because a floor that has stopped tracking reality has stopped being a check.

Verified: 9/9 green at tornado 6.5.10 with coverage on, every package clearing
its floor, and every measured value matching the table in REPORT.md. 45/45
harness self-tests, shellcheck and zizmor clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TDiwCvRXgWijEDDsbuiT3g
@bdarnell
bdarnell merged commit 81c24cf into tornadoweb:main Sep 19, 2026
13 checks passed
@bdarnell
bdarnell deleted the claude/elegant-cori-x2ogv3 branch September 19, 2026 19:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants