From 55b5cafa7c31f3b9ef7e1b9025350e99462057f9 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sat, 1 Aug 2026 23:14:04 +0200 Subject: [PATCH 01/25] Add continuous deployment design spec Records the deployment architecture: data pinned as git submodules, a release bundle (wheel + offline wheelhouse + the three data trees + a manifest) scp'd to the VPS, and a synchronous deploy script run by a dedicated non-chrooted user under two narrow sudoers rules. Rejects Docker (host Python 3.12 is pinnable, so a container would only add a registry credential able to pull the private corpus) and rejects pip-install-from-git (puts a second read path to martyrology-texts on the server, and requires packaging metadata in a repo curated by another group). Co-Authored-By: Claude Opus 5 (1M context) --- ...2026-08-01-continuous-deployment-design.md | 409 ++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-01-continuous-deployment-design.md diff --git a/docs/superpowers/specs/2026-08-01-continuous-deployment-design.md b/docs/superpowers/specs/2026-08-01-continuous-deployment-design.md new file mode 100644 index 0000000..8f66c59 --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-continuous-deployment-design.md @@ -0,0 +1,409 @@ +# Continuous Deployment Design + +**Date:** 2026-08-01 +**Status:** Approved, pending implementation +**Supersedes:** the three-option deployment list in `docs/architecture.md` + +## Problem + +`martyrology-api` is a public repository that must serve texts it cannot contain. +The copyrighted 2004-family eulogies live in the private `CatholicOS/martyrology-texts` +repository; the canonical-ID registries (`crmedr`, `clbdr`) live in separate public +repositories. The API resolves all three from filesystem paths at startup +(`create_app()` → `Registry.load(crmedr_path, clbdr_path)` and +`Store(data_path_list, registry)` in `src/martyrology_api/app.py:24-27`). + +Deployment must therefore assemble code and three external data trees into one +running service, on a VPS where: + +- most services are managed by Plesk, which has no ASGI support; +- the existing GitHub Actions deploy identity is a Plesk-chrooted user with + scp access but severely limited command execution; +- the async capabilities of ASGI must be preserved (no WSGI shim, no Passenger). + +## Constraints and decisions taken as given + +These were settled during design and are not revisited here: + +1. **The served corpus is frozen to the release artifact.** A curation PR merged + on `martyrology-texts` does not reach the live API until a release is cut. + This is a deliberate trade of immediacy for reproducibility and auditability. + The mitigation is to make releases cheap and automatic, not to loosen the freeze. +2. **The host's Python is a stable, pinnable 3.12+.** Ubuntu 24.04 LTS ships + Python 3.12 as the system interpreter. A venv on the host is therefore safe, + which removes the principal argument for shipping a container runtime. +3. **The runtime substrate is a plain systemd unit**, not Docker. Given (2), a + container would buy isolation that is not needed while adding a registry + credential on the VPS — a second path by which the private corpus could be + pulled. + +## Verified environment facts + +| Fact | Value | +|---|---| +| VPS OS | Ubuntu 24.04.4 LTS (noble) | +| VPS glibc | 2.39-0ubuntu8.8 | +| Matching runner | `ubuntu-24.04` (pinned explicitly, **not** `ubuntu-latest`) | +| Web front end | Plesk-managed nginx, reverse proxy via "Additional nginx directives" | +| TLS | Owned by Plesk (Let's Encrypt renewal stays automatic) | + +`ubuntu-latest` will eventually roll to 26.04 and silently break the ABI match +between the CI-built wheelhouse and the host. The pin is load-bearing. + +--- + +## 1. Data pinning: git submodules + +Three submodules are added to this repository: + +| Path | Repository | Visibility | +|---|---|---| +| `vendor/crmedr` | `CatholicOS/crmedr` | public | +| `vendor/clbdr` | `CatholicOS/clbdr` | public | +| `vendor/texts` | `CatholicOS/martyrology-texts` | **private** | + +**All three `.gitmodules` URLs must be HTTPS, never SSH.** `actions/checkout` +authenticates submodules by injecting a token as an HTTP extraheader; an +`git@github.com:` URL breaks both the release workflow and Dependabot. + +### Why submodules rather than a pins file + +The pin is a reviewable commit SHA tracked by git, and Dependabot has a +first-class `gitsubmodule` ecosystem. A data update therefore becomes an +automatic pull request on the existing schedule, auto-merged by the existing +`.github/workflows/dependabot-automerge.yml`. No pin-parsing code and no +`repository_dispatch` plumbing are required in the baseline. + +This is deployment option 2 already sanctioned in `docs/architecture.md`, +repurposed for *pinning* rather than for attachment. + +### Dependabot access to the private submodule + +Primary: the organisation-level **Grant Dependabot access to private +repositories** allowlist (Organization Settings → Code security), with +`martyrology-texts` added. + +Fallback, if the org setting is unavailable: a Dependabot secret plus a `git` +registry in `.github/dependabot.yml`: + +```yaml +registries: + martyrology-texts: + type: git + url: https://github.com/CatholicOS/martyrology-texts.git + username: x-access-token + password: ${{ secrets.SUBMODULE_PAT }} + +updates: + - package-ecosystem: "gitsubmodule" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + labels: ["dependencies", "data"] + registries: + - martyrology-texts +``` + +Two known behaviours to accept: the submodule updater tracks the default-branch +tip rather than tags, and it does not group — so expect up to three separate +pull requests per cycle. + +### Effect on local development + +None. `.env` keeps `MARTYROLOGY_CRMEDR_PATH=../crmedr` and +`MARTYROLOGY_CLBDR_PATH=../clbdr` pointing at sibling checkouts. The submodules +are what CI reads. A clone without access to `martyrology-texts` fails to +initialise `vendor/texts` only; `git submodule update --init vendor/crmedr +vendor/clbdr` succeeds for everyone, and the graceful-degradation guarantee +(architecture.md principle 1) already covers the absent corpus. + +## 2. The release bundle + +Artifact name: `martyrology--linux-x86_64-cp312.tar.gz` + +``` +manifest.json +wheels/ martyrology_api--py3-none-any.whl + + every resolved runtime dependency as a wheel +data/editions/ from this repo (public-domain editions) +data/texts/ from vendor/texts +data/crmedr/ from vendor/crmedr +data/clbdr/ from vendor/clbdr +``` + +`manifest.json` records: + +```json +{ + "bundle_format": 1, + "api_version": "0.1.0", + "api_commit": "", + "data": { "texts": "", "crmedr": "", "clbdr": "" }, + "python_requires": ">=3.12", + "files": { "": "" } +} +``` + +This manifest is the auditable record of exactly which corpus is live — the +concrete artifact of decision (1) above. + +**The bundle deliberately excludes `.env`.** Zitadel, OpenFGA and +`MARTYROLOGY_GITHUB_TOKEN` secrets live in root-owned `/etc/martyrology/api.env`, +loaded by the systemd unit's `EnvironmentFile=`. They are never shipped and +never overwritten by a deploy. A deploy changes code and data only, and a leaked +bundle contains no credentials. + +## 3. Release workflow + +`.github/workflows/deploy.yml`, modelled on `cdcf-website/.github/workflows/deploy.yml`. + +```yaml +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy-martyrology + cancel-in-progress: false +``` + +### Repository settings + +| Kind | Name | Value | +|---|---|---| +| Secret | `VPS_HOST` | VPS hostname | +| Secret | `VPS_SSH_KEY` | private half of the deploy keypair | +| Secret | `VPS_USERNAME` | `martyrology-deploy` | +| Secret | `SUBMODULE_TOKEN` | fine-grained PAT, read-only on `martyrology-texts` | +| Variable | `VPS_HOST_KEY` | output of `ssh-keyscan -t ed25519,rsa ` | +| Variable | `APP_DIR` | `/opt/martyrology` | + +`SUBMODULE_TOKEN` is unavoidable: the workflow's `GITHUB_TOKEN` cannot read a +different private repository. + +### Steps + +1. `actions/checkout` with `submodules: recursive` and `token: ${{ secrets.SUBMODULE_TOKEN }}`. +2. `uv build` → api wheel. `uv export --no-dev --format requirements-txt` → hash-pinned + requirements → `pip wheel -r requirements.txt -w wheels/`, on `ubuntu-24.04` / cp312. +3. Assemble the tree, write `manifest.json`, `tar czf`, `sha256sum`. +4. Set up SSH exactly as the existing workflows do: fail fast if any secret or + variable is empty; write `VPS_SSH_KEY` to `~/.ssh/deploy_key` mode 0600; write + the pinned `VPS_HOST_KEY` to `~/.ssh/known_hosts`; run the DNS SSHFP drift check. +5. `scp` the bundle and its `.sha256` to `${APP_DIR}/incoming/`. +6. `ssh … "bash ${APP_DIR}/bin/deploy.sh "`. + +Each network step is wrapped in the established 3-attempt / 15-second retry loop. + +Step 6 is synchronous: the deploy script's stdout lands in the Actions log and +its exit code decides the build. There are no blind deploys and no status-file +handshake. + +## 4. VPS provisioning and privilege boundaries + +`scripts/setup-vps-deploy-user.sh`, modelled on +`cdcf-infra/scripts/setup-vps-sync-user.sh` — run once as root, idempotent. + +### Two distinct identities + +| User | Role | Rights | +|---|---|---| +| `martyrology-deploy` | GitHub Actions deploy identity | owns `/opt/martyrology`; no password; no sudo except the two rules below | +| `martyrology` | systemd service account | read-only on the release tree; no login | + +The Plesk-chrooted subscription user is **not** used. A dedicated non-chrooted +user (the `cdcfinfra-deploy` pattern) can execute one command over ssh, which +removes the need for a trigger file, a systemd `.path` watcher, a root-run +oneshot parsing attacker-influenced filenames, and a status-file handshake back +to CI. It also keeps the application out of `/var/www/vhosts//`, where +Plesk may rearrange things underneath it. + +### Directory layout + +``` +/opt/martyrology/ + bin/deploy.sh installed by the setup script + incoming/ scp target + releases//{venv,data,manifest.json} + current -> releases/ +/etc/martyrology/api.env root:root 0600 +``` + +### Secrets remain unreadable to the deploy identity + +`/etc/martyrology/api.env` is `root:root 0600`. systemd reads `EnvironmentFile=` +as root before dropping privileges, so the service gets its secrets while +`martyrology-deploy` cannot read them. This mirrors step 4 of +`setup-vps-sync-user.sh`, which restores `ubuntu` ownership and mode 0600 on +`.env.production` after the recursive chown. + +### Sudoers drop-in + +`/etc/sudoers.d/martyrology-deploy`: + +``` +martyrology-deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart martyrology-api.service, \ + /usr/bin/systemctl is-active martyrology-api.service +``` + +Two exact commands. No wildcards. If the deploy key leaks, the blast radius is +"replace the application code and bounce the service" — definitionally what a +deploy key can do. It cannot read secrets, touch other units, or escalate. + +### The deploy script is not self-updating + +`/opt/martyrology/bin/deploy.sh` is versioned in this repository and installed by +the setup script. It is deliberately **not** refreshed from the bundle: updating +it is an operator-initiated action, on the same reasoning as the `sync-to-vps.yml` +header comment about keeping script changes out of the automatic path. + +## 5. Deploy script algorithm + +`bash ${APP_DIR}/bin/deploy.sh `, running as `martyrology-deploy`: + +1. Reject a `` that does not match `^v?[0-9]+(\.[0-9]+)*$`. +2. Verify the bundle against its `.sha256`; abort on mismatch. +3. Extract to a temporary directory, refusing any member with an absolute path + or a `..` component. +4. Validate `manifest.json` against the expected schema and `bundle_format`. +5. `python3.12 -m venv releases//venv`, then + `pip install --no-index --find-links wheels …`. Fully offline — a GitHub or + PyPI outage cannot break a deploy. +6. **Smoke check before committing:** start the new release on a random free + loopback port, assert `/healthz` returns 200 with the expected edition set, + then kill it. +7. Flip `current` to the new release; `sudo systemctl restart martyrology-api.service`. + systemd resolves the symlink at exec time, so a restart alone picks up the + new release. +8. Poll the live port until `/healthz` is 200. If it is not healthy within the + timeout, **flip `current` back, restart, and exit non-zero** — automatic + rollback, with a red build. +9. Prune to the five most recent releases; remove the consumed bundle from + `incoming/`. + +Steps 1–4 are the reason nothing from the payload is ever executed except a +fixed, known entrypoint, and only after its hash matches. + +## 6. systemd unit and Plesk + +`/etc/systemd/system/martyrology-api.service`: + +```ini +[Service] +User=martyrology +EnvironmentFile=/etc/martyrology/api.env +ExecStart=/opt/martyrology/current/venv/bin/uvicorn \ + martyrology_api.app:create_app --factory --host 127.0.0.1 --port ${MARTYROLOGY_PORT} +Restart=on-failure +NoNewPrivileges=true +ProtectSystem=strict +PrivateTmp=true +``` + +### Port selection + +The port must be free and stable. Plesk itself occupies 8443, 8880 and 8447; +Linux's default ephemeral range starts at 32768, so any stable port below that is +safe from ephemeral collision. **Default choice: 8412.** Verify before committing +to it: + +```bash +ss -ltnp | sort -t: -k2 -n +``` + +The port appears in exactly two places — `MARTYROLOGY_PORT` in +`/etc/martyrology/api.env`, and the nginx directive below. That coupling is +manual and must be kept in sync by hand; it is recorded in the runbook. + +### Plesk nginx directives + +Domain → Apache & nginx Settings → Additional nginx directives: + +```nginx +location / { + proxy_pass http://127.0.0.1:8412; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +Proxy-mode-to-Apache is disabled for the domain. + +WebSocket upgrade headers are deliberately omitted. The usual +`proxy_set_header Connection $connection_upgrade` depends on a `map` block, which +is only legal at `http` level — Plesk injects these directives at `server` level, +so including it would make nginx reject the configuration. The API does not stream +today; if it ever does, the `map` goes in an http-level Plesk configuration +include (`/etc/nginx/conf.d/`), not in this field. + +Plesk retains ownership of TLS and Let's Encrypt renewal, which is the actual +argument for staying inside Plesk rather than bypassing it. Nothing in Plesk +knows the application is Python, and uvicorn keeps its own event loop, so ASGI +async behaviour is fully preserved. + +## 7. Changes to this repository + +- **`GET /healthz`** (new, the only new endpoint) returning + `{status, version, data: {crmedr, clbdr, texts}, editions: [...]}`, read from + `manifest.json` via a new `MARTYROLOGY_MANIFEST_PATH` setting. The manifest is + absent in development, where the data fields degrade to `null`. Required by the + smoke check (§5.6), the rollback poll (§5.8), and by anyone asking which corpus + is live. The service document at `app.py:47` is unchanged. +- **`scripts/setup-vps-deploy-user.sh`** (new), per §4. +- **`scripts/deploy/deploy.sh`** (new) — the source of truth for what the setup + script installs at `/opt/martyrology/bin/deploy.sh`. +- **`.github/workflows/deploy.yml`** (new), per §3. +- **`.github/dependabot.yml`** — add the `gitsubmodule` ecosystem. +- **`.gitmodules`** (new) — three HTTPS submodule URLs. +- **`.env.example`** — a commented production block. +- **`docs/architecture.md`** — replace the three-option deployment list with a + pointer to this spec. + +## 8. Testing + +- Unit tests for manifest parsing and for `/healthz` degradation when the + manifest is absent. +- A CI job, triggered on pull requests touching `deploy.yml` or the bundle + assembly, that builds the bundle and asserts its tree shape and manifest schema. +- `--dry-run` support in `deploy.sh`, plus a `shellcheck` job covering both shell + scripts. + +## 9. Out of scope + +No blue/green or zero-downtime deployment — a restart behind nginx is a +sub-second gap. No database. No in-process data reload endpoint. No multi-host +deployment. No image registry. No staging environment (GitHub Actions +Environments can scope `APP_DIR` and the port per environment later, if wanted). + +## 10. One-time operator runbook + +1. `sudo apt install python3.12-venv` on the VPS. +2. Clone this repo somewhere and run `sudo bash scripts/setup-vps-deploy-user.sh`. +3. Generate the deploy keypair: `ssh-keygen -t ed25519 -C "martyrology-api deploy" -f ./deploy-key`. +4. Append the public half to `/home/martyrology-deploy/.ssh/authorized_keys`. +5. Capture the host key: `ssh-keyscan -t ed25519,rsa `. +6. Populate `/etc/martyrology/api.env` (secrets plus `MARTYROLOGY_PORT`, + `MARTYROLOGY_MANIFEST_PATH`, and the three data paths under + `/opt/martyrology/current/data/`). +7. Choose and verify the port with `ss -ltnp`; add the nginx directives in Plesk. +8. Set the repository secrets and variables listed in §3. +9. Add `martyrology-texts` to the organisation's Dependabot private-repository + allowlist. +10. Publish a release to trigger the first deploy. + +## Appendix: questions resolved during design + +| Question | Resolution | +|---|---| +| Can the deploy identity execute commands, or only scp? | Resolved by using a dedicated non-chrooted user instead of the Plesk one; commands run, so the watcher design was dropped entirely. | +| Can Dependabot read a private same-org submodule? | Yes — org-level allowlist, or a Dependabot `git` registry secret as fallback. Requires HTTPS submodule URLs. | +| Does the runner's ABI match the VPS? | Yes — both Ubuntu 24.04 / glibc 2.39, with `ubuntu-24.04` pinned explicitly. | +| Can Plesk proxy to a long-lived ASGI process? | Yes, via Additional nginx directives to a loopback port. | From 73dee5cb756e04e173009d49ea9371d126896b65 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 00:03:23 +0200 Subject: [PATCH 02/25] Add continuous deployment implementation plan Six TDD tasks: manifest reader and /healthz, submodule pinning, bundle builder, on-VPS deploy script, VPS provisioning, release workflow. Amends the spec to split the service environment into a root-only secret file and a deploy-readable runtime file. deploy.sh must read the live port to poll /healthz before deciding whether to roll back, and it must not be able to read Zitadel/OpenFGA credentials to do so. Co-Authored-By: Claude Opus 5 (1M context) --- .../plans/2026-08-01-continuous-deployment.md | 1409 +++++++++++++++++ ...2026-08-01-continuous-deployment-design.md | 39 +- 2 files changed, 1436 insertions(+), 12 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-01-continuous-deployment.md diff --git a/docs/superpowers/plans/2026-08-01-continuous-deployment.md b/docs/superpowers/plans/2026-08-01-continuous-deployment.md new file mode 100644 index 0000000..664f731 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-continuous-deployment.md @@ -0,0 +1,1409 @@ +# Continuous Deployment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `martyrology-api` to the Plesk-managed VPS automatically on every published GitHub release, bundling the private text corpus and the two public registries into one verifiable, rollback-able artifact. + +**Architecture:** Three data repositories are pinned as git submodules. On release, CI builds a wheel plus an offline wheelhouse, assembles them with the data trees and a `manifest.json` into a tarball, scp's it to the VPS, and runs a deploy script over ssh as a dedicated non-chrooted user. The script verifies the checksum, builds a venv offline, smoke-checks the new release on a scratch port, flips a `current` symlink, restarts systemd, and rolls back automatically if the live health check fails. + +**Tech Stack:** Python 3.12, FastAPI, uvicorn, pydantic v2, hatchling, uv, pytest, GitHub Actions, systemd, bash, nginx (via Plesk). + +**Spec:** `docs/superpowers/specs/2026-08-01-continuous-deployment-design.md` + +## Global Constraints + +- Python floor is `>=3.12`; ruff `target-version = "py312"`, `line-length = 100`, lint select `["E", "F", "W", "I", "UP", "B"]`. +- Coverage gate is `fail_under = 90` over `source = ["martyrology_api"]`. Code under `scripts/` is linted by ruff but not counted for coverage and not checked by pyright (`include = ["src", "tests"]`). +- CI runs `ruff check src tests scripts` and `ruff format --check src tests scripts` — every new Python file under those roots must be ruff-clean and ruff-formatted. +- All `.gitmodules` URLs must be **HTTPS**, never `git@github.com:`. `actions/checkout` authenticates submodules via an HTTP extraheader; an SSH URL breaks both the release workflow and Dependabot. +- The runner is pinned to `ubuntu-24.04`, never `ubuntu-latest`. The VPS is Ubuntu 24.04.4 / glibc 2.39, and the wheelhouse ABI must match. +- Bundle artifact name: `martyrology--linux-x86_64-cp312.tar.gz`. +- `bundle_format` is `1`. +- `APP_DIR` on the VPS is `/opt/martyrology`. Default service port is `8412`. +- Third-party GitHub Actions are pinned to full commit SHAs (established by commit `f253b38`). +- Commits are GPG-signed (`git commit -S`). Never bypass signing. + +--- + +## File Structure + +**Created:** + +| Path | Responsibility | +|---|---| +| `src/martyrology_api/manifest.py` | Parse and validate a deployment manifest; return `None` for any unusable manifest. | +| `tests/test_manifest.py` | Unit tests for manifest parsing. | +| `tests/test_health_api.py` | Endpoint tests for `/healthz`. | +| `scripts/deploy/build_bundle.py` | Assemble the release bundle and write `manifest.json`. | +| `tests/test_build_bundle.py` | Unit tests for bundle assembly, including a writer/reader cross-check. | +| `scripts/deploy/deploy.sh` | On-VPS installer: verify, extract, venv, smoke-check, flip, restart, roll back. | +| `tests/test_deploy_script.py` | Subprocess tests for `deploy.sh` rejection paths and `--dry-run`. | +| `scripts/deploy/setup-vps-deploy-user.sh` | One-time root provisioning of users, dirs, sudoers, units, runtime.env. | +| `.github/workflows/deploy.yml` | Release → build → scp → ssh deploy. | +| `.gitmodules` | Three HTTPS submodule pins. | + +**Modified:** + +| Path | Change | +|---|---| +| `src/martyrology_api/config.py` | Add `manifest_path` setting and `manifest_file` property. | +| `src/martyrology_api/models.py` | Add `HealthOut`. | +| `src/martyrology_api/app.py` | Add the `/healthz` route beside the service document at `app.py:47`. | +| `.github/dependabot.yml` | Add the `gitsubmodule` ecosystem. | +| `.github/workflows/ci.yml` | Add a `shellcheck` job. | +| `.env.example` | Add a commented production block. | +| `docs/architecture.md` | Replace the three-option deployment list with a pointer to the spec. | + +--- + +### Task 1: Manifest reader and the `/healthz` endpoint + +The only runtime code change. `/healthz` is consumed by the deploy script's +pre-flip smoke check and post-restart rollback poll, so it must answer even when +the manifest is missing or corrupt — a health endpoint that 500s on a bad +manifest would turn a cosmetic problem into a failed deploy and a rollback. + +**Files:** +- Create: `src/martyrology_api/manifest.py` +- Create: `tests/test_manifest.py` +- Create: `tests/test_health_api.py` +- Modify: `src/martyrology_api/config.py` +- Modify: `src/martyrology_api/models.py` +- Modify: `src/martyrology_api/app.py:47` + +**Interfaces:** +- Consumes: `Settings` (`src/martyrology_api/config.py`), `Store.available()` returning `set[str]` (`src/martyrology_api/store.py:134`), `__version__` (`src/martyrology_api/__init__.py`). +- Produces: + - `martyrology_api.manifest.BUNDLE_FORMAT: int` (value `1`) + - `martyrology_api.manifest.Manifest` — pydantic model with fields `bundle_format: int`, `api_version: str`, `api_commit: str`, `data: dict[str, str]`, `python_requires: str`, `files: dict[str, str]` + - `martyrology_api.manifest.load_manifest(path: Path | None) -> Manifest | None` + - `Settings.manifest_path: str` and `Settings.manifest_file -> Path | None` + - `GET /healthz` returning `{"status": "ok", "version": str, "data": {"crmedr": str|None, "clbdr": str|None, "texts": str|None}, "editions": list[str]}` + - Task 3 validates its generated manifest against `Manifest`. + +- [ ] **Step 1: Write the failing manifest tests** + +Create `tests/test_manifest.py`: + +```python +import json +from pathlib import Path + +from martyrology_api.manifest import load_manifest + +GOOD = { + "bundle_format": 1, + "api_version": "0.1.0", + "api_commit": "a" * 40, + "data": {"texts": "t" * 40, "crmedr": "c" * 40, "clbdr": "l" * 40}, + "python_requires": ">=3.12", + "files": {"data/crmedr/x.json": "0" * 64}, +} + + +def _write(tmp_path: Path, payload: object) -> Path: + path = tmp_path / "manifest.json" + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +def test_none_path_yields_none(): + assert load_manifest(None) is None + + +def test_missing_file_yields_none(tmp_path: Path): + assert load_manifest(tmp_path / "absent.json") is None + + +def test_malformed_json_yields_none(tmp_path: Path): + path = tmp_path / "manifest.json" + path.write_text("{ not json", encoding="utf-8") + assert load_manifest(path) is None + + +def test_missing_required_field_yields_none(tmp_path: Path): + payload = {k: v for k, v in GOOD.items() if k != "api_commit"} + assert load_manifest(_write(tmp_path, payload)) is None + + +def test_unknown_bundle_format_yields_none(tmp_path: Path): + assert load_manifest(_write(tmp_path, {**GOOD, "bundle_format": 99})) is None + + +def test_good_manifest_parses(tmp_path: Path): + manifest = load_manifest(_write(tmp_path, GOOD)) + assert manifest is not None + assert manifest.api_commit == "a" * 40 + assert manifest.data["texts"] == "t" * 40 + assert manifest.files["data/crmedr/x.json"] == "0" * 64 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_manifest.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'martyrology_api.manifest'` + +- [ ] **Step 3: Implement the manifest module** + +Create `src/martyrology_api/manifest.py`: + +```python +import json +from pathlib import Path + +from pydantic import BaseModel, ValidationError + +BUNDLE_FORMAT = 1 + + +class Manifest(BaseModel): + """The deployment manifest written into every release bundle. + + `data` maps a data-repository nickname (texts, crmedr, clbdr) to the + commit SHA that was bundled; `files` maps every bundled path to its + sha256. Together they are the auditable record of which corpus is live. + """ + + bundle_format: int + api_version: str + api_commit: str + data: dict[str, str] + python_requires: str + files: dict[str, str] + + +def load_manifest(path: Path | None) -> Manifest | None: + """Read a deployment manifest, or None when it is absent or unusable. + + Absence is the ordinary development case: no bundle, no manifest. A + malformed manifest, or one written by a future bundle format, is also + reported as absent rather than raised. /healthz is what the deploy + script polls to decide whether to roll back, so it must keep answering + even when the manifest is the thing that is broken. + """ + if path is None: + return None + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + try: + manifest = Manifest.model_validate(raw) + except ValidationError: + return None + if manifest.bundle_format != BUNDLE_FORMAT: + return None + return manifest +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_manifest.py -v` +Expected: PASS (6 passed) + +- [ ] **Step 5: Write the failing `/healthz` tests** + +Create `tests/test_health_api.py`: + +```python +import json +from pathlib import Path + +MANIFEST = { + "bundle_format": 1, + "api_version": "0.1.0", + "api_commit": "a" * 40, + "data": {"texts": "t" * 40, "crmedr": "c" * 40, "clbdr": "l" * 40}, + "python_requires": ">=3.12", + "files": {}, +} + + +def test_healthz_ok_without_a_manifest(make_client): + body = make_client().get("/healthz").json() + assert body["status"] == "ok" + assert body["version"] + assert body["data"] == {"crmedr": None, "clbdr": None, "texts": None} + + +def test_healthz_lists_available_editions_sorted(make_client): + body = make_client().get("/healthz").json() + assert body["editions"], "fixtures should expose at least one edition" + assert body["editions"] == sorted(body["editions"]) + + +def test_healthz_reports_commits_from_the_manifest(make_client, tmp_path: Path): + path = tmp_path / "manifest.json" + path.write_text(json.dumps(MANIFEST), encoding="utf-8") + body = make_client(manifest_path=str(path)).get("/healthz").json() + assert body["data"] == {"crmedr": "c" * 40, "clbdr": "l" * 40, "texts": "t" * 40} + + +def test_healthz_survives_a_corrupt_manifest(make_client, tmp_path: Path): + path = tmp_path / "manifest.json" + path.write_text("{ not json", encoding="utf-8") + response = make_client(manifest_path=str(path)).get("/healthz") + assert response.status_code == 200 + assert response.json()["data"] == {"crmedr": None, "clbdr": None, "texts": None} +``` + +- [ ] **Step 6: Run the tests to verify they fail** + +Run: `pytest tests/test_health_api.py -v` +Expected: FAIL — 404 on `/healthz`, and `Settings` rejects the `manifest_path` keyword. + +- [ ] **Step 7: Add the setting** + +In `src/martyrology_api/config.py`, add after the `access_info_url` field (line 18): + +```python + manifest_path: str = "" # deployment manifest.json; empty outside a bundle +``` + +and add this property beside the other properties: + +```python + @property + def manifest_file(self) -> Path | None: + return Path(self.manifest_path) if self.manifest_path else None +``` + +`Path` is already imported at `config.py:2`. + +- [ ] **Step 8: Add the response model** + +In `src/martyrology_api/models.py`, add beside the other `*Out` models: + +```python +class HealthOut(BaseModel): + status: Literal["ok"] + version: str + data: dict[str, str | None] + editions: list[str] +``` + +`Literal` is already imported at `models.py:1`. + +- [ ] **Step 9: Add the endpoint** + +In `src/martyrology_api/app.py`, extend the imports: + +```python +from .manifest import load_manifest +from .models import HealthOut +``` + +and add this route immediately after the `service_document` function (which ends at `app.py:61`), before `return app`: + +```python + @app.get("/healthz", tags=["service"], response_model=HealthOut) + def healthz() -> HealthOut: + manifest = load_manifest(settings.manifest_file) + commits: dict[str, str | None] = {"crmedr": None, "clbdr": None, "texts": None} + if manifest is not None: + for key in commits: + commits[key] = manifest.data.get(key) + return HealthOut( + status="ok", + version=__version__, + data=commits, + editions=sorted(app.state.store.available()), + ) +``` + +- [ ] **Step 10: Run the full suite** + +Run: `pytest -q --cov --cov-branch --cov-report=term-missing` +Expected: PASS, coverage still at or above 90. `tests/test_openapi.py` has no +schema snapshot, so the new route needs no fixture update; its +`test_openapi_every_route_declares_responses` loop covers `/healthz` automatically. + +- [ ] **Step 11: Lint and typecheck** + +Run: `ruff check src tests scripts && ruff format --check src tests scripts && pyright` +Expected: all clean. + +- [ ] **Step 12: Commit** + +```bash +git add src/martyrology_api/manifest.py src/martyrology_api/config.py \ + src/martyrology_api/models.py src/martyrology_api/app.py \ + tests/test_manifest.py tests/test_health_api.py +git commit -S -m "Add deployment manifest reader and /healthz endpoint" +``` + +--- + +### Task 2: Pin the data repositories as submodules + +**Files:** +- Create: `.gitmodules` +- Modify: `.github/dependabot.yml` +- Modify: `.env.example` +- Modify: `docs/architecture.md` + +**Interfaces:** +- Produces: `vendor/crmedr`, `vendor/clbdr`, `vendor/texts` — the paths Task 3's bundle builder reads and derives commit SHAs from. + +- [ ] **Step 1: Add the three submodules** + +```bash +git submodule add https://github.com/CatholicOS/crmedr.git vendor/crmedr +git submodule add https://github.com/CatholicOS/clbdr.git vendor/clbdr +git submodule add https://github.com/CatholicOS/martyrology-texts.git vendor/texts +``` + +- [ ] **Step 2: Verify the URLs are HTTPS** + +Run: `grep url .gitmodules` +Expected: three `https://github.com/CatholicOS/...` lines and **no** `git@github.com:` line. An SSH URL here breaks both `actions/checkout` and Dependabot; fix it with `git config -f .gitmodules submodule..url https://...` and re-run `git submodule sync` if any appear. + +- [ ] **Step 3: Verify all three initialise** + +Run: `git submodule status` +Expected: three lines, each with a commit SHA and no leading `-` (which would mean uninitialised). + +- [ ] **Step 4: Add the Dependabot ecosystem** + +Append to the `updates:` list in `.github/dependabot.yml`: + +```yaml + - package-ecosystem: "gitsubmodule" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + cooldown: + default-days: 7 + labels: + - "dependencies" + - "data" +``` + +The private `vendor/texts` submodule additionally needs `martyrology-texts` added +to the organisation's *Grant Dependabot access to private repositories* allowlist +(Organization Settings → Code security). If that setting is unavailable, use the +`registries:` fallback documented in spec §1. + +- [ ] **Step 5: Add the production block to `.env.example`** + +Append: + +```bash +# --- Production (VPS) --------------------------------------------------- +# Written to /opt/martyrology/config/runtime.env by setup-vps-deploy-user.sh. +# All paths route through the `current` symlink, so they never change between +# releases. Secrets live in /etc/martyrology/api.env (root:root 0600) instead. +# MARTYROLOGY_PORT=8412 +# MARTYROLOGY_MANIFEST_PATH=/opt/martyrology/current/manifest.json +# MARTYROLOGY_DATA_PATH=/opt/martyrology/current/data/editions:/opt/martyrology/current/data/texts +# MARTYROLOGY_CRMEDR_PATH=/opt/martyrology/current/data/crmedr +# MARTYROLOGY_CLBDR_PATH=/opt/martyrology/current/data/clbdr +``` + +- [ ] **Step 6: Repoint the architecture doc** + +In `docs/architecture.md`, replace the numbered three-option deployment list +(the "Deployment options, in order of preference" block) with: + +```markdown +The deployment architecture is specified in +[docs/superpowers/specs/2026-08-01-continuous-deployment-design.md](superpowers/specs/2026-08-01-continuous-deployment-design.md): +the three data trees are pinned as git submodules under `vendor/`, assembled by +CI into a release bundle with a manifest, and installed on the VPS by a deploy +script that smoke-checks before activating and rolls back on failure. +``` + +- [ ] **Step 7: Confirm the test suite is unaffected** + +Run: `pytest -q` +Expected: PASS. Submodules under `vendor/` are not on any configured data path +(`testpaths = ["tests"]`, fixtures under `tests/fixtures`), so nothing changes. + +- [ ] **Step 8: Commit** + +```bash +git add .gitmodules vendor .github/dependabot.yml .env.example docs/architecture.md +git commit -S -m "Pin crmedr, clbdr and martyrology-texts as submodules" +``` + +--- + +### Task 3: Bundle builder + +**Files:** +- Create: `scripts/deploy/build_bundle.py` +- Create: `tests/test_build_bundle.py` + +**Interfaces:** +- Consumes: `martyrology_api.manifest.Manifest` and `BUNDLE_FORMAT` from Task 1; the `vendor/*` submodules from Task 2. +- Produces: + - `sha256_file(path: Path) -> str` + - `hash_tree(root: Path) -> dict[str, str]` + - `build_manifest(staging: Path, api_version: str, api_commit: str, data_commits: dict[str, str]) -> dict` + - `git_commit(repo: Path) -> str` + - `assemble(staging: Path, out_dir: Path, version: str) -> Path` returning the tarball path + - CLI: `python scripts/deploy/build_bundle.py --version --staging --out `, used by Task 6's workflow. + +Spec §8 asks for a CI job, gated on pull requests touching the bundle assembly, +that builds a bundle and asserts its tree shape and manifest schema. These tests +satisfy that requirement more broadly: they live in `pytest`, which already runs +on every pull request, so the check cannot be skipped by a path filter that +someone forgets to update. No separate job is added. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_build_bundle.py`: + +```python +import importlib.util +import json +import tarfile +from pathlib import Path + +from martyrology_api import manifest as runtime_manifest +from martyrology_api.manifest import Manifest + +_PATH = Path(__file__).resolve().parents[1] / "scripts" / "deploy" / "build_bundle.py" +_spec = importlib.util.spec_from_file_location("build_bundle", _PATH) +assert _spec is not None and _spec.loader is not None +build_bundle = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(build_bundle) + +COMMITS = {"texts": "t" * 40, "crmedr": "c" * 40, "clbdr": "l" * 40} + + +def _staging(tmp_path: Path) -> Path: + root = tmp_path / "staging" + (root / "data" / "crmedr").mkdir(parents=True) + (root / "wheels").mkdir() + (root / "data" / "crmedr" / "ids.json").write_text("{}", encoding="utf-8") + (root / "wheels" / "fake.whl").write_bytes(b"PK\x03\x04") + return root + + +def test_sha256_file_matches_known_digest(tmp_path: Path): + target = tmp_path / "f.txt" + target.write_bytes(b"abc") + assert build_bundle.sha256_file(target) == ( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ) + + +def test_hash_tree_uses_sorted_posix_relative_keys(tmp_path: Path): + root = _staging(tmp_path) + tree = build_bundle.hash_tree(root) + assert list(tree) == sorted(tree) + assert "data/crmedr/ids.json" in tree + assert "wheels/fake.whl" in tree + assert not any(key.startswith("/") for key in tree) + + +def test_build_manifest_records_format_and_commits(tmp_path: Path): + manifest = build_bundle.build_manifest(_staging(tmp_path), "0.1.0", "a" * 40, COMMITS) + assert manifest["bundle_format"] == 1 + assert manifest["api_commit"] == "a" * 40 + assert manifest["data"] == COMMITS + + +def test_build_manifest_validates_against_the_runtime_model(tmp_path: Path): + """The writer and the reader must agree; this is the contract between + scripts/deploy/build_bundle.py and src/martyrology_api/manifest.py.""" + manifest = build_bundle.build_manifest(_staging(tmp_path), "0.1.0", "a" * 40, COMMITS) + parsed = Manifest.model_validate(manifest) + assert parsed.api_version == "0.1.0" + + +def test_bundle_format_constants_agree(): + """BUNDLE_FORMAT is declared in both modules. If they ever drift, the + deploy script's manifest check rejects every bundle CI produces, so + pin them together here rather than discovering it on the VPS.""" + assert build_bundle.BUNDLE_FORMAT == runtime_manifest.BUNDLE_FORMAT + + +def test_assemble_writes_a_tarball_with_a_manifest_at_the_root(tmp_path: Path): + root = _staging(tmp_path) + out = tmp_path / "out" + out.mkdir() + tarball = build_bundle.assemble(root, out, "1.2.3") + assert tarball.name == "martyrology-1.2.3-linux-x86_64-cp312.tar.gz" + with tarfile.open(tarball) as archive: + names = archive.getnames() + assert "manifest.json" in names + assert "data/crmedr/ids.json" in names + + +def test_assemble_manifest_does_not_hash_itself(tmp_path: Path): + root = _staging(tmp_path) + build_bundle.write_manifest(root, "0.1.0", "a" * 40, COMMITS) + manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8")) + assert "manifest.json" not in manifest["files"] +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_build_bundle.py -v` +Expected: FAIL — `FileNotFoundError` for `scripts/deploy/build_bundle.py`. + +- [ ] **Step 3: Implement the builder** + +Create `scripts/deploy/build_bundle.py`: + +```python +#!/usr/bin/env python3 +"""Assemble a martyrology-api release bundle. + +Takes a staging directory already populated with `wheels/` and `data/`, +writes `manifest.json` into it, and tars the result. Run by +.github/workflows/deploy.yml; the manifest it writes is read at runtime by +src/martyrology_api/manifest.py, and tests/test_build_bundle.py asserts the +two agree. +""" + +import argparse +import hashlib +import json +import subprocess +import tarfile +from pathlib import Path + +BUNDLE_FORMAT = 1 +PYTHON_REQUIRES = ">=3.12" +BUNDLE_NAME = "martyrology-{version}-linux-x86_64-cp312.tar.gz" + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def hash_tree(root: Path) -> dict[str, str]: + """sha256 of every regular file under root, keyed by POSIX-style relative + path and sorted so the manifest is byte-stable across runs.""" + return { + path.relative_to(root).as_posix(): sha256_file(path) + for path in sorted(root.rglob("*")) + if path.is_file() + } + + +def git_commit(repo: Path) -> str: + result = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def build_manifest( + staging: Path, api_version: str, api_commit: str, data_commits: dict[str, str] +) -> dict: + return { + "bundle_format": BUNDLE_FORMAT, + "api_version": api_version, + "api_commit": api_commit, + "data": data_commits, + "python_requires": PYTHON_REQUIRES, + "files": hash_tree(staging), + } + + +def write_manifest( + staging: Path, api_version: str, api_commit: str, data_commits: dict[str, str] +) -> Path: + """Hash the staged tree, then write the manifest into it. Order matters: + the manifest cannot contain its own digest, so it is built first.""" + manifest = build_manifest(staging, api_version, api_commit, data_commits) + path = staging / "manifest.json" + path.write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8") + return path + + +def assemble(staging: Path, out_dir: Path, version: str) -> Path: + tarball = out_dir / BUNDLE_NAME.format(version=version) + with tarfile.open(tarball, "w:gz") as archive: + for path in sorted(staging.rglob("*")): + archive.add(path, arcname=path.relative_to(staging).as_posix()) + return tarball + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", required=True) + parser.add_argument("--staging", required=True, type=Path) + parser.add_argument("--out", required=True, type=Path) + parser.add_argument("--api-version", required=True) + parser.add_argument("--repo-root", default=Path("."), type=Path) + args = parser.parse_args() + + data_commits = { + "texts": git_commit(args.repo_root / "vendor" / "texts"), + "crmedr": git_commit(args.repo_root / "vendor" / "crmedr"), + "clbdr": git_commit(args.repo_root / "vendor" / "clbdr"), + } + write_manifest(args.staging, args.api_version, git_commit(args.repo_root), data_commits) + tarball = assemble(args.staging, args.out, args.version) + print(tarball) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_build_bundle.py -v` +Expected: PASS (7 passed) + +- [ ] **Step 5: Lint** + +Run: `ruff check src tests scripts && ruff format --check src tests scripts` +Expected: clean. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/deploy/build_bundle.py tests/test_build_bundle.py +git commit -S -m "Add release bundle builder" +``` + +--- + +### Task 4: On-VPS deploy script + +**Files:** +- Create: `scripts/deploy/deploy.sh` +- Create: `tests/test_deploy_script.py` + +**Interfaces:** +- Consumes: the bundle naming and layout produced by Task 3; `/healthz` from Task 1. +- Produces: `bash deploy.sh [--dry-run] `, invoked over ssh by Task 6 and installed to `/opt/martyrology/bin/deploy.sh` by Task 5. Honours `APP_DIR` (default `/opt/martyrology`). + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_deploy_script.py`: + +```python +import hashlib +import subprocess +import tarfile +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "deploy" / "deploy.sh" + + +def _app_dir(tmp_path: Path) -> Path: + app = tmp_path / "app" + (app / "incoming").mkdir(parents=True) + (app / "releases").mkdir() + return app + + +def _bundle(app: Path, version: str, *, arcname: str = "manifest.json") -> Path: + payload = app / "incoming" / f"martyrology-{version}-linux-x86_64-cp312.tar.gz" + source = app / "manifest.json" + source.write_text("{}", encoding="utf-8") + with tarfile.open(payload, "w:gz") as archive: + archive.add(source, arcname=arcname) + source.unlink() + digest = hashlib.sha256(payload.read_bytes()).hexdigest() + (payload.parent / f"{payload.name}.sha256").write_text( + f"{digest} {payload.name}\n", encoding="utf-8" + ) + return payload + + +def _run(app: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", str(SCRIPT), *args], + env={"PATH": "/usr/bin:/bin", "APP_DIR": str(app)}, + capture_output=True, + text=True, + ) + + +def test_rejects_a_missing_version(tmp_path: Path): + result = _run(_app_dir(tmp_path)) + assert result.returncode != 0 + assert "usage" in result.stderr.lower() + + +def test_rejects_a_shell_metacharacter_version(tmp_path: Path): + result = _run(_app_dir(tmp_path), "--dry-run", "1.0.0; rm -rf /") + assert result.returncode != 0 + assert "suspicious version" in result.stderr + + +def test_rejects_a_missing_bundle(tmp_path: Path): + result = _run(_app_dir(tmp_path), "--dry-run", "9.9.9") + assert result.returncode != 0 + assert "bundle not found" in result.stderr + + +def test_rejects_a_checksum_mismatch(tmp_path: Path): + app = _app_dir(tmp_path) + bundle = _bundle(app, "1.0.0") + bundle.write_bytes(bundle.read_bytes() + b"tampered") + result = _run(app, "--dry-run", "1.0.0") + assert result.returncode != 0 + assert "checksum mismatch" in result.stderr + + +def test_rejects_a_path_traversal_member(tmp_path: Path): + app = _app_dir(tmp_path) + _bundle(app, "1.0.0", arcname="../escape.json") + result = _run(app, "--dry-run", "1.0.0") + assert result.returncode != 0 + assert "absolute or parent-relative paths" in result.stderr + + +def test_dry_run_accepts_a_good_bundle(tmp_path: Path): + app = _app_dir(tmp_path) + _bundle(app, "1.0.0") + result = _run(app, "--dry-run", "1.0.0") + assert result.returncode == 0, result.stderr + assert "dry-run" in result.stdout + assert not (app / "releases" / "1.0.0").exists() +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_deploy_script.py -v` +Expected: FAIL — the script does not exist, so bash exits 127. + +- [ ] **Step 3: Implement the script** + +Create `scripts/deploy/deploy.sh`: + +```bash +#!/usr/bin/env bash +# +# deploy.sh — install and activate a martyrology-api release bundle. +# +# Runs on the VPS as the martyrology-deploy user, invoked over ssh by +# .github/workflows/deploy.yml. Installed at $APP_DIR/bin/deploy.sh by +# scripts/deploy/setup-vps-deploy-user.sh; deliberately NOT refreshed from +# the bundle, so updating it stays an operator action. +# +# Nothing from the payload is ever executed. The bundle is checksum-verified +# and screened for path traversal before a single byte is extracted. + +set -euo pipefail + +APP_DIR="${APP_DIR:-/opt/martyrology}" +SERVICE="martyrology-api.service" +RUNTIME_ENV="$APP_DIR/config/runtime.env" +KEEP_RELEASES=5 +HEALTH_TIMEOUT=30 + +die() { + echo "ERROR: $*" >&2 + exit 1 +} + +DRY_RUN=0 +if [ "${1:-}" = "--dry-run" ]; then + DRY_RUN=1 + shift +fi + +VERSION="${1:-}" +[ -n "$VERSION" ] || die "usage: deploy.sh [--dry-run] " + +# Anchored, no metacharacters: the version becomes part of a path and of a +# filename, so anything outside this shape is refused outright. +[[ "$VERSION" =~ ^v?[0-9]+(\.[0-9]+)*$ ]] || die "refusing suspicious version string: $VERSION" + +BUNDLE="$APP_DIR/incoming/martyrology-${VERSION}-linux-x86_64-cp312.tar.gz" +[ -f "$BUNDLE" ] || die "bundle not found: $BUNDLE" +[ -f "$BUNDLE.sha256" ] || die "checksum not found: $BUNDLE.sha256" + +(cd "$(dirname "$BUNDLE")" && sha256sum -c "$(basename "$BUNDLE").sha256" >/dev/null 2>&1) \ + || die "checksum mismatch for $BUNDLE" + +if tar -tzf "$BUNDLE" | grep -Eq '^/|(^|/)\.\.(/|$)'; then + die "bundle contains absolute or parent-relative paths" +fi + +RELEASE="$APP_DIR/releases/$VERSION" + +if [ "$DRY_RUN" -eq 1 ]; then + echo "dry-run: $BUNDLE verified; would install to $RELEASE" + exit 0 +fi + +echo "Installing $VERSION to $RELEASE" +rm -rf "$RELEASE" +mkdir -p "$RELEASE" +tar -xzf "$BUNDLE" -C "$RELEASE" + +echo "Building venv (offline)" +python3.12 -m venv "$RELEASE/venv" +"$RELEASE/venv/bin/pip" install --quiet --upgrade pip +"$RELEASE/venv/bin/pip" install --quiet --no-index \ + --find-links "$RELEASE/wheels" martyrology-api + +# Validate the manifest with the reader the app itself uses, so a bundle whose +# manifest this release cannot parse is rejected before it is ever activated. +"$RELEASE/venv/bin/python" - "$RELEASE/manifest.json" <<'PY' || die "manifest validation failed" +import sys +from pathlib import Path + +from martyrology_api.manifest import load_manifest + +if load_manifest(Path(sys.argv[1])) is None: + sys.exit("manifest.json is absent, malformed, or an unsupported bundle_format") +PY + +wait_healthy() { + local port="$1" + local deadline=$((SECONDS + HEALTH_TIMEOUT)) + while [ "$SECONDS" -lt "$deadline" ]; do + if curl -fsS "http://127.0.0.1:${port}/healthz" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + return 1 +} + +echo "Smoke-checking the new release before activating it" +SMOKE_PORT="$("$RELEASE/venv/bin/python" -c \ + 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')" + +MARTYROLOGY_MANIFEST_PATH="$RELEASE/manifest.json" \ +MARTYROLOGY_DATA_PATH="$RELEASE/data/editions:$RELEASE/data/texts" \ +MARTYROLOGY_CRMEDR_PATH="$RELEASE/data/crmedr" \ +MARTYROLOGY_CLBDR_PATH="$RELEASE/data/clbdr" \ + "$RELEASE/venv/bin/uvicorn" martyrology_api.app:create_app --factory \ + --host 127.0.0.1 --port "$SMOKE_PORT" >"$RELEASE/smoke.log" 2>&1 & +SMOKE_PID=$! +trap 'kill "$SMOKE_PID" 2>/dev/null || true' EXIT + +if ! wait_healthy "$SMOKE_PORT"; then + kill "$SMOKE_PID" 2>/dev/null || true + cat "$RELEASE/smoke.log" >&2 + die "smoke check failed; $VERSION was not activated" +fi + +EDITIONS="$(curl -fsS "http://127.0.0.1:${SMOKE_PORT}/healthz" \ + | "$RELEASE/venv/bin/python" -c 'import json,sys; print(len(json.load(sys.stdin)["editions"]))')" +[ "$EDITIONS" -gt 0 ] || die "smoke check served zero editions; $VERSION was not activated" +echo "Smoke check passed: $EDITIONS editions" + +kill "$SMOKE_PID" 2>/dev/null || true +trap - EXIT + +PREVIOUS="" +if [ -L "$APP_DIR/current" ]; then + PREVIOUS="$(readlink "$APP_DIR/current")" +fi + +echo "Activating $VERSION" +ln -sfn "$RELEASE" "$APP_DIR/current.new" +mv -Tf "$APP_DIR/current.new" "$APP_DIR/current" +sudo /usr/bin/systemctl restart "$SERVICE" + +# shellcheck source=/dev/null +LIVE_PORT="$(. "$RUNTIME_ENV" && echo "$MARTYROLOGY_PORT")" + +if ! wait_healthy "$LIVE_PORT"; then + echo "ERROR: $VERSION is unhealthy on port $LIVE_PORT; rolling back" >&2 + if [ -n "$PREVIOUS" ]; then + ln -sfn "$PREVIOUS" "$APP_DIR/current.new" + mv -Tf "$APP_DIR/current.new" "$APP_DIR/current" + sudo /usr/bin/systemctl restart "$SERVICE" + echo "Rolled back to $PREVIOUS" >&2 + else + echo "No previous release to roll back to" >&2 + fi + exit 1 +fi + +echo "$VERSION is live and healthy on port $LIVE_PORT" + +rm -f "$BUNDLE" "$BUNDLE.sha256" +CURRENT_TARGET="$(readlink "$APP_DIR/current")" +# shellcheck disable=SC2012 +ls -1dt "$APP_DIR"/releases/*/ 2>/dev/null | tail -n "+$((KEEP_RELEASES + 1))" | while read -r old; do + [ "${old%/}" = "$CURRENT_TARGET" ] && continue + echo "Pruning ${old%/}" + rm -rf "${old%/}" +done +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_deploy_script.py -v` +Expected: PASS (6 passed). Only the pre-extraction rejection paths and +`--dry-run` are exercised; venv creation, systemd and sudo are not reachable in CI. + +- [ ] **Step 5: Shellcheck the script** + +Run: `shellcheck scripts/deploy/deploy.sh` +Expected: clean. Install with `sudo apt install shellcheck` if absent. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/deploy/deploy.sh tests/test_deploy_script.py +git commit -S -m "Add on-VPS deploy script with smoke check and rollback" +``` + +--- + +### Task 5: VPS provisioning script + +**Files:** +- Create: `scripts/deploy/setup-vps-deploy-user.sh` + +**Interfaces:** +- Consumes: `scripts/deploy/deploy.sh` from Task 4 (installs it to `$APP_DIR/bin/`). +- Produces: the `martyrology-deploy` and `martyrology` accounts, `/opt/martyrology` with `config/runtime.env`, `/etc/martyrology/api.env`, `/etc/sudoers.d/martyrology-deploy`, and `martyrology-api.service` — the environment Task 6's workflow deploys into. + +- [ ] **Step 1: Write the script** + +Create `scripts/deploy/setup-vps-deploy-user.sh`: + +```bash +#!/usr/bin/env bash +# +# setup-vps-deploy-user.sh — provision the VPS for martyrology-api deploys. +# +# Run ONCE on the VPS as root. Idempotent: re-runs are safe. +# +# Creates two identities with different jobs: +# martyrology-deploy — the GitHub Actions identity. Owns $APP_DIR, has no +# password and no sudo beyond two exact systemctl commands. +# martyrology — the service account the unit runs as. Read-only on the +# release tree, no login. +# +# Secrets live in /etc/martyrology/api.env (root:root 0600), which the deploy +# identity cannot read; systemd loads it as root before dropping privileges. +# Non-secret settings live in $APP_DIR/config/runtime.env, which the deploy +# script reads to learn the live port. + +set -euo pipefail + +DEPLOY_USER="martyrology-deploy" +SERVICE_USER="martyrology" +APP_DIR="/opt/martyrology" +SECRET_ENV="/etc/martyrology/api.env" +RUNTIME_ENV="$APP_DIR/config/runtime.env" +PORT="${MARTYROLOGY_PORT:-8412}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +[ "$(id -u)" -eq 0 ] || { echo "ERROR: run as root (try: sudo $0)" >&2; exit 1; } +[ -f "$SCRIPT_DIR/deploy.sh" ] || { echo "ERROR: deploy.sh not beside this script" >&2; exit 1; } + +for user in "$DEPLOY_USER" "$SERVICE_USER"; do + if ! id -u "$user" >/dev/null 2>&1; then + echo "Creating user: $user" + useradd --create-home --shell /bin/bash "$user" + passwd --lock "$user" >/dev/null + else + echo "User already exists: $user" + fi +done + +SSH_DIR="/home/$DEPLOY_USER/.ssh" +mkdir -p "$SSH_DIR" +touch "$SSH_DIR/authorized_keys" +chmod 700 "$SSH_DIR" +chmod 600 "$SSH_DIR/authorized_keys" +chown -R "$DEPLOY_USER:$DEPLOY_USER" "$SSH_DIR" + +mkdir -p "$APP_DIR"/{bin,config,incoming,releases} +chown -R "$DEPLOY_USER:$DEPLOY_USER" "$APP_DIR" +chmod 755 "$APP_DIR" + +install -o "$DEPLOY_USER" -g "$DEPLOY_USER" -m 755 "$SCRIPT_DIR/deploy.sh" "$APP_DIR/bin/deploy.sh" + +if [ ! -f "$RUNTIME_ENV" ]; then + echo "Writing $RUNTIME_ENV" + cat >"$RUNTIME_ENV" <"$SECRET_ENV" <<'EOF' +MARTYROLOGY_ZITADEL_ISSUER= +MARTYROLOGY_ZITADEL_CLIENT_ID= +MARTYROLOGY_ZITADEL_CLIENT_SECRET= +MARTYROLOGY_OPENFGA_API_URL= +MARTYROLOGY_OPENFGA_STORE_ID= +MARTYROLOGY_OPENFGA_MODEL_ID= +MARTYROLOGY_GITHUB_TOKEN= +EOF +else + echo "Keeping existing $SECRET_ENV" +fi +chown root:root "$SECRET_ENV" +chmod 600 "$SECRET_ENV" + +# Validate before installing: a malformed sudoers file breaks sudo host-wide. +SUDOERS_TMP="$(mktemp)" +cat >"$SUDOERS_TMP" </dev/null || { rm -f "$SUDOERS_TMP"; echo "ERROR: generated sudoers is invalid" >&2; exit 1; } +install -o root -g root -m 440 "$SUDOERS_TMP" /etc/sudoers.d/martyrology-deploy +rm -f "$SUDOERS_TMP" + +cat >/etc/systemd/system/martyrology-api.service < +5. Confirm port $PORT is free: + ss -ltnp | sort -t: -k2 -n +6. Add the nginx proxy directives for the domain in Plesk (spec §6). +7. In the martyrology-api repo settings: + Secrets: VPS_HOST, VPS_SSH_KEY (private half), VPS_USERNAME=$DEPLOY_USER, + SUBMODULE_TOKEN + Variables: VPS_HOST_KEY (ssh-keyscan output), APP_DIR=$APP_DIR +8. Publish a GitHub release to trigger the first deploy. +EOF +``` + +- [ ] **Step 2: Check the syntax parses** + +Run: `bash -n scripts/deploy/setup-vps-deploy-user.sh` +Expected: no output, exit 0. + +- [ ] **Step 3: Shellcheck it** + +Run: `shellcheck scripts/deploy/setup-vps-deploy-user.sh` +Expected: clean. + +- [ ] **Step 4: Verify the non-root guard** + +Run: `bash scripts/deploy/setup-vps-deploy-user.sh; echo "exit=$?"` +Expected: `ERROR: run as root (try: sudo ...)` and `exit=1`. This is the only +part of the script that is safe to exercise on a workstation — everything past +the guard mutates system state and belongs on the VPS. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/deploy/setup-vps-deploy-user.sh +git commit -S -m "Add VPS provisioning script for martyrology-api deploys" +``` + +--- + +### Task 6: Release workflow and shellcheck CI + +**Files:** +- Create: `.github/workflows/deploy.yml` +- Modify: `.github/workflows/ci.yml` + +**Interfaces:** +- Consumes: `scripts/deploy/build_bundle.py` (Task 3) and `$APP_DIR/bin/deploy.sh` (Tasks 4–5). +- Produces: the deployment pipeline itself. No downstream consumers. + +- [ ] **Step 1: Track the lockfile** + +`uv.lock` currently exists on disk but is untracked (and is *not* gitignored). +The workflow's `uv export` step needs it committed, or the release build resolves +dependencies afresh and the "frozen to the release" guarantee is only as good as +whatever PyPI served that minute. + +```bash +git add uv.lock +git commit -S -m "Track uv.lock so release builds resolve deterministically" +``` + +Run: `git ls-files uv.lock` +Expected: `uv.lock` + +- [ ] **Step 2: Add the shellcheck job to CI** + +Append to `jobs:` in `.github/workflows/ci.yml`: + +```yaml + shellcheck: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: shellcheck + run: shellcheck scripts/deploy/*.sh +``` + +Submodules are deliberately not checked out here — the job only lints shell +scripts, and skipping them keeps CI runnable from forks with no access to +`martyrology-texts`. + +- [ ] **Step 3: Verify the CI change is valid YAML** + +Run: `python -c "import yaml,sys; yaml.safe_load(open('.github/workflows/ci.yml'))"` +Expected: no output, exit 0. (Install PyYAML in the venv if missing: +`pip install pyyaml`.) + +- [ ] **Step 4: Write the deploy workflow** + +Create `.github/workflows/deploy.yml`: + +```yaml +name: Deploy + +# Builds a release bundle (api wheel + offline wheelhouse + the three pinned +# data trees + manifest.json), ships it to the VPS, and activates it. +# +# All ${{ ... }} interpolations are repo secrets/vars (trusted). No untrusted +# github.event.* field is used. +# +# See docs/superpowers/specs/2026-08-01-continuous-deployment-design.md + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy-martyrology + cancel-in-progress: false + +jobs: + deploy: + # Pinned, never ubuntu-latest: the VPS is Ubuntu 24.04 / glibc 2.39 and the + # wheelhouse ABI must match it. + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + with: + submodules: recursive + token: ${{ secrets.SUBMODULE_TOKEN }} + persist-credentials: false + + - uses: actions/setup-python@v6.3.0 + with: + python-version: "3.12" + + - name: Resolve version + id: version + run: | + VERSION="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')" + echo "value=$VERSION" >> "$GITHUB_OUTPUT" + echo "Building martyrology-api $VERSION" + + - name: Build wheel and offline wheelhouse + run: | + pip install uv + mkdir -p staging/wheels + uv build --wheel --out-dir dist + uv export --no-dev --no-emit-project --format requirements-txt -o requirements.txt + pip wheel -r requirements.txt -w staging/wheels + cp dist/*.whl staging/wheels/ + + - name: Stage data trees + run: | + mkdir -p staging/data + cp -a data/editions staging/data/editions + cp -a vendor/texts staging/data/texts + cp -a vendor/crmedr staging/data/crmedr + cp -a vendor/clbdr staging/data/clbdr + rm -rf staging/data/*/.git + + - name: Assemble bundle + id: bundle + env: + VERSION: ${{ steps.version.outputs.value }} + run: | + mkdir -p out + BUNDLE="$(python scripts/deploy/build_bundle.py \ + --version "$VERSION" --api-version "$VERSION" \ + --staging staging --out out --repo-root .)" + NAME="$(basename "$BUNDLE")" + # Generated from inside out/ so the checksum file names the bundle + # bare; deploy.sh runs `sha256sum -c` from the incoming/ directory. + (cd out && sha256sum "$NAME" > "$NAME.sha256") + echo "path=$BUNDLE" >> "$GITHUB_OUTPUT" + ls -la out + + - name: Setup SSH + env: + VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }} + VPS_HOST_KEY: ${{ vars.VPS_HOST_KEY }} + run: | + if [ -z "$VPS_SSH_KEY" ] || [ -z "$VPS_HOST_KEY" ]; then + echo "ERROR: secrets.VPS_SSH_KEY or vars.VPS_HOST_KEY is empty." + exit 1 + fi + mkdir -p ~/.ssh + chmod 700 ~/.ssh + echo "$VPS_SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + echo "$VPS_HOST_KEY" > ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts + + - name: Verify the pinned host key covers the target + env: + VPS_HOST: ${{ secrets.VPS_HOST }} + run: | + if ! ssh-keygen -F "$VPS_HOST" -f ~/.ssh/known_hosts >/dev/null; then + echo "ERROR: vars.VPS_HOST_KEY has no key for $VPS_HOST." + exit 1 + fi + + - name: Sanity-check the pinned key against DNS SSHFP records + continue-on-error: true + env: + VPS_HOST: ${{ secrets.VPS_HOST }} + VPS_HOST_KEY: ${{ vars.VPS_HOST_KEY }} + run: | + # Non-fatal drift detector, mirroring cdcf-website's deploy workflow. + # The pinned key is the trust anchor; DNS is only corroboration, so a + # mismatch warns rather than blocks (DNS may simply lag a rotation). + PIN_FPS=$(printf '%s\n' "$VPS_HOST_KEY" \ + | awk '$1 ~ /^(ssh-|ecdsa-)/ || $2 ~ /^(ssh-|ecdsa-)/' \ + | ssh-keygen -l -f - 2>/dev/null \ + | awk '{print $2}' | sed 's/^SHA256://' | sort -u) + if [ -z "$PIN_FPS" ]; then + echo "::warning::Could not derive fingerprints from VPS_HOST_KEY; skipping drift check." + exit 0 + fi + DNS_FPS=$(dig +short SSHFP "$VPS_HOST" 2>/dev/null | awk '{print toupper($3)}' | sort -u) + if [ -z "$DNS_FPS" ]; then + echo "::warning::No SSHFP records published for $VPS_HOST; skipping drift check." + exit 0 + fi + for fp in $PIN_FPS; do + echo "$DNS_FPS" | grep -qi "$fp" \ + || echo "::warning::Pinned key $fp not advertised in SSHFP for $VPS_HOST. Either DNS lags reality or VPS_HOST_KEY is stale." + done + + - name: Upload bundle + env: + VPS_USERNAME: ${{ secrets.VPS_USERNAME }} + VPS_HOST: ${{ secrets.VPS_HOST }} + APP_DIR: ${{ vars.APP_DIR }} + BUNDLE: ${{ steps.bundle.outputs.path }} + run: | + if [ -z "$VPS_USERNAME" ] || [ -z "$VPS_HOST" ] || [ -z "$APP_DIR" ]; then + echo "ERROR: VPS_USERNAME / VPS_HOST / APP_DIR is empty." + exit 1 + fi + for attempt in 1 2 3; do + echo "Upload attempt $attempt..." + if scp -i ~/.ssh/deploy_key \ + -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=2 \ + "$BUNDLE" "$BUNDLE.sha256" \ + "${VPS_USERNAME}@${VPS_HOST}:${APP_DIR}/incoming/"; then + echo "Upload succeeded on attempt $attempt" + exit 0 + fi + [ "$attempt" -lt 3 ] && echo "Retrying in 15s..." && sleep 15 + done + echo "All upload attempts failed" + exit 1 + + - name: Activate release + env: + VPS_USERNAME: ${{ secrets.VPS_USERNAME }} + VPS_HOST: ${{ secrets.VPS_HOST }} + APP_DIR: ${{ vars.APP_DIR }} + VERSION: ${{ steps.version.outputs.value }} + run: | + ssh -i ~/.ssh/deploy_key \ + -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=2 \ + "${VPS_USERNAME}@${VPS_HOST}" \ + "bash ${APP_DIR}/bin/deploy.sh ${VERSION}" + + - name: Attach manifest to the release + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.event.release.tag_name }} + run: gh release upload "$TAG" staging/manifest.json --clobber +``` + +The activation step is intentionally not retried: `deploy.sh` rolls back on +failure, so a retry would reinstall a release already judged unhealthy. + +- [ ] **Step 5: Validate the workflow YAML** + +Run: `python -c "import yaml; yaml.safe_load(open('.github/workflows/deploy.yml'))"` +Expected: no output, exit 0. + +- [ ] **Step 6: Run the full suite one more time** + +Run: `pytest -q --cov --cov-branch --cov-report=term-missing && ruff check src tests scripts && ruff format --check src tests scripts && pyright` +Expected: all pass, coverage at or above 90. + +- [ ] **Step 7: Commit** + +```bash +git add .github/workflows/deploy.yml .github/workflows/ci.yml +git commit -S -m "Add release deploy workflow and shellcheck CI job" +``` + +--- + +## Post-implementation: operator steps + +These are not code and cannot be done by the implementing engineer. They belong +to whoever administers the VPS and the GitHub organisation, and are listed in +spec §10: + +1. `sudo apt install python3.12-venv shellcheck` on the VPS. +2. Run `sudo bash scripts/deploy/setup-vps-deploy-user.sh` on the VPS and follow + its printed next steps. +3. Add `martyrology-texts` to the organisation's Dependabot private-repository + allowlist. +4. Add the nginx directives in Plesk (spec §6) after confirming port 8412 is free. +5. Publish a release to trigger the first deploy. diff --git a/docs/superpowers/specs/2026-08-01-continuous-deployment-design.md b/docs/superpowers/specs/2026-08-01-continuous-deployment-design.md index 8f66c59..e0fb704 100644 --- a/docs/superpowers/specs/2026-08-01-continuous-deployment-design.md +++ b/docs/superpowers/specs/2026-08-01-continuous-deployment-design.md @@ -228,19 +228,32 @@ Plesk may rearrange things underneath it. ``` /opt/martyrology/ bin/deploy.sh installed by the setup script + config/runtime.env deploy-readable 0644, non-secret settings incoming/ scp target releases//{venv,data,manifest.json} current -> releases/ -/etc/martyrology/api.env root:root 0600 +/etc/martyrology/api.env root:root 0600, secrets only ``` -### Secrets remain unreadable to the deploy identity +### Two environment files, split by secrecy -`/etc/martyrology/api.env` is `root:root 0600`. systemd reads `EnvironmentFile=` -as root before dropping privileges, so the service gets its secrets while -`martyrology-deploy` cannot read them. This mirrors step 4 of -`setup-vps-sync-user.sh`, which restores `ubuntu` ownership and mode 0600 on -`.env.production` after the recursive chown. +systemd accepts multiple `EnvironmentFile=` lines, so the service's configuration +is split by who is allowed to read it: + +- **`/etc/martyrology/api.env`** — `root:root 0600`. Zitadel, OpenFGA and + `MARTYROLOGY_GITHUB_TOKEN`. systemd reads `EnvironmentFile=` as root before + dropping privileges, so the service gets its secrets while `martyrology-deploy` + cannot read them. This mirrors step 4 of `setup-vps-sync-user.sh`, which + restores `ubuntu` ownership and mode 0600 on `.env.production` after the + recursive chown. +- **`/opt/martyrology/config/runtime.env`** — owned by the deploy user, 0644. + `MARTYROLOGY_PORT`, `MARTYROLOGY_MANIFEST_PATH` and the three data paths. All + point through the stable `current` symlink, so this file is written once at + provisioning and never changes. + +The split is load-bearing, not cosmetic: `deploy.sh` must know the live port to +poll `/healthz` after restarting (§5.8), and it must not be able to read secrets +to do so. ### Sudoers drop-in @@ -296,6 +309,7 @@ fixed, known entrypoint, and only after its hash matches. ```ini [Service] User=martyrology +EnvironmentFile=/opt/martyrology/config/runtime.env EnvironmentFile=/etc/martyrology/api.env ExecStart=/opt/martyrology/current/venv/bin/uvicorn \ martyrology_api.app:create_app --factory --host 127.0.0.1 --port ${MARTYROLOGY_PORT} @@ -317,8 +331,8 @@ ss -ltnp | sort -t: -k2 -n ``` The port appears in exactly two places — `MARTYROLOGY_PORT` in -`/etc/martyrology/api.env`, and the nginx directive below. That coupling is -manual and must be kept in sync by hand; it is recorded in the runbook. +`/opt/martyrology/config/runtime.env`, and the nginx directive below. That +coupling is manual and must be kept in sync by hand; it is recorded in the runbook. ### Plesk nginx directives @@ -390,9 +404,10 @@ Environments can scope `APP_DIR` and the port per environment later, if wanted). 3. Generate the deploy keypair: `ssh-keygen -t ed25519 -C "martyrology-api deploy" -f ./deploy-key`. 4. Append the public half to `/home/martyrology-deploy/.ssh/authorized_keys`. 5. Capture the host key: `ssh-keyscan -t ed25519,rsa `. -6. Populate `/etc/martyrology/api.env` (secrets plus `MARTYROLOGY_PORT`, - `MARTYROLOGY_MANIFEST_PATH`, and the three data paths under - `/opt/martyrology/current/data/`). +6. Populate `/etc/martyrology/api.env` with the Zitadel, OpenFGA and GitHub-token + secrets. The setup script has already written `/opt/martyrology/config/runtime.env` + with `MARTYROLOGY_PORT`, `MARTYROLOGY_MANIFEST_PATH` and the three data paths + under `/opt/martyrology/current/data/`. 7. Choose and verify the port with `ss -ltnp`; add the nginx directives in Plesk. 8. Set the repository secrets and variables listed in §3. 9. Add `martyrology-texts` to the organisation's Dependabot private-repository From 667edac169f031d2d985a8f9c4c07b924bd9d7c0 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 00:16:16 +0200 Subject: [PATCH 03/25] Add deployment manifest reader and /healthz endpoint --- src/martyrology_api/app.py | 16 +++++++++++ src/martyrology_api/config.py | 5 ++++ src/martyrology_api/manifest.py | 46 ++++++++++++++++++++++++++++++ src/martyrology_api/models.py | 7 +++++ tests/test_health_api.py | 39 +++++++++++++++++++++++++ tests/test_manifest.py | 50 +++++++++++++++++++++++++++++++++ 6 files changed, 163 insertions(+) create mode 100644 src/martyrology_api/manifest.py create mode 100644 tests/test_health_api.py create mode 100644 tests/test_manifest.py diff --git a/src/martyrology_api/app.py b/src/martyrology_api/app.py index e1bc6b5..4889813 100644 --- a/src/martyrology_api/app.py +++ b/src/martyrology_api/app.py @@ -7,6 +7,8 @@ from .authz import Authz from .caching import CacheHeadersMiddleware from .config import Settings +from .manifest import load_manifest +from .models import HealthOut from .problems import install_problem_handlers from .registry import Registry from .routers import curation, discovery, read @@ -60,4 +62,18 @@ def service_document() -> dict: }, } + @app.get("/healthz", tags=["service"], response_model=HealthOut) + def healthz() -> HealthOut: + manifest = load_manifest(settings.manifest_file) + commits: dict[str, str | None] = {"crmedr": None, "clbdr": None, "texts": None} + if manifest is not None: + for key in commits: + commits[key] = manifest.data.get(key) + return HealthOut( + status="ok", + version=__version__, + data=commits, + editions=sorted(app.state.store.available()), + ) + return app diff --git a/src/martyrology_api/config.py b/src/martyrology_api/config.py index c655197..9922bb6 100644 --- a/src/martyrology_api/config.py +++ b/src/martyrology_api/config.py @@ -16,6 +16,7 @@ class Settings(BaseSettings): "martyrologium_romanum_2004_en_unofficial" ) access_info_url: str = "https://github.com/CatholicOS/martyrology-api#licensing" + manifest_path: str = "" # deployment manifest.json; empty outside a bundle zitadel_issuer: str = "" zitadel_client_id: str = "" @@ -39,6 +40,10 @@ def data_path_list(self) -> list[Path]: def restricted_set(self) -> set[str]: return {e.strip() for e in self.restricted_editions.split(",") if e.strip()} + @property + def manifest_file(self) -> Path | None: + return Path(self.manifest_path) if self.manifest_path else None + @property def auth_enabled(self) -> bool: return bool(self.zitadel_issuer) diff --git a/src/martyrology_api/manifest.py b/src/martyrology_api/manifest.py new file mode 100644 index 0000000..2e862dd --- /dev/null +++ b/src/martyrology_api/manifest.py @@ -0,0 +1,46 @@ +import json +from pathlib import Path + +from pydantic import BaseModel, ValidationError + +BUNDLE_FORMAT = 1 + + +class Manifest(BaseModel): + """The deployment manifest written into every release bundle. + + `data` maps a data-repository nickname (texts, crmedr, clbdr) to the + commit SHA that was bundled; `files` maps every bundled path to its + sha256. Together they are the auditable record of which corpus is live. + """ + + bundle_format: int + api_version: str + api_commit: str + data: dict[str, str] + python_requires: str + files: dict[str, str] + + +def load_manifest(path: Path | None) -> Manifest | None: + """Read a deployment manifest, or None when it is absent or unusable. + + Absence is the ordinary development case: no bundle, no manifest. A + malformed manifest, or one written by a future bundle format, is also + reported as absent rather than raised. /healthz is what the deploy + script polls to decide whether to roll back, so it must keep answering + even when the manifest is the thing that is broken. + """ + if path is None: + return None + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + try: + manifest = Manifest.model_validate(raw) + except ValidationError: + return None + if manifest.bundle_format != BUNDLE_FORMAT: + return None + return manifest diff --git a/src/martyrology_api/models.py b/src/martyrology_api/models.py index 231cfae..ca80928 100644 --- a/src/martyrology_api/models.py +++ b/src/martyrology_api/models.py @@ -124,6 +124,13 @@ class CatalogOut(BaseModel): elogia: list[CatalogEntryOut] +class HealthOut(BaseModel): + status: Literal["ok"] + version: str + data: dict[str, str | None] + editions: list[str] + + class WriteReceiptOut(BaseModel): branch: str commit_sha: str diff --git a/tests/test_health_api.py b/tests/test_health_api.py new file mode 100644 index 0000000..84850a9 --- /dev/null +++ b/tests/test_health_api.py @@ -0,0 +1,39 @@ +import json +from pathlib import Path + +MANIFEST = { + "bundle_format": 1, + "api_version": "0.1.0", + "api_commit": "a" * 40, + "data": {"texts": "t" * 40, "crmedr": "c" * 40, "clbdr": "l" * 40}, + "python_requires": ">=3.12", + "files": {}, +} + + +def test_healthz_ok_without_a_manifest(make_client): + body = make_client().get("/healthz").json() + assert body["status"] == "ok" + assert body["version"] + assert body["data"] == {"crmedr": None, "clbdr": None, "texts": None} + + +def test_healthz_lists_available_editions_sorted(make_client): + body = make_client().get("/healthz").json() + assert body["editions"], "fixtures should expose at least one edition" + assert body["editions"] == sorted(body["editions"]) + + +def test_healthz_reports_commits_from_the_manifest(make_client, tmp_path: Path): + path = tmp_path / "manifest.json" + path.write_text(json.dumps(MANIFEST), encoding="utf-8") + body = make_client(manifest_path=str(path)).get("/healthz").json() + assert body["data"] == {"crmedr": "c" * 40, "clbdr": "l" * 40, "texts": "t" * 40} + + +def test_healthz_survives_a_corrupt_manifest(make_client, tmp_path: Path): + path = tmp_path / "manifest.json" + path.write_text("{ not json", encoding="utf-8") + response = make_client(manifest_path=str(path)).get("/healthz") + assert response.status_code == 200 + assert response.json()["data"] == {"crmedr": None, "clbdr": None, "texts": None} diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..de10b43 --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,50 @@ +import json +from pathlib import Path + +from martyrology_api.manifest import load_manifest + +GOOD = { + "bundle_format": 1, + "api_version": "0.1.0", + "api_commit": "a" * 40, + "data": {"texts": "t" * 40, "crmedr": "c" * 40, "clbdr": "l" * 40}, + "python_requires": ">=3.12", + "files": {"data/crmedr/x.json": "0" * 64}, +} + + +def _write(tmp_path: Path, payload: object) -> Path: + path = tmp_path / "manifest.json" + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +def test_none_path_yields_none(): + assert load_manifest(None) is None + + +def test_missing_file_yields_none(tmp_path: Path): + assert load_manifest(tmp_path / "absent.json") is None + + +def test_malformed_json_yields_none(tmp_path: Path): + path = tmp_path / "manifest.json" + path.write_text("{ not json", encoding="utf-8") + assert load_manifest(path) is None + + +def test_missing_required_field_yields_none(tmp_path: Path): + payload = {k: v for k, v in GOOD.items() if k != "api_commit"} + assert load_manifest(_write(tmp_path, payload)) is None + + +def test_unknown_bundle_format_yields_none(tmp_path: Path): + assert load_manifest(_write(tmp_path, {**GOOD, "bundle_format": 99})) is None + + +def test_good_manifest_parses(tmp_path: Path): + manifest = load_manifest(_write(tmp_path, GOOD)) + assert manifest is not None + assert manifest.api_commit == "a" * 40 + assert manifest.data["texts"] == "t" * 40 + assert manifest.files["data/crmedr/x.json"] == "0" * 64 From 5e35cd9669327daab17428d8d7501f0584488697 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 00:22:29 +0200 Subject: [PATCH 04/25] Pin crmedr, clbdr and martyrology-texts as submodules --- .env.example | 10 ++++++++++ .github/dependabot.yml | 11 +++++++++++ .gitmodules | 9 +++++++++ docs/architecture.md | 16 +++++----------- vendor/clbdr | 1 + vendor/crmedr | 1 + vendor/texts | 1 + 7 files changed, 38 insertions(+), 11 deletions(-) create mode 100644 .gitmodules create mode 160000 vendor/clbdr create mode 160000 vendor/crmedr create mode 160000 vendor/texts diff --git a/.env.example b/.env.example index 95aeb43..654fa05 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,13 @@ MARTYROLOGY_OPENFGA_MODEL_ID= # Curation backend: set ONE of these; local_git_root takes precedence for dev MARTYROLOGY_GITHUB_TOKEN= MARTYROLOGY_LOCAL_GIT_ROOT= + +# --- Production (VPS) --------------------------------------------------- +# Written to /opt/martyrology/config/runtime.env by setup-vps-deploy-user.sh. +# All paths route through the `current` symlink, so they never change between +# releases. Secrets live in /etc/martyrology/api.env (root:root 0600) instead. +# MARTYROLOGY_PORT=8412 +# MARTYROLOGY_MANIFEST_PATH=/opt/martyrology/current/manifest.json +# MARTYROLOGY_DATA_PATH=/opt/martyrology/current/data/editions:/opt/martyrology/current/data/texts +# MARTYROLOGY_CRMEDR_PATH=/opt/martyrology/current/data/crmedr +# MARTYROLOGY_CLBDR_PATH=/opt/martyrology/current/data/clbdr diff --git a/.github/dependabot.yml b/.github/dependabot.yml index bbad764..c3ae30b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -47,3 +47,14 @@ updates: labels: - "dependencies" - "github-actions" + + - package-ecosystem: "gitsubmodule" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + cooldown: + default-days: 7 + labels: + - "dependencies" + - "data" diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..3a2a3de --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "vendor/crmedr"] + path = vendor/crmedr + url = https://github.com/CatholicOS/crmedr.git +[submodule "vendor/clbdr"] + path = vendor/clbdr + url = https://github.com/CatholicOS/clbdr.git +[submodule "vendor/texts"] + path = vendor/texts + url = https://github.com/CatholicOS/martyrology-texts.git diff --git a/docs/architecture.md b/docs/architecture.md index 510e705..2b36bae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,17 +29,11 @@ ### Attaching the private data The API reads text-data directories from a configurable path (`MARTYROLOGY_DATA_PATH`, -one directory per edition). Deployment options, in order of preference: - -1. **Deploy-time clone** of `martyrology-texts` via a read-only deploy key or GitHub - Actions secret, into a directory outside the public repo working tree; -2. **Git submodule** referencing the private repository (clones without access simply - skip it — the API detects absence and serves public editions only); -3. a database loaded from the private repo by a migration script (if/when the API - outgrows flat files). - -Option 1 is the recommended default: no submodule friction for public contributors, -no risk of accidentally vendoring private content into the public tree. +one directory per edition). The deployment architecture is specified in +[docs/superpowers/specs/2026-08-01-continuous-deployment-design.md](superpowers/specs/2026-08-01-continuous-deployment-design.md): +the three data trees are pinned as git submodules under `vendor/`, assembled by +CI into a release bundle with a manifest, and installed on the VPS by a deploy +script that smoke-checks before activating and rolls back on failure. ## Edition resolution: serving the right texts per date and territory diff --git a/vendor/clbdr b/vendor/clbdr new file mode 160000 index 0000000..ecb147b --- /dev/null +++ b/vendor/clbdr @@ -0,0 +1 @@ +Subproject commit ecb147b47b47368fbdefeb2074c5770ebb7c8f9d diff --git a/vendor/crmedr b/vendor/crmedr new file mode 160000 index 0000000..51740e7 --- /dev/null +++ b/vendor/crmedr @@ -0,0 +1 @@ +Subproject commit 51740e79584f64940f9e3f98615b000ef5f77e92 diff --git a/vendor/texts b/vendor/texts new file mode 160000 index 0000000..0903ce7 --- /dev/null +++ b/vendor/texts @@ -0,0 +1 @@ +Subproject commit 0903ce776ae39b5a4fb6b3773c48371b15e43111 From 91dca75042b6e15416d3fabecf7f7d1126351727 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 00:28:03 +0200 Subject: [PATCH 05/25] Add release bundle builder --- scripts/deploy/build_bundle.py | 109 +++++++++++++++++++++++++++++++++ tests/test_build_bundle.py | 82 +++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 scripts/deploy/build_bundle.py create mode 100644 tests/test_build_bundle.py diff --git a/scripts/deploy/build_bundle.py b/scripts/deploy/build_bundle.py new file mode 100644 index 0000000..b2aec7f --- /dev/null +++ b/scripts/deploy/build_bundle.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Assemble a martyrology-api release bundle. + +Takes a staging directory already populated with `wheels/` and `data/`, +writes `manifest.json` into it, and tars the result. Run by +.github/workflows/deploy.yml; the manifest it writes is read at runtime by +src/martyrology_api/manifest.py, and tests/test_build_bundle.py asserts the +two agree. +""" + +import argparse +import hashlib +import json +import subprocess +import tarfile +from pathlib import Path + +BUNDLE_FORMAT = 1 +PYTHON_REQUIRES = ">=3.12" +BUNDLE_NAME = "martyrology-{version}-linux-x86_64-cp312.tar.gz" + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def hash_tree(root: Path) -> dict[str, str]: + """sha256 of every regular file under root, keyed by POSIX-style relative + path and sorted so the manifest is byte-stable across runs.""" + return { + path.relative_to(root).as_posix(): sha256_file(path) + for path in sorted(root.rglob("*")) + if path.is_file() + } + + +def git_commit(repo: Path) -> str: + result = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def build_manifest( + staging: Path, api_version: str, api_commit: str, data_commits: dict[str, str] +) -> dict: + return { + "bundle_format": BUNDLE_FORMAT, + "api_version": api_version, + "api_commit": api_commit, + "data": data_commits, + "python_requires": PYTHON_REQUIRES, + "files": hash_tree(staging), + } + + +def write_manifest( + staging: Path, api_version: str, api_commit: str, data_commits: dict[str, str] +) -> Path: + """Hash the staged tree, then write the manifest into it. Order matters: + the manifest cannot contain its own digest, so it is built first.""" + manifest = build_manifest(staging, api_version, api_commit, data_commits) + path = staging / "manifest.json" + path.write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8") + return path + + +def assemble(staging: Path, out_dir: Path, version: str) -> Path: + """Tar the staged tree. If the caller has not already written a manifest + (main() does, with real commit data), write a minimal placeholder one so + the tarball is never missing manifest.json.""" + if not (staging / "manifest.json").exists(): + write_manifest(staging, version, "", {}) + tarball = out_dir / BUNDLE_NAME.format(version=version) + with tarfile.open(tarball, "w:gz") as archive: + for path in sorted(staging.rglob("*")): + archive.add(path, arcname=path.relative_to(staging).as_posix()) + return tarball + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", required=True) + parser.add_argument("--staging", required=True, type=Path) + parser.add_argument("--out", required=True, type=Path) + parser.add_argument("--api-version", required=True) + parser.add_argument("--repo-root", default=Path("."), type=Path) + args = parser.parse_args() + + data_commits = { + "texts": git_commit(args.repo_root / "vendor" / "texts"), + "crmedr": git_commit(args.repo_root / "vendor" / "crmedr"), + "clbdr": git_commit(args.repo_root / "vendor" / "clbdr"), + } + write_manifest(args.staging, args.api_version, git_commit(args.repo_root), data_commits) + tarball = assemble(args.staging, args.out, args.version) + print(tarball) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_build_bundle.py b/tests/test_build_bundle.py new file mode 100644 index 0000000..e11c0c5 --- /dev/null +++ b/tests/test_build_bundle.py @@ -0,0 +1,82 @@ +import importlib.util +import json +import tarfile +from pathlib import Path + +from martyrology_api import manifest as runtime_manifest +from martyrology_api.manifest import Manifest + +_PATH = Path(__file__).resolve().parents[1] / "scripts" / "deploy" / "build_bundle.py" +_spec = importlib.util.spec_from_file_location("build_bundle", _PATH) +assert _spec is not None and _spec.loader is not None +build_bundle = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(build_bundle) + +COMMITS = {"texts": "t" * 40, "crmedr": "c" * 40, "clbdr": "l" * 40} + + +def _staging(tmp_path: Path) -> Path: + root = tmp_path / "staging" + (root / "data" / "crmedr").mkdir(parents=True) + (root / "wheels").mkdir() + (root / "data" / "crmedr" / "ids.json").write_text("{}", encoding="utf-8") + (root / "wheels" / "fake.whl").write_bytes(b"PK\x03\x04") + return root + + +def test_sha256_file_matches_known_digest(tmp_path: Path): + target = tmp_path / "f.txt" + target.write_bytes(b"abc") + assert build_bundle.sha256_file(target) == ( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ) + + +def test_hash_tree_uses_sorted_posix_relative_keys(tmp_path: Path): + root = _staging(tmp_path) + tree = build_bundle.hash_tree(root) + assert list(tree) == sorted(tree) + assert "data/crmedr/ids.json" in tree + assert "wheels/fake.whl" in tree + assert not any(key.startswith("/") for key in tree) + + +def test_build_manifest_records_format_and_commits(tmp_path: Path): + manifest = build_bundle.build_manifest(_staging(tmp_path), "0.1.0", "a" * 40, COMMITS) + assert manifest["bundle_format"] == 1 + assert manifest["api_commit"] == "a" * 40 + assert manifest["data"] == COMMITS + + +def test_build_manifest_validates_against_the_runtime_model(tmp_path: Path): + """The writer and the reader must agree; this is the contract between + scripts/deploy/build_bundle.py and src/martyrology_api/manifest.py.""" + manifest = build_bundle.build_manifest(_staging(tmp_path), "0.1.0", "a" * 40, COMMITS) + parsed = Manifest.model_validate(manifest) + assert parsed.api_version == "0.1.0" + + +def test_bundle_format_constants_agree(): + """BUNDLE_FORMAT is declared in both modules. If they ever drift, the + deploy script's manifest check rejects every bundle CI produces, so + pin them together here rather than discovering it on the VPS.""" + assert build_bundle.BUNDLE_FORMAT == runtime_manifest.BUNDLE_FORMAT + + +def test_assemble_writes_a_tarball_with_a_manifest_at_the_root(tmp_path: Path): + root = _staging(tmp_path) + out = tmp_path / "out" + out.mkdir() + tarball = build_bundle.assemble(root, out, "1.2.3") + assert tarball.name == "martyrology-1.2.3-linux-x86_64-cp312.tar.gz" + with tarfile.open(tarball) as archive: + names = archive.getnames() + assert "manifest.json" in names + assert "data/crmedr/ids.json" in names + + +def test_assemble_manifest_does_not_hash_itself(tmp_path: Path): + root = _staging(tmp_path) + build_bundle.write_manifest(root, "0.1.0", "a" * 40, COMMITS) + manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8")) + assert "manifest.json" not in manifest["files"] From e9daeebf827452d0b1e8fbc26533c7efcd245ca2 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 00:32:52 +0200 Subject: [PATCH 06/25] Fix plan Task 3: assemble() must refuse a manifest-less tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan's own test called assemble() without write_manifest(), which the implementer resolved by making assemble() fabricate a placeholder manifest. That ships a bundle with empty api_commit and empty data — it passes deploy.sh's manifest check and serves with no audit trail, defeating the guarantee the manifest exists to provide. Fix the test; fail the build. Co-Authored-By: Claude Opus 5 (1M context) --- .../plans/2026-08-01-continuous-deployment.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/superpowers/plans/2026-08-01-continuous-deployment.md b/docs/superpowers/plans/2026-08-01-continuous-deployment.md index 664f731..ff55ad6 100644 --- a/docs/superpowers/plans/2026-08-01-continuous-deployment.md +++ b/docs/superpowers/plans/2026-08-01-continuous-deployment.md @@ -461,6 +461,8 @@ import json import tarfile from pathlib import Path +import pytest + from martyrology_api import manifest as runtime_manifest from martyrology_api.manifest import Manifest @@ -525,6 +527,7 @@ def test_assemble_writes_a_tarball_with_a_manifest_at_the_root(tmp_path: Path): root = _staging(tmp_path) out = tmp_path / "out" out.mkdir() + build_bundle.write_manifest(root, "0.1.0", "a" * 40, COMMITS) tarball = build_bundle.assemble(root, out, "1.2.3") assert tarball.name == "martyrology-1.2.3-linux-x86_64-cp312.tar.gz" with tarfile.open(tarball) as archive: @@ -533,6 +536,15 @@ def test_assemble_writes_a_tarball_with_a_manifest_at_the_root(tmp_path: Path): assert "data/crmedr/ids.json" in names +def test_assemble_refuses_a_staging_tree_with_no_manifest(tmp_path: Path): + """A bundle with no manifest has no provenance, yet would pass deploy.sh's + manifest check and serve with an empty audit trail. Fail the build instead.""" + out = tmp_path / "out" + out.mkdir() + with pytest.raises(FileNotFoundError): + build_bundle.assemble(_staging(tmp_path), out, "1.2.3") + + def test_assemble_manifest_does_not_hash_itself(tmp_path: Path): root = _staging(tmp_path) build_bundle.write_manifest(root, "0.1.0", "a" * 40, COMMITS) @@ -625,6 +637,12 @@ def write_manifest( def assemble(staging: Path, out_dir: Path, version: str) -> Path: + """Tar the staged tree. Refuses to build a bundle with no manifest: a + tarball without provenance would pass deploy.sh's manifest check and + serve happily with an empty audit trail, which is the one failure this + design exists to prevent. Call write_manifest() first.""" + if not (staging / "manifest.json").exists(): + raise FileNotFoundError("write_manifest() must run before assemble()") tarball = out_dir / BUNDLE_NAME.format(version=version) with tarfile.open(tarball, "w:gz") as archive: for path in sorted(staging.rglob("*")): From e3056e3b00fc356ed0d9aa2f5c66980891e06031 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 00:34:38 +0200 Subject: [PATCH 07/25] Fix Task 3 round 1: assemble() fails loudly with no manifest A tarball with no manifest.json would still pass deploy.sh's manifest check and serve with an empty audit trail (api_commit/data all null), defeating the one guarantee the manifest exists to provide. Replace the placeholder-manifest fallback in assemble() with a hard FileNotFoundError, and cover it with test_assemble_refuses_a_staging_tree_with_no_manifest. The prior "manifest exists" test now calls write_manifest() first, as the real CI flow (main()) already does. --- scripts/deploy/build_bundle.py | 9 +++++---- tests/test_build_bundle.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/scripts/deploy/build_bundle.py b/scripts/deploy/build_bundle.py index b2aec7f..0c61826 100644 --- a/scripts/deploy/build_bundle.py +++ b/scripts/deploy/build_bundle.py @@ -73,11 +73,12 @@ def write_manifest( def assemble(staging: Path, out_dir: Path, version: str) -> Path: - """Tar the staged tree. If the caller has not already written a manifest - (main() does, with real commit data), write a minimal placeholder one so - the tarball is never missing manifest.json.""" + """Tar the staged tree. Refuses to build a bundle with no manifest: a + tarball without provenance would pass deploy.sh's manifest check and + serve happily with an empty audit trail, which is the one failure this + design exists to prevent. Call write_manifest() first.""" if not (staging / "manifest.json").exists(): - write_manifest(staging, version, "", {}) + raise FileNotFoundError("write_manifest() must run before assemble()") tarball = out_dir / BUNDLE_NAME.format(version=version) with tarfile.open(tarball, "w:gz") as archive: for path in sorted(staging.rglob("*")): diff --git a/tests/test_build_bundle.py b/tests/test_build_bundle.py index e11c0c5..dacaf28 100644 --- a/tests/test_build_bundle.py +++ b/tests/test_build_bundle.py @@ -3,6 +3,8 @@ import tarfile from pathlib import Path +import pytest + from martyrology_api import manifest as runtime_manifest from martyrology_api.manifest import Manifest @@ -67,6 +69,7 @@ def test_assemble_writes_a_tarball_with_a_manifest_at_the_root(tmp_path: Path): root = _staging(tmp_path) out = tmp_path / "out" out.mkdir() + build_bundle.write_manifest(root, "0.1.0", "a" * 40, COMMITS) tarball = build_bundle.assemble(root, out, "1.2.3") assert tarball.name == "martyrology-1.2.3-linux-x86_64-cp312.tar.gz" with tarfile.open(tarball) as archive: @@ -75,6 +78,15 @@ def test_assemble_writes_a_tarball_with_a_manifest_at_the_root(tmp_path: Path): assert "data/crmedr/ids.json" in names +def test_assemble_refuses_a_staging_tree_with_no_manifest(tmp_path: Path): + """A bundle with no manifest has no provenance, yet would pass deploy.sh's + manifest check and serve with an empty audit trail. Fail the build instead.""" + out = tmp_path / "out" + out.mkdir() + with pytest.raises(FileNotFoundError): + build_bundle.assemble(_staging(tmp_path), out, "1.2.3") + + def test_assemble_manifest_does_not_hash_itself(tmp_path: Path): root = _staging(tmp_path) build_bundle.write_manifest(root, "0.1.0", "a" * 40, COMMITS) From 47ba1411d1244788e2292277de553d4230d5390c Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 00:40:38 +0200 Subject: [PATCH 08/25] Add on-VPS deploy script with smoke check and rollback --- scripts/deploy/deploy.sh | 154 ++++++++++++++++++++++++++++++++++++ tests/test_deploy_script.py | 80 +++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100755 scripts/deploy/deploy.sh create mode 100644 tests/test_deploy_script.py diff --git a/scripts/deploy/deploy.sh b/scripts/deploy/deploy.sh new file mode 100755 index 0000000..afb3a44 --- /dev/null +++ b/scripts/deploy/deploy.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# +# deploy.sh — install and activate a martyrology-api release bundle. +# +# Runs on the VPS as the martyrology-deploy user, invoked over ssh by +# .github/workflows/deploy.yml. Installed at $APP_DIR/bin/deploy.sh by +# scripts/deploy/setup-vps-deploy-user.sh; deliberately NOT refreshed from +# the bundle, so updating it stays an operator action. +# +# Nothing from the payload is ever executed. The bundle is checksum-verified +# and screened for path traversal before a single byte is extracted. + +set -euo pipefail + +APP_DIR="${APP_DIR:-/opt/martyrology}" +SERVICE="martyrology-api.service" +RUNTIME_ENV="$APP_DIR/config/runtime.env" +KEEP_RELEASES=5 +HEALTH_TIMEOUT=30 + +die() { + echo "ERROR: $*" >&2 + exit 1 +} + +DRY_RUN=0 +if [ "${1:-}" = "--dry-run" ]; then + DRY_RUN=1 + shift +fi + +VERSION="${1:-}" +[ -n "$VERSION" ] || die "usage: deploy.sh [--dry-run] " + +# Anchored, no metacharacters: the version becomes part of a path and of a +# filename, so anything outside this shape is refused outright. +[[ "$VERSION" =~ ^v?[0-9]+(\.[0-9]+)*$ ]] || die "refusing suspicious version string: $VERSION" + +BUNDLE="$APP_DIR/incoming/martyrology-${VERSION}-linux-x86_64-cp312.tar.gz" +[ -f "$BUNDLE" ] || die "bundle not found: $BUNDLE" +[ -f "$BUNDLE.sha256" ] || die "checksum not found: $BUNDLE.sha256" + +(cd "$(dirname "$BUNDLE")" && sha256sum -c "$(basename "$BUNDLE").sha256" >/dev/null 2>&1) \ + || die "checksum mismatch for $BUNDLE" + +if tar -tzf "$BUNDLE" | grep -Eq '^/|(^|/)\.\.(/|$)'; then + die "bundle contains absolute or parent-relative paths" +fi + +RELEASE="$APP_DIR/releases/$VERSION" + +if [ "$DRY_RUN" -eq 1 ]; then + echo "dry-run: $BUNDLE verified; would install to $RELEASE" + exit 0 +fi + +echo "Installing $VERSION to $RELEASE" +rm -rf "$RELEASE" +mkdir -p "$RELEASE" +tar -xzf "$BUNDLE" -C "$RELEASE" + +echo "Building venv (offline)" +python3.12 -m venv "$RELEASE/venv" +"$RELEASE/venv/bin/pip" install --quiet --upgrade pip +"$RELEASE/venv/bin/pip" install --quiet --no-index \ + --find-links "$RELEASE/wheels" martyrology-api + +# Validate the manifest with the reader the app itself uses, so a bundle whose +# manifest this release cannot parse is rejected before it is ever activated. +"$RELEASE/venv/bin/python" - "$RELEASE/manifest.json" <<'PY' || die "manifest validation failed" +import sys +from pathlib import Path + +from martyrology_api.manifest import load_manifest + +if load_manifest(Path(sys.argv[1])) is None: + sys.exit("manifest.json is absent, malformed, or an unsupported bundle_format") +PY + +wait_healthy() { + local port="$1" + local deadline=$((SECONDS + HEALTH_TIMEOUT)) + while [ "$SECONDS" -lt "$deadline" ]; do + if curl -fsS "http://127.0.0.1:${port}/healthz" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + return 1 +} + +echo "Smoke-checking the new release before activating it" +SMOKE_PORT="$("$RELEASE/venv/bin/python" -c \ + 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')" + +MARTYROLOGY_MANIFEST_PATH="$RELEASE/manifest.json" \ +MARTYROLOGY_DATA_PATH="$RELEASE/data/editions:$RELEASE/data/texts" \ +MARTYROLOGY_CRMEDR_PATH="$RELEASE/data/crmedr" \ +MARTYROLOGY_CLBDR_PATH="$RELEASE/data/clbdr" \ + "$RELEASE/venv/bin/uvicorn" martyrology_api.app:create_app --factory \ + --host 127.0.0.1 --port "$SMOKE_PORT" >"$RELEASE/smoke.log" 2>&1 & +SMOKE_PID=$! +trap 'kill "$SMOKE_PID" 2>/dev/null || true' EXIT + +if ! wait_healthy "$SMOKE_PORT"; then + kill "$SMOKE_PID" 2>/dev/null || true + cat "$RELEASE/smoke.log" >&2 + die "smoke check failed; $VERSION was not activated" +fi + +EDITIONS="$(curl -fsS "http://127.0.0.1:${SMOKE_PORT}/healthz" \ + | "$RELEASE/venv/bin/python" -c 'import json,sys; print(len(json.load(sys.stdin)["editions"]))')" +[ "$EDITIONS" -gt 0 ] || die "smoke check served zero editions; $VERSION was not activated" +echo "Smoke check passed: $EDITIONS editions" + +kill "$SMOKE_PID" 2>/dev/null || true +trap - EXIT + +PREVIOUS="" +if [ -L "$APP_DIR/current" ]; then + PREVIOUS="$(readlink "$APP_DIR/current")" +fi + +echo "Activating $VERSION" +ln -sfn "$RELEASE" "$APP_DIR/current.new" +mv -Tf "$APP_DIR/current.new" "$APP_DIR/current" +sudo /usr/bin/systemctl restart "$SERVICE" + +# shellcheck source=/dev/null +LIVE_PORT="$(. "$RUNTIME_ENV" && echo "$MARTYROLOGY_PORT")" + +if ! wait_healthy "$LIVE_PORT"; then + echo "ERROR: $VERSION is unhealthy on port $LIVE_PORT; rolling back" >&2 + if [ -n "$PREVIOUS" ]; then + ln -sfn "$PREVIOUS" "$APP_DIR/current.new" + mv -Tf "$APP_DIR/current.new" "$APP_DIR/current" + sudo /usr/bin/systemctl restart "$SERVICE" + echo "Rolled back to $PREVIOUS" >&2 + else + echo "No previous release to roll back to" >&2 + fi + exit 1 +fi + +echo "$VERSION is live and healthy on port $LIVE_PORT" + +rm -f "$BUNDLE" "$BUNDLE.sha256" +CURRENT_TARGET="$(readlink "$APP_DIR/current")" +# shellcheck disable=SC2012 +ls -1dt "$APP_DIR"/releases/*/ 2>/dev/null | tail -n "+$((KEEP_RELEASES + 1))" | while read -r old; do + [ "${old%/}" = "$CURRENT_TARGET" ] && continue + echo "Pruning ${old%/}" + rm -rf "${old%/}" +done diff --git a/tests/test_deploy_script.py b/tests/test_deploy_script.py new file mode 100644 index 0000000..d5ead7a --- /dev/null +++ b/tests/test_deploy_script.py @@ -0,0 +1,80 @@ +import hashlib +import subprocess +import tarfile +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "deploy" / "deploy.sh" + + +def _app_dir(tmp_path: Path) -> Path: + app = tmp_path / "app" + (app / "incoming").mkdir(parents=True) + (app / "releases").mkdir() + return app + + +def _bundle(app: Path, version: str, *, arcname: str = "manifest.json") -> Path: + payload = app / "incoming" / f"martyrology-{version}-linux-x86_64-cp312.tar.gz" + source = app / "manifest.json" + source.write_text("{}", encoding="utf-8") + with tarfile.open(payload, "w:gz") as archive: + archive.add(source, arcname=arcname) + source.unlink() + digest = hashlib.sha256(payload.read_bytes()).hexdigest() + (payload.parent / f"{payload.name}.sha256").write_text( + f"{digest} {payload.name}\n", encoding="utf-8" + ) + return payload + + +def _run(app: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", str(SCRIPT), *args], + env={"PATH": "/usr/bin:/bin", "APP_DIR": str(app)}, + capture_output=True, + text=True, + ) + + +def test_rejects_a_missing_version(tmp_path: Path): + result = _run(_app_dir(tmp_path)) + assert result.returncode != 0 + assert "usage" in result.stderr.lower() + + +def test_rejects_a_shell_metacharacter_version(tmp_path: Path): + result = _run(_app_dir(tmp_path), "--dry-run", "1.0.0; rm -rf /") + assert result.returncode != 0 + assert "suspicious version" in result.stderr + + +def test_rejects_a_missing_bundle(tmp_path: Path): + result = _run(_app_dir(tmp_path), "--dry-run", "9.9.9") + assert result.returncode != 0 + assert "bundle not found" in result.stderr + + +def test_rejects_a_checksum_mismatch(tmp_path: Path): + app = _app_dir(tmp_path) + bundle = _bundle(app, "1.0.0") + bundle.write_bytes(bundle.read_bytes() + b"tampered") + result = _run(app, "--dry-run", "1.0.0") + assert result.returncode != 0 + assert "checksum mismatch" in result.stderr + + +def test_rejects_a_path_traversal_member(tmp_path: Path): + app = _app_dir(tmp_path) + _bundle(app, "1.0.0", arcname="../escape.json") + result = _run(app, "--dry-run", "1.0.0") + assert result.returncode != 0 + assert "absolute or parent-relative paths" in result.stderr + + +def test_dry_run_accepts_a_good_bundle(tmp_path: Path): + app = _app_dir(tmp_path) + _bundle(app, "1.0.0") + result = _run(app, "--dry-run", "1.0.0") + assert result.returncode == 0, result.stderr + assert "dry-run" in result.stdout + assert not (app / "releases" / "1.0.0").exists() From 6794ef6b7fdc61ca279bd6a107c27068a1ff5b1c Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 00:57:12 +0200 Subject: [PATCH 09/25] Fix deploy.sh: path-traversal SIGPIPE bypass, live-release destruction, link screening, offline venv, and rollback gaps Round-1 review found the brief's deploy.sh had two critical and five important defects, all inherited verbatim from the plan. Fixes: - Path-traversal guard failed open: piping `tar -tzf | grep -q` let grep exit on first match and SIGPIPE the still-writing tar, making the pipeline non-zero and skipping `die` under `pipefail`. Now `tar -tvzf` output is captured to a variable first, then screened twice. - Redeploying the currently active version wiped it via `rm -rf` before the replacement was verified, with no useful rollback target left. Now refused outright before extraction. - Symlink/hardlink targets were never screened (tar -t only lists member names, not link targets); added a dedicated check against the "name -> target" column from the verbose listing. - `pip install --upgrade pip` in the "offline" venv build reached PyPI; removed, the venv's bundled pip is sufficient for --no-index installs. - A failed `systemctl restart`, missing runtime.env, or unset MARTYROLOGY_PORT after flipping `current` left the flip stranded with no rollback, since set -e exits before the old manual rollback check was reached. Now an EXIT trap is armed right after the flip and disarmed only once the live health check passes, so any failure in that window (including ones set -e exits on immediately) restores the previous release. - `sha256sum -c` verifies whatever filename the .sha256 file names, not the bundle itself; now the digest and the named filename are both checked explicitly against the bundle. - Rollback silently reported success without checking the previous release still exists, checking the restart succeeded, or re-polling health. - --dry-run was only honoured as $1; now accepted in any argument position, with unrecognised arguments rejected. - smoke.log was written into the release tree; now a mktemp path. - Reworded the header comment's "nothing from the payload is ever executed" claim, which pip-installing and running the bundle's own wheels contradicts. Adds regression tests for all of the above, including a multi-member archive (traversal member plus 20k filler members) that reproduces the SIGPIPE-masking bug directly. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/deploy/deploy.sh | 182 +++++++++++++++++++++++++++--------- tests/test_deploy_script.py | 162 +++++++++++++++++++++++++++++++- 2 files changed, 297 insertions(+), 47 deletions(-) diff --git a/scripts/deploy/deploy.sh b/scripts/deploy/deploy.sh index afb3a44..6269cae 100755 --- a/scripts/deploy/deploy.sh +++ b/scripts/deploy/deploy.sh @@ -7,8 +7,11 @@ # scripts/deploy/setup-vps-deploy-user.sh; deliberately NOT refreshed from # the bundle, so updating it stays an operator action. # -# Nothing from the payload is ever executed. The bundle is checksum-verified -# and screened for path traversal before a single byte is extracted. +# The bundle's own wheels are installed and its venv's python/uvicorn are +# invoked, but only the fixed entrypoints below are ever run, and only after +# the checksum matches and every tar member name and link target has been +# screened for path traversal. Nothing else in the payload is ever executed, +# and nothing runs before those checks pass. set -euo pipefail @@ -23,13 +26,90 @@ die() { exit 1 } +wait_healthy() { + local port="$1" + local deadline=$((SECONDS + HEALTH_TIMEOUT)) + while [ "$SECONDS" -lt "$deadline" ]; do + if curl -fsS "http://127.0.0.1:${port}/healthz" >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + return 1 +} + +# Reads the live port from runtime.env without ever raising on a missing +# file or an unset variable, so callers (including the rollback trap) can +# treat "unknown port" as an ordinary failure rather than a script abort. +get_live_port() { + [ -f "$RUNTIME_ENV" ] || return 1 + local port="" + # shellcheck source=/dev/null + port="$(. "$RUNTIME_ENV" && printf '%s' "${MARTYROLOGY_PORT:-}")" + [ -n "$port" ] || return 1 + printf '%s' "$port" +} + +# Armed immediately after `current` is flipped to the new release and +# disarmed only once the live health check passes, so any failure in +# between — a failed restart, a missing runtime.env, an unset +# MARTYROLOGY_PORT, an unhealthy service — restores the previous release +# instead of leaving the flip half-done. set -e can exit the script at any +# of those points; the EXIT trap still fires and this still runs. +ROLLBACK_ARMED=0 +PREVIOUS="" + +rollback_on_failure() { + local status=$? + trap - EXIT + if [ "$ROLLBACK_ARMED" -ne 1 ] || [ "$status" -eq 0 ]; then + exit "$status" + fi + echo "ERROR: activation of $VERSION failed (exit $status); rolling back" >&2 + if [ -z "$PREVIOUS" ]; then + echo "No previous release to roll back to" >&2 + exit "$status" + fi + if [ ! -d "$PREVIOUS" ]; then + echo "ERROR: previous release $PREVIOUS no longer exists; cannot roll back" >&2 + exit "$status" + fi + ln -sfn "$PREVIOUS" "$APP_DIR/current.new" + mv -Tf "$APP_DIR/current.new" "$APP_DIR/current" + if ! sudo /usr/bin/systemctl restart "$SERVICE"; then + echo "ERROR: failed to restart $SERVICE while rolling back to $PREVIOUS" >&2 + exit "$status" + fi + local rollback_port="" + rollback_port="$(get_live_port || true)" + if [ -n "$rollback_port" ] && wait_healthy "$rollback_port"; then + echo "Rolled back to $PREVIOUS and it is healthy on port $rollback_port" >&2 + else + echo "ERROR: rolled back to $PREVIOUS but it did not become healthy" >&2 + fi + exit "$status" +} + +# Accept --dry-run in any position and a single positional ; +# anything else is rejected rather than silently ignored (a stray +# "deploy.sh --dry-run" must not fall through to a real deploy). DRY_RUN=0 -if [ "${1:-}" = "--dry-run" ]; then - DRY_RUN=1 +VERSION="" +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) + DRY_RUN=1 + ;; + -*) + die "usage: deploy.sh [--dry-run] " + ;; + *) + [ -z "$VERSION" ] || die "usage: deploy.sh [--dry-run] " + VERSION="$1" + ;; + esac shift -fi - -VERSION="${1:-}" +done [ -n "$VERSION" ] || die "usage: deploy.sh [--dry-run] " # Anchored, no metacharacters: the version becomes part of a path and of a @@ -40,13 +120,40 @@ BUNDLE="$APP_DIR/incoming/martyrology-${VERSION}-linux-x86_64-cp312.tar.gz" [ -f "$BUNDLE" ] || die "bundle not found: $BUNDLE" [ -f "$BUNDLE.sha256" ] || die "checksum not found: $BUNDLE.sha256" -(cd "$(dirname "$BUNDLE")" && sha256sum -c "$(basename "$BUNDLE").sha256" >/dev/null 2>&1) \ - || die "checksum mismatch for $BUNDLE" - -if tar -tzf "$BUNDLE" | grep -Eq '^/|(^|/)\.\.(/|$)'; then +# Verify the digest directly against the bundle's own bytes, and assert the +# checksum file actually names this bundle — `sha256sum -c` only checks that +# the digest matches whatever filename is written in the .sha256 file, so a +# checksum file naming an unrelated file would otherwise verify cleanly +# without ever hashing the tarball. +BUNDLE_BASENAME="$(basename "$BUNDLE")" +CHECKSUM_EXPECTED="$(awk '{print $1}' "$BUNDLE.sha256")" +CHECKSUM_NAMED="$(awk '{print $2}' "$BUNDLE.sha256" | sed 's|^\*||')" +[ "$CHECKSUM_NAMED" = "$BUNDLE_BASENAME" ] \ + || die "checksum file names $CHECKSUM_NAMED, not $BUNDLE_BASENAME" +CHECKSUM_ACTUAL="$(sha256sum "$BUNDLE" | awk '{print $1}')" +[ "$CHECKSUM_EXPECTED" = "$CHECKSUM_ACTUAL" ] || die "checksum mismatch for $BUNDLE" + +# Capture the full verbose listing once so it can be screened twice (member +# names, then link targets) without piping tar's output into grep: grep -q +# exits as soon as it finds a match, which closes the pipe out from under a +# still-writing tar and makes it exit on SIGPIPE — under `pipefail` that +# turns the whole pipeline non-zero, so `if pipeline; then die; fi` sees a +# FALSE condition and the guard never fires. Capturing to a variable first +# lets tar always run to completion before anything is screened. +BUNDLE_MEMBERS="$(tar -tvzf "$BUNDLE")" + +if grep -Eq '^/|(^|/)\.\.(/|$)' <<<"$(awk '{print $NF}' <<<"$BUNDLE_MEMBERS")"; then die "bundle contains absolute or parent-relative paths" fi +# tar -t prints member names, not link targets, so a symlink (or hardlink) +# member can carry an absolute or parent-escaping target that the name-only +# screen above never sees. The verbose listing renders links as +# "name -> target"; reject any whose target escapes the release tree. +if grep -Eq '(^| )l?[rwx-]{9}.* -> (/|.*\.\./)' <<<"$BUNDLE_MEMBERS"; then + die "bundle contains a link pointing outside the release tree" +fi + RELEASE="$APP_DIR/releases/$VERSION" if [ "$DRY_RUN" -eq 1 ]; then @@ -54,6 +161,13 @@ if [ "$DRY_RUN" -eq 1 ]; then exit 0 fi +# Refuse to redeploy the version that is already live: rm -rf below would +# tear down the active release before a replacement is verified, and a +# rollback afterwards would just relink the same now-empty directory. +if [ -L "$APP_DIR/current" ] && [ "$(readlink "$APP_DIR/current")" = "$RELEASE" ]; then + die "$VERSION is the currently active release; deactivate or bump the version before redeploying" +fi + echo "Installing $VERSION to $RELEASE" rm -rf "$RELEASE" mkdir -p "$RELEASE" @@ -61,7 +175,6 @@ tar -xzf "$BUNDLE" -C "$RELEASE" echo "Building venv (offline)" python3.12 -m venv "$RELEASE/venv" -"$RELEASE/venv/bin/pip" install --quiet --upgrade pip "$RELEASE/venv/bin/pip" install --quiet --no-index \ --find-links "$RELEASE/wheels" martyrology-api @@ -77,34 +190,23 @@ if load_manifest(Path(sys.argv[1])) is None: sys.exit("manifest.json is absent, malformed, or an unsupported bundle_format") PY -wait_healthy() { - local port="$1" - local deadline=$((SECONDS + HEALTH_TIMEOUT)) - while [ "$SECONDS" -lt "$deadline" ]; do - if curl -fsS "http://127.0.0.1:${port}/healthz" >/dev/null 2>&1; then - return 0 - fi - sleep 1 - done - return 1 -} - echo "Smoke-checking the new release before activating it" SMOKE_PORT="$("$RELEASE/venv/bin/python" -c \ 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')" +SMOKE_LOG="$(mktemp)" MARTYROLOGY_MANIFEST_PATH="$RELEASE/manifest.json" \ MARTYROLOGY_DATA_PATH="$RELEASE/data/editions:$RELEASE/data/texts" \ MARTYROLOGY_CRMEDR_PATH="$RELEASE/data/crmedr" \ MARTYROLOGY_CLBDR_PATH="$RELEASE/data/clbdr" \ "$RELEASE/venv/bin/uvicorn" martyrology_api.app:create_app --factory \ - --host 127.0.0.1 --port "$SMOKE_PORT" >"$RELEASE/smoke.log" 2>&1 & + --host 127.0.0.1 --port "$SMOKE_PORT" >"$SMOKE_LOG" 2>&1 & SMOKE_PID=$! -trap 'kill "$SMOKE_PID" 2>/dev/null || true' EXIT +trap 'kill "$SMOKE_PID" 2>/dev/null || true; rm -f "$SMOKE_LOG"' EXIT if ! wait_healthy "$SMOKE_PORT"; then kill "$SMOKE_PID" 2>/dev/null || true - cat "$RELEASE/smoke.log" >&2 + cat "$SMOKE_LOG" >&2 die "smoke check failed; $VERSION was not activated" fi @@ -114,9 +216,9 @@ EDITIONS="$(curl -fsS "http://127.0.0.1:${SMOKE_PORT}/healthz" \ echo "Smoke check passed: $EDITIONS editions" kill "$SMOKE_PID" 2>/dev/null || true +rm -f "$SMOKE_LOG" trap - EXIT -PREVIOUS="" if [ -L "$APP_DIR/current" ]; then PREVIOUS="$(readlink "$APP_DIR/current")" fi @@ -124,26 +226,20 @@ fi echo "Activating $VERSION" ln -sfn "$RELEASE" "$APP_DIR/current.new" mv -Tf "$APP_DIR/current.new" "$APP_DIR/current" -sudo /usr/bin/systemctl restart "$SERVICE" -# shellcheck source=/dev/null -LIVE_PORT="$(. "$RUNTIME_ENV" && echo "$MARTYROLOGY_PORT")" +ROLLBACK_ARMED=1 +trap rollback_on_failure EXIT -if ! wait_healthy "$LIVE_PORT"; then - echo "ERROR: $VERSION is unhealthy on port $LIVE_PORT; rolling back" >&2 - if [ -n "$PREVIOUS" ]; then - ln -sfn "$PREVIOUS" "$APP_DIR/current.new" - mv -Tf "$APP_DIR/current.new" "$APP_DIR/current" - sudo /usr/bin/systemctl restart "$SERVICE" - echo "Rolled back to $PREVIOUS" >&2 - else - echo "No previous release to roll back to" >&2 - fi - exit 1 -fi +sudo /usr/bin/systemctl restart "$SERVICE" + +LIVE_PORT="$(get_live_port)" || die "could not determine MARTYROLOGY_PORT from $RUNTIME_ENV" +wait_healthy "$LIVE_PORT" || die "$VERSION is unhealthy on port $LIVE_PORT" echo "$VERSION is live and healthy on port $LIVE_PORT" +ROLLBACK_ARMED=0 +trap - EXIT + rm -f "$BUNDLE" "$BUNDLE.sha256" CURRENT_TARGET="$(readlink "$APP_DIR/current")" # shellcheck disable=SC2012 diff --git a/tests/test_deploy_script.py b/tests/test_deploy_script.py index d5ead7a..5ec0166 100644 --- a/tests/test_deploy_script.py +++ b/tests/test_deploy_script.py @@ -1,4 +1,5 @@ import hashlib +import io import subprocess import tarfile from pathlib import Path @@ -13,6 +14,13 @@ def _app_dir(tmp_path: Path) -> Path: return app +def _write_checksum(payload: Path) -> None: + digest = hashlib.sha256(payload.read_bytes()).hexdigest() + (payload.parent / f"{payload.name}.sha256").write_text( + f"{digest} {payload.name}\n", encoding="utf-8" + ) + + def _bundle(app: Path, version: str, *, arcname: str = "manifest.json") -> Path: payload = app / "incoming" / f"martyrology-{version}-linux-x86_64-cp312.tar.gz" source = app / "manifest.json" @@ -20,10 +28,57 @@ def _bundle(app: Path, version: str, *, arcname: str = "manifest.json") -> Path: with tarfile.open(payload, "w:gz") as archive: archive.add(source, arcname=arcname) source.unlink() - digest = hashlib.sha256(payload.read_bytes()).hexdigest() - (payload.parent / f"{payload.name}.sha256").write_text( - f"{digest} {payload.name}\n", encoding="utf-8" - ) + _write_checksum(payload) + return payload + + +def _bundle_with_traversal_and_filler( + app: Path, version: str, *, filler_count: int = 20_000 +) -> Path: + """A traversal member first, then enough filler members that a naive + `tar -tzf | grep -q` pipeline would see tar killed by SIGPIPE (exit 141) + once grep matches and exits early — the regression case for the fix that + captures `tar -tvzf` output before screening it, instead of piping into + grep directly.""" + payload = app / "incoming" / f"martyrology-{version}-linux-x86_64-cp312.tar.gz" + with tarfile.open(payload, "w:gz") as archive: + evil = tarfile.TarInfo(name="../escape.json") + evil.size = 0 + archive.addfile(evil, io.BytesIO(b"")) + for i in range(filler_count): + filler = tarfile.TarInfo(name=f"wheels/filler-{i}.whl") + filler.size = 0 + archive.addfile(filler, io.BytesIO(b"")) + _write_checksum(payload) + return payload + + +def _bundle_with_absolute_member(app: Path, version: str) -> Path: + # tarfile.TarFile.add() normalizes away a leading "/" in arcname before + # storing it, so an absolute member can only be produced by constructing + # the TarInfo directly and calling addfile(), bypassing that + # normalization the same way a hand-crafted malicious archive would. + payload = app / "incoming" / f"martyrology-{version}-linux-x86_64-cp312.tar.gz" + with tarfile.open(payload, "w:gz") as archive: + info = tarfile.TarInfo(name="/etc/passwd") + info.size = 2 + archive.addfile(info, io.BytesIO(b"{}")) + _write_checksum(payload) + return payload + + +def _bundle_with_symlink(app: Path, version: str, *, target: str) -> Path: + payload = app / "incoming" / f"martyrology-{version}-linux-x86_64-cp312.tar.gz" + source = app / "manifest.json" + source.write_text("{}", encoding="utf-8") + with tarfile.open(payload, "w:gz") as archive: + archive.add(source, arcname="manifest.json") + link = tarfile.TarInfo(name="escape-link") + link.type = tarfile.SYMTYPE + link.linkname = target + archive.addfile(link) + source.unlink() + _write_checksum(payload) return payload @@ -78,3 +133,102 @@ def test_dry_run_accepts_a_good_bundle(tmp_path: Path): assert result.returncode == 0, result.stderr assert "dry-run" in result.stdout assert not (app / "releases" / "1.0.0").exists() + + +def test_rejects_a_multi_member_traversal_archive_without_sigpipe_masking(tmp_path: Path): + # Regression test: a naive `tar -tzf "$BUNDLE" | grep -Eq ...` pipeline lets + # grep exit as soon as it matches the first (evil) member, which closes the + # pipe out from under a still-writing tar; under `pipefail` the resulting + # SIGPIPE makes the whole pipeline non-zero, so `if pipeline; then die; fi` + # sees a FALSE condition and the guard never fires. A single-member archive + # does not reproduce this because tar finishes before grep can exit early. + app = _app_dir(tmp_path) + _bundle_with_traversal_and_filler(app, "1.0.0") + result = _run(app, "--dry-run", "1.0.0") + assert result.returncode != 0 + assert "absolute or parent-relative paths" in result.stderr + + +def test_rejects_an_absolute_path_member(tmp_path: Path): + app = _app_dir(tmp_path) + _bundle_with_absolute_member(app, "1.0.0") + result = _run(app, "--dry-run", "1.0.0") + assert result.returncode != 0 + assert "absolute or parent-relative paths" in result.stderr + + +def test_rejects_a_symlink_member_escaping_the_release_tree(tmp_path: Path): + # The target has a space, so the name/target screen above (which reads + # only the last whitespace-delimited field of each listing line) sees + # just "copy" and misses it; the dedicated link-target regex, which + # matches on the text right after " -> " instead of a split field, still + # catches it. This is the case that makes the dedicated symlink check + # more than a duplicate of the name-based one. + app = _app_dir(tmp_path) + _bundle_with_symlink(app, "1.0.0", target="/etc/passwd copy") + result = _run(app, "--dry-run", "1.0.0") + assert result.returncode != 0 + assert "link pointing outside the release tree" in result.stderr + + +def test_accepts_a_symlink_member_that_stays_inside_the_release_tree(tmp_path: Path): + app = _app_dir(tmp_path) + _bundle_with_symlink(app, "1.0.0", target="manifest.json") + result = _run(app, "--dry-run", "1.0.0") + assert result.returncode == 0, result.stderr + assert "dry-run" in result.stdout + + +def test_rejects_a_checksum_file_naming_a_different_bundle(tmp_path: Path): + app = _app_dir(tmp_path) + bundle = _bundle(app, "1.0.0") + digest = hashlib.sha256(bundle.read_bytes()).hexdigest() + (bundle.parent / f"{bundle.name}.sha256").write_text( + f"{digest} some-other-file.tar.gz\n", encoding="utf-8" + ) + result = _run(app, "--dry-run", "1.0.0") + assert result.returncode != 0 + assert "checksum file names" in result.stderr + + +def test_rejects_redeploy_of_the_currently_active_version(tmp_path: Path): + app = _app_dir(tmp_path) + _bundle(app, "1.0.0") + release_dir = app / "releases" / "1.0.0" + release_dir.mkdir() + (app / "current").symlink_to(release_dir) + # No --dry-run: this must be rejected by the active-release guard before + # any real install step (rm -rf/venv/sudo) is ever reached. + result = _run(app, "1.0.0") + assert result.returncode != 0 + assert "currently active release" in result.stderr + assert release_dir.exists() + + +def test_dry_run_ignores_active_release_guard(tmp_path: Path): + # --dry-run only verifies the bundle and never touches the filesystem, so + # it is not subject to the active-release guard (nothing would be torn + # down anyway). + app = _app_dir(tmp_path) + _bundle(app, "1.0.0") + release_dir = app / "releases" / "1.0.0" + release_dir.mkdir() + (app / "current").symlink_to(release_dir) + result = _run(app, "--dry-run", "1.0.0") + assert result.returncode == 0, result.stderr + + +def test_dry_run_flag_is_honoured_after_the_version(tmp_path: Path): + app = _app_dir(tmp_path) + _bundle(app, "1.0.0") + result = _run(app, "1.0.0", "--dry-run") + assert result.returncode == 0, result.stderr + assert "dry-run" in result.stdout + assert not (app / "releases" / "1.0.0").exists() + + +def test_rejects_an_unrecognised_extra_argument(tmp_path: Path): + app = _app_dir(tmp_path) + result = _run(app, "--dry-run", "1.0.0", "extra") + assert result.returncode != 0 + assert "usage" in result.stderr.lower() From a03bd7de87b6c0b9a5b7ea7c9211dd6a6d1e6dfe Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 01:06:34 +0200 Subject: [PATCH 10/25] Add token expiry watch to plan Task 6 SUBMODULE_TOKEN is a fine-grained PAT with a hard expiry; when it lapses actions/checkout fails at the vendor/texts submodule, and since deploys only fire on published releases that surfaces mid-release. Reads the expiry from GitHub's GitHub-Authentication-Token-Expiration response header rather than a hardcoded date, so it survives rotation, and doubles as a liveness check for a revoked token. Co-Authored-By: Claude Opus 5 (1M context) --- .../plans/2026-08-01-continuous-deployment.md | 123 +++++++++++++++++- 1 file changed, 120 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-08-01-continuous-deployment.md b/docs/superpowers/plans/2026-08-01-continuous-deployment.md index ff55ad6..a5e85c6 100644 --- a/docs/superpowers/plans/2026-08-01-continuous-deployment.md +++ b/docs/superpowers/plans/2026-08-01-continuous-deployment.md @@ -40,6 +40,7 @@ | `tests/test_deploy_script.py` | Subprocess tests for `deploy.sh` rejection paths and `--dry-run`. | | `scripts/deploy/setup-vps-deploy-user.sh` | One-time root provisioning of users, dirs, sudoers, units, runtime.env. | | `.github/workflows/deploy.yml` | Release → build → scp → ssh deploy. | +| `.github/workflows/token-expiry-watch.yml` | Weekly check that SUBMODULE_TOKEN still works and is not near expiry. | | `.gitmodules` | Three HTTPS submodule pins. | **Modified:** @@ -1403,11 +1404,127 @@ Expected: no output, exit 0. Run: `pytest -q --cov --cov-branch --cov-report=term-missing && ruff check src tests scripts && ruff format --check src tests scripts && pyright` Expected: all pass, coverage at or above 90. -- [ ] **Step 7: Commit** +- [ ] **Step 7: Add the token expiry watch** + +`SUBMODULE_TOKEN` is a fine-grained PAT with a hard expiry. When it lapses, +`actions/checkout` fails at the submodule step — and because deploys only fire on +published releases, that surfaces at the worst possible moment. This workflow +reads the expiry off the token itself rather than from a hardcoded date, so it +stays correct across rotations, and doubles as a liveness check: a revoked token +fails the API call and raises the same alarm. + +Create `.github/workflows/token-expiry-watch.yml`: + +```yaml +name: Token expiry watch + +# SUBMODULE_TOKEN gates the release workflow's private-submodule checkout. +# GitHub returns a fine-grained PAT's expiry in the +# GitHub-Authentication-Token-Expiration response header, so this reads the +# real expiry off the token instead of tracking a date by hand. + +on: + schedule: + - cron: "0 7 * * 1" + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + check: + runs-on: ubuntu-24.04 + steps: + - name: Check SUBMODULE_TOKEN health and expiry + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }} + REPO: ${{ github.repository }} + WATCHED: CatholicOS/martyrology-texts + WARN_DAYS: "30" + run: | + set -euo pipefail + + open_issue() { + local title="$1" body="$2" + if gh issue list --repo "$REPO" --state open --search "in:title $title" \ + --json title --jq '.[].title' | grep -Fxq "$title"; then + echo "Issue already open: $title" + return 0 + fi + gh issue create --repo "$REPO" --title "$title" --body "$body" --label dependencies + } + + status="$(curl -sS -o /dev/null -D headers.txt -w '%{http_code}' \ + -H "Authorization: Bearer $SUBMODULE_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/$WATCHED")" + + if [ "$status" != "200" ]; then + open_issue "SUBMODULE_TOKEN is not working (HTTP $status)" \ + "The scheduled token check could not read \`$WATCHED\` (HTTP $status). + + The release workflow's \`actions/checkout\` step will fail at the + \`vendor/texts\` submodule until this is fixed. Likely causes: the PAT + expired, was revoked, or its organization approval was withdrawn. + + Fix: mint a new fine-grained PAT (resource owner \`CatholicOS\`, + Contents: Read-only on \`$WATCHED\`), approve it in the org's pending + requests, then \`gh secret set SUBMODULE_TOKEN --repo $REPO --app actions\`." + exit 1 + fi + + expiry="$(grep -i '^github-authentication-token-expiration:' headers.txt \ + | sed 's/^[^:]*: *//' | tr -d '\r' || true)" + + if [ -z "$expiry" ]; then + echo "::notice::Token reports no expiration date; nothing to warn about." + exit 0 + fi + + expiry_epoch="$(date -d "$expiry" +%s)" + days_left=$(( (expiry_epoch - $(date +%s)) / 86400 )) + echo "SUBMODULE_TOKEN expires $expiry ($days_left days)" + + if [ "$days_left" -le "$WARN_DAYS" ]; then + open_issue "SUBMODULE_TOKEN expires in $days_left days ($expiry)" \ + "\`SUBMODULE_TOKEN\` expires on **$expiry** — $days_left days from now. + + When it lapses, the release workflow fails at the \`vendor/texts\` + submodule checkout, and because deploys only run on published releases + you will discover it mid-release. + + Renew: mint a fine-grained PAT (resource owner \`CatholicOS\`, + Contents: Read-only on \`$WATCHED\`), approve it in the org's pending + requests, then \`gh secret set SUBMODULE_TOKEN --repo $REPO --app actions\`. + + Longer term, an org-owned GitHub App installation token removes this + expiry cycle entirely (see the deployment spec, §3)." + fi +``` + +Two behaviors to know about, both documented here rather than discovered later. +The issue title embeds the expiry date, so a renewed token produces a distinct +title next cycle instead of being deduplicated against the stale one. And GitHub +disables scheduled workflows in repositories with no activity for 60 days — an +inactive repo would silently stop warning, so if this repo ever goes quiet the +watch stops with it. + +- [ ] **Step 8: Validate the watch workflow YAML** + +Run: `python -c "import yaml; yaml.safe_load(open('.github/workflows/token-expiry-watch.yml'))"` +Expected: no output, exit 0. + +Note: scheduled workflows only run from the default branch, so this stays dormant +until the branch merges to `main`. Use `workflow_dispatch` to test it before then. + +- [ ] **Step 9: Commit** ```bash -git add .github/workflows/deploy.yml .github/workflows/ci.yml -git commit -S -m "Add release deploy workflow and shellcheck CI job" +git add .github/workflows/deploy.yml .github/workflows/ci.yml \ + .github/workflows/token-expiry-watch.yml +git commit -S -m "Add release deploy workflow, shellcheck CI job and token expiry watch" ``` --- From 4fb58e34fb91c8b62814233df01c5ddf35c332ba Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 01:09:32 +0200 Subject: [PATCH 11/25] Fix deploy.sh: name-only screen bypassed by SIGPIPE fix's field-splitting, plus rollback robustness Round-2 review found the round-1 SIGPIPE fix (capturing tar -tvzf output before screening it) had regressed the member-name traversal check itself: screening `awk '{print $NF}'` over a verbose listing line, instead of the raw member name, only screens a symlink's target (never its own name, since $NF on a "name -> target" line is the target) and only the last whitespace-delimited token of any name containing a space. Fixed by capturing two things instead of one: - BUNDLE_NAMES (`tar -tzf`, one name per line) for the whole-line, unsplit name screen. - BUNDLE_MEMBERS (`tar -tvzf`) kept only for the link-target screen, which needs the verbose "-> target" column tar -t does not print. Both captures still avoid piping into grep -q, preserving the round-1 SIGPIPE fix. Added three isolated single-member regression tests (a combined archive masks the failure, which is how this slipped through round 1): a symlink whose own name traverses but whose target does not, a traversal member with a space in its name, and an absolute member with a space in its name. Also folded in three robustness fixes to the rollback trap built in round 1: - rollback_on_failure now guards its own ln/mv calls so a failure there cannot abort the trap under set -e before it reaches the final `exit "$status"`, losing the original diagnostic. - The smoke-check EXIT trap is now armed immediately after `SMOKE_LOG=$(mktemp)`, before the background uvicorn is started, so the temp file cannot leak if something fails in between. - The post-flip rollback trap is now registered for EXIT, INT, and TERM (not just EXIT), since a signal during the flip window would otherwise leave `current` half-flipped with no trap to catch it. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/deploy/deploy.sh | 73 +++++++++++++++++++++++++----------- tests/test_deploy_script.py | 75 ++++++++++++++++++++++++++++++++++--- 2 files changed, 121 insertions(+), 27 deletions(-) diff --git a/scripts/deploy/deploy.sh b/scripts/deploy/deploy.sh index 6269cae..558566c 100755 --- a/scripts/deploy/deploy.sh +++ b/scripts/deploy/deploy.sh @@ -53,15 +53,23 @@ get_live_port() { # Armed immediately after `current` is flipped to the new release and # disarmed only once the live health check passes, so any failure in # between — a failed restart, a missing runtime.env, an unset -# MARTYROLOGY_PORT, an unhealthy service — restores the previous release -# instead of leaving the flip half-done. set -e can exit the script at any -# of those points; the EXIT trap still fires and this still runs. +# MARTYROLOGY_PORT, an unhealthy service, or a signal — restores the +# previous release instead of leaving the flip half-done. set -e can exit +# the script at any of those points, and EXIT traps do not fire on their +# own for a signal, so the trap is registered for EXIT, INT, and TERM +# alike; this function still runs either way. +# +# Runs under `set -e`, so every step that could itself fail (the relink, +# the restart) is explicitly guarded: an unguarded failure here would abort +# the trap mid-rollback, losing both the original failure's exit status and +# the diagnostic explaining what happened. Every path below ends by +# reaching the final `exit "$status"`. ROLLBACK_ARMED=0 PREVIOUS="" rollback_on_failure() { local status=$? - trap - EXIT + trap - EXIT INT TERM if [ "$ROLLBACK_ARMED" -ne 1 ] || [ "$status" -eq 0 ]; then exit "$status" fi @@ -74,8 +82,14 @@ rollback_on_failure() { echo "ERROR: previous release $PREVIOUS no longer exists; cannot roll back" >&2 exit "$status" fi - ln -sfn "$PREVIOUS" "$APP_DIR/current.new" - mv -Tf "$APP_DIR/current.new" "$APP_DIR/current" + if ! ln -sfn "$PREVIOUS" "$APP_DIR/current.new"; then + echo "ERROR: failed to prepare rollback symlink for $PREVIOUS" >&2 + exit "$status" + fi + if ! mv -Tf "$APP_DIR/current.new" "$APP_DIR/current"; then + echo "ERROR: failed to activate rollback symlink for $PREVIOUS" >&2 + exit "$status" + fi if ! sudo /usr/bin/systemctl restart "$SERVICE"; then echo "ERROR: failed to restart $SERVICE while rolling back to $PREVIOUS" >&2 exit "$status" @@ -133,23 +147,35 @@ CHECKSUM_NAMED="$(awk '{print $2}' "$BUNDLE.sha256" | sed 's|^\*||')" CHECKSUM_ACTUAL="$(sha256sum "$BUNDLE" | awk '{print $1}')" [ "$CHECKSUM_EXPECTED" = "$CHECKSUM_ACTUAL" ] || die "checksum mismatch for $BUNDLE" -# Capture the full verbose listing once so it can be screened twice (member -# names, then link targets) without piping tar's output into grep: grep -q -# exits as soon as it finds a match, which closes the pipe out from under a -# still-writing tar and makes it exit on SIGPIPE — under `pipefail` that -# turns the whole pipeline non-zero, so `if pipeline; then die; fi` sees a -# FALSE condition and the guard never fires. Capturing to a variable first -# lets tar always run to completion before anything is screened. +# Two separate captures, neither piped into grep: grep -q exits as soon as +# it finds a match, which closes the pipe out from under a still-writing +# tar and makes it exit on SIGPIPE — under `pipefail` that turns the whole +# pipeline non-zero, so `if pipeline; then die; fi` sees a FALSE condition +# and the guard never fires. Capturing to a variable first makes tar always +# run to completion before anything is screened. +# +# BUNDLE_NAMES (plain `tar -t`) is one member name per line, verbatim, and +# is grepped whole-line — not split into fields — so a name containing a +# space is still screened as a unit. GNU tar itself strips a leading "/" +# or "../" from member names by default on extraction, so this check is +# mostly defense-in-depth over tar's own behavior; it exists because not +# every tar implementation does that, and because relying on it silently +# would be exactly the kind of assumption this script exists to avoid. +# +# BUNDLE_MEMBERS (`tar -tv`) is the verbose listing, needed separately +# because `tar -t` never prints where a symlink points — only its own +# name — and a symlink's target gets no sanitization from tar at all, on +# extraction or otherwise. It renders links as "name -> target"; the +# second check below reads the text after " -> " directly rather than +# splitting the line into whitespace fields, so a target containing a +# space is still screened correctly. +BUNDLE_NAMES="$(tar -tzf "$BUNDLE")" BUNDLE_MEMBERS="$(tar -tvzf "$BUNDLE")" -if grep -Eq '^/|(^|/)\.\.(/|$)' <<<"$(awk '{print $NF}' <<<"$BUNDLE_MEMBERS")"; then +if grep -Eq '^/|(^|/)\.\.(/|$)' <<<"$BUNDLE_NAMES"; then die "bundle contains absolute or parent-relative paths" fi -# tar -t prints member names, not link targets, so a symlink (or hardlink) -# member can carry an absolute or parent-escaping target that the name-only -# screen above never sees. The verbose listing renders links as -# "name -> target"; reject any whose target escapes the release tree. if grep -Eq '(^| )l?[rwx-]{9}.* -> (/|.*\.\./)' <<<"$BUNDLE_MEMBERS"; then die "bundle contains a link pointing outside the release tree" fi @@ -194,6 +220,12 @@ echo "Smoke-checking the new release before activating it" SMOKE_PORT="$("$RELEASE/venv/bin/python" -c \ 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')" SMOKE_LOG="$(mktemp)" +SMOKE_PID="" +# Armed right after the temp file is created, before the background +# process even starts, so SMOKE_LOG cannot leak if something between here +# and the `&` below exits the script; $SMOKE_PID is expanded when the trap +# actually fires, so it picks up the real pid once one exists. +trap 'kill "$SMOKE_PID" 2>/dev/null || true; rm -f "$SMOKE_LOG"' EXIT MARTYROLOGY_MANIFEST_PATH="$RELEASE/manifest.json" \ MARTYROLOGY_DATA_PATH="$RELEASE/data/editions:$RELEASE/data/texts" \ @@ -202,7 +234,6 @@ MARTYROLOGY_CLBDR_PATH="$RELEASE/data/clbdr" \ "$RELEASE/venv/bin/uvicorn" martyrology_api.app:create_app --factory \ --host 127.0.0.1 --port "$SMOKE_PORT" >"$SMOKE_LOG" 2>&1 & SMOKE_PID=$! -trap 'kill "$SMOKE_PID" 2>/dev/null || true; rm -f "$SMOKE_LOG"' EXIT if ! wait_healthy "$SMOKE_PORT"; then kill "$SMOKE_PID" 2>/dev/null || true @@ -228,7 +259,7 @@ ln -sfn "$RELEASE" "$APP_DIR/current.new" mv -Tf "$APP_DIR/current.new" "$APP_DIR/current" ROLLBACK_ARMED=1 -trap rollback_on_failure EXIT +trap rollback_on_failure EXIT INT TERM sudo /usr/bin/systemctl restart "$SERVICE" @@ -238,7 +269,7 @@ wait_healthy "$LIVE_PORT" || die "$VERSION is unhealthy on port $LIVE_PORT" echo "$VERSION is live and healthy on port $LIVE_PORT" ROLLBACK_ARMED=0 -trap - EXIT +trap - EXIT INT TERM rm -f "$BUNDLE" "$BUNDLE.sha256" CURRENT_TARGET="$(readlink "$APP_DIR/current")" diff --git a/tests/test_deploy_script.py b/tests/test_deploy_script.py index 5ec0166..b4a4499 100644 --- a/tests/test_deploy_script.py +++ b/tests/test_deploy_script.py @@ -67,6 +67,28 @@ def _bundle_with_absolute_member(app: Path, version: str) -> Path: return payload +def _bundle_with_raw_member( + app: Path, version: str, *, name: str, symlink_target: str | None = None +) -> Path: + # Builds a TarInfo directly and calls addfile(), bypassing tarfile's own + # arcname normalization (see _bundle_with_absolute_member above) so the + # member name is stored exactly as given — leading "/", leading "../", + # and embedded spaces included — the same way a hand-crafted malicious + # archive would produce it. + payload = app / "incoming" / f"martyrology-{version}-linux-x86_64-cp312.tar.gz" + with tarfile.open(payload, "w:gz") as archive: + info = tarfile.TarInfo(name=name) + if symlink_target is not None: + info.type = tarfile.SYMTYPE + info.linkname = symlink_target + archive.addfile(info) + else: + info.size = 2 + archive.addfile(info, io.BytesIO(b"{}")) + _write_checksum(payload) + return payload + + def _bundle_with_symlink(app: Path, version: str, *, target: str) -> Path: payload = app / "incoming" / f"martyrology-{version}-linux-x86_64-cp312.tar.gz" source = app / "manifest.json" @@ -158,12 +180,12 @@ def test_rejects_an_absolute_path_member(tmp_path: Path): def test_rejects_a_symlink_member_escaping_the_release_tree(tmp_path: Path): - # The target has a space, so the name/target screen above (which reads - # only the last whitespace-delimited field of each listing line) sees - # just "copy" and misses it; the dedicated link-target regex, which - # matches on the text right after " -> " instead of a split field, still - # catches it. This is the case that makes the dedicated symlink check - # more than a duplicate of the name-based one. + # The member's own name ("escape-link") is innocuous; only its target + # escapes, and a target with a space in it at that. The name-only screen + # (plain `tar -t`) never sees link targets at all, by design, so it + # cannot catch this regardless of whitespace; only the dedicated + # verbose-listing check, which reads the text after " -> " directly + # rather than splitting the line into fields, can. app = _app_dir(tmp_path) _bundle_with_symlink(app, "1.0.0", target="/etc/passwd copy") result = _run(app, "--dry-run", "1.0.0") @@ -171,6 +193,47 @@ def test_rejects_a_symlink_member_escaping_the_release_tree(tmp_path: Path): assert "link pointing outside the release tree" in result.stderr +def test_rejects_a_symlink_whose_own_name_traverses(tmp_path: Path): + # Regression test for the round-1 regression: the fix that captured + # `tar -tvzf`'s *verbose* listing and screened `awk '{print $NF}'` over + # it never screened a link's own member name at all, only its target — + # for a symlink listing line ("name -> target"), $NF is the target, not + # the name. A single-member archive isolates this from the link-target + # check, which would otherwise also fire (on the target) and mask the + # gap in the name check. + app = _app_dir(tmp_path) + _bundle_with_raw_member( + app, + "1.0.0", + name="../../evil-name", + symlink_target="benign-relative", + ) + result = _run(app, "--dry-run", "1.0.0") + assert result.returncode != 0 + assert "absolute or parent-relative paths" in result.stderr + + +def test_rejects_a_traversal_member_with_a_space_in_its_name(tmp_path: Path): + # Regression test: with the round-1 $NF-based screen, a name containing + # a space was only screened by its last token ("sh"), missing the + # leading "../../" entirely. + app = _app_dir(tmp_path) + _bundle_with_raw_member(app, "1.0.0", name="../../etc/cron.d/evil sh") + result = _run(app, "--dry-run", "1.0.0") + assert result.returncode != 0 + assert "absolute or parent-relative paths" in result.stderr + + +def test_rejects_an_absolute_member_with_a_space_in_its_name(tmp_path: Path): + # Regression test: same $NF-splitting gap as above, for an absolute + # path ("/etc/passwd x" was only screened as "x"). + app = _app_dir(tmp_path) + _bundle_with_raw_member(app, "1.0.0", name="/etc/passwd x") + result = _run(app, "--dry-run", "1.0.0") + assert result.returncode != 0 + assert "absolute or parent-relative paths" in result.stderr + + def test_accepts_a_symlink_member_that_stays_inside_the_release_tree(tmp_path: Path): app = _app_dir(tmp_path) _bundle_with_symlink(app, "1.0.0", target="manifest.json") From 1c427cb91415afa62ef87225147b640e9d89a563 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 01:30:27 +0200 Subject: [PATCH 12/25] Fix deploy.sh: signal during flip window read as success, plus hardlink/dotdot screening Round-3 review reproduced a new Important finding in the round-2 rollback trap: on SIGTERM delivered to the deploy.sh process alone (a CI cancellation, an ssh disconnect), bash defers running an INT/TERM trap until the current foreground command completes. If that command (the `sleep 1` inside wait_healthy) finishes normally in the interim, $? in the trap is 0 -- not signal-derived -- so rollback_on_failure's early-return on status 0 skipped rollback entirely and the script exited 0 with `current` left pointing at an unverified release. Before round 2 the same signal reliably produced exit 143 (bash's default disposition) and the caller saw a failure; round 2's `trap rollback_on_failure EXIT INT TERM` made that specific case silently look like success instead. Fixed by giving INT/TERM their own explicit status so a signal can never present as 0: trap rollback_on_failure EXIT trap 'rollback_on_failure 143' INT TERM `local status="${1:-$?}"` in the handler uses the explicit argument when given (a signal) and falls back to $? otherwise (a plain command failure). Verified deterministically with a standalone harness (not flaky -- matches bash's documented "defer trap until the current foreground command completes" behavior): old wiring exits 0 with no rollback message on TERM sent to the process alone; new wiring exits 143 and rolls back. Also folded in three more findings, flagged as pre-existing minors but cheap to fix in code already being touched: - The link-target screen only matched a symlink's "-> target" rendering; a hardlink's "link to target" rendering (from `tar -tv`) was not screened at all. Extended the regex to match both. (GNU tar 1.35, as installed here, proactively normalizes hard link targets during listing itself -- stripping a leading "/" and collapsing every ".." before the line is ever displayed -- so this is defense-in-depth over that specific tar implementation's own hardening, not a gap it currently leaves open on this system; documented as such rather than overstated.) - A symlink/hardlink target of exactly ".." (or ending in "..' with no trailing slash, e.g. "a/..") was not caught -- the old pattern required a trailing "/" after "..". Anchored the pattern to also match ".." at end-of-string. - `tar -tzf`/`tar -tvzf` failing under `set -e` (a corrupt or truncated bundle) aborted with a bare non-zero exit and no diagnostic. Both captures now die with a message naming the bundle. - The smoke-check EXIT trap is now also registered for INT/TERM, so a signal during the smoke phase kills the smoke uvicorn and removes SMOKE_LOG instead of orphaning both. New tests, most exercising the real script end-to-end; two documented as white-box/harness tests where the real path is unreachable, per the coordinator's own guidance for exactly that situation, with what each does and does not cover stated explicitly in its docstring: - Real, end-to-end: bare ".." and "a/.." symlink-target rejection, a benign hardlink acceptance (positive control), and a corrupt-bundle diagnostic. - White-box: a hardlink-target rejection test that extracts the actual link-screening regex from deploy.sh's source and runs it through the real `grep -E` binary against a synthetic listing line, since no real archive built with this system's GNU tar can produce the dangerous text the regex is meant to catch (tar already neutralizes it first). - Harness: a signal-handling regression test that extracts the actual rollback_on_failure() function body and its two trap-arming lines from deploy.sh's current source (not hand-duplicated, to avoid drift) and splices them into a minimal standalone script, then sends SIGTERM to it directly. Verified to genuinely fail when the fix is reverted (confirmed by temporarily restoring the old single-trap wiring and re-running this test alone, then restoring the fix). Co-Authored-By: Claude Opus 5 (1M context) --- scripts/deploy/deploy.sh | 53 ++++++--- tests/test_deploy_script.py | 230 ++++++++++++++++++++++++++++++++++++ 2 files changed, 269 insertions(+), 14 deletions(-) diff --git a/scripts/deploy/deploy.sh b/scripts/deploy/deploy.sh index 558566c..91be6ed 100755 --- a/scripts/deploy/deploy.sh +++ b/scripts/deploy/deploy.sh @@ -59,6 +59,18 @@ get_live_port() { # own for a signal, so the trap is registered for EXIT, INT, and TERM # alike; this function still runs either way. # +# INT/TERM are wired to call this with an explicit "143" argument instead +# of relying on the bare EXIT trap's `$?`: bash only runs a signal trap +# once the current foreground command finishes, and does not retroactively +# set $? to a signal-derived value — if the signal arrives while a plain +# external command (e.g. the `sleep 1` inside wait_healthy) is running and +# that command then completes normally on its own, $? is that command's +# own (successful) exit status, not the signal. A SIGTERM delivered to +# this process alone (a CI cancellation, an ssh disconnect) would then +# read as status 0 and skip rollback entirely, leaving `current` flipped +# to an unverified release while reporting success. Giving INT/TERM their +# own explicit, non-zero status means a signal can never present as 0. +# # Runs under `set -e`, so every step that could itself fail (the relink, # the restart) is explicitly guarded: an unguarded failure here would abort # the trap mid-rollback, losing both the original failure's exit status and @@ -68,7 +80,7 @@ ROLLBACK_ARMED=0 PREVIOUS="" rollback_on_failure() { - local status=$? + local status="${1:-$?}" trap - EXIT INT TERM if [ "$ROLLBACK_ARMED" -ne 1 ] || [ "$status" -eq 0 ]; then exit "$status" @@ -163,20 +175,30 @@ CHECKSUM_ACTUAL="$(sha256sum "$BUNDLE" | awk '{print $1}')" # would be exactly the kind of assumption this script exists to avoid. # # BUNDLE_MEMBERS (`tar -tv`) is the verbose listing, needed separately -# because `tar -t` never prints where a symlink points — only its own -# name — and a symlink's target gets no sanitization from tar at all, on -# extraction or otherwise. It renders links as "name -> target"; the -# second check below reads the text after " -> " directly rather than -# splitting the line into whitespace fields, so a target containing a -# space is still screened correctly. -BUNDLE_NAMES="$(tar -tzf "$BUNDLE")" -BUNDLE_MEMBERS="$(tar -tvzf "$BUNDLE")" +# because `tar -t` never prints where a symlink or hardlink points — only +# its own name — and a symlink's target gets no sanitization from tar at +# all, on extraction or otherwise. GNU tar does eagerly normalize hard +# link targets (both here and at extraction), but that is this tar +# implementation's behavior, not a guarantee this script can rely on, so +# both link kinds are screened the same way regardless. The verbose +# listing renders a symlink as "name -> target" and a hardlink as +# "name link to target"; the check below matches either rendering and +# reads the text right after it directly, rather than splitting the line +# into whitespace fields, so a target containing a space is still +# screened correctly. The escape patterns themselves catch an absolute +# target, a "../" anywhere in it, and also a bare ".." (or a component +# ending in "..", e.g. "a/..") with nothing after it — not just one +# followed by a slash — since that also walks up a directory. +BUNDLE_NAMES="$(tar -tzf "$BUNDLE")" \ + || die "failed to list bundle contents: $BUNDLE (corrupt or truncated?)" +BUNDLE_MEMBERS="$(tar -tvzf "$BUNDLE")" \ + || die "failed to list bundle contents: $BUNDLE (corrupt or truncated?)" if grep -Eq '^/|(^|/)\.\.(/|$)' <<<"$BUNDLE_NAMES"; then die "bundle contains absolute or parent-relative paths" fi -if grep -Eq '(^| )l?[rwx-]{9}.* -> (/|.*\.\./)' <<<"$BUNDLE_MEMBERS"; then +if grep -Eq '(^| )[hl]?[rwx-]{9}.* (->|link to) (/|.*\.\.(/|$))' <<<"$BUNDLE_MEMBERS"; then die "bundle contains a link pointing outside the release tree" fi @@ -224,8 +246,10 @@ SMOKE_PID="" # Armed right after the temp file is created, before the background # process even starts, so SMOKE_LOG cannot leak if something between here # and the `&` below exits the script; $SMOKE_PID is expanded when the trap -# actually fires, so it picks up the real pid once one exists. -trap 'kill "$SMOKE_PID" 2>/dev/null || true; rm -f "$SMOKE_LOG"' EXIT +# actually fires, so it picks up the real pid once one exists. Also covers +# INT/TERM, not just EXIT, so a signal during the smoke phase still kills +# the smoke uvicorn and removes the log instead of orphaning both. +trap 'kill "$SMOKE_PID" 2>/dev/null || true; rm -f "$SMOKE_LOG"' EXIT INT TERM MARTYROLOGY_MANIFEST_PATH="$RELEASE/manifest.json" \ MARTYROLOGY_DATA_PATH="$RELEASE/data/editions:$RELEASE/data/texts" \ @@ -248,7 +272,7 @@ echo "Smoke check passed: $EDITIONS editions" kill "$SMOKE_PID" 2>/dev/null || true rm -f "$SMOKE_LOG" -trap - EXIT +trap - EXIT INT TERM if [ -L "$APP_DIR/current" ]; then PREVIOUS="$(readlink "$APP_DIR/current")" @@ -259,7 +283,8 @@ ln -sfn "$RELEASE" "$APP_DIR/current.new" mv -Tf "$APP_DIR/current.new" "$APP_DIR/current" ROLLBACK_ARMED=1 -trap rollback_on_failure EXIT INT TERM +trap rollback_on_failure EXIT +trap 'rollback_on_failure 143' INT TERM sudo /usr/bin/systemctl restart "$SERVICE" diff --git a/tests/test_deploy_script.py b/tests/test_deploy_script.py index b4a4499..241a5b0 100644 --- a/tests/test_deploy_script.py +++ b/tests/test_deploy_script.py @@ -1,7 +1,10 @@ import hashlib import io +import re +import signal import subprocess import tarfile +import time from pathlib import Path SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "deploy" / "deploy.sh" @@ -104,6 +107,86 @@ def _bundle_with_symlink(app: Path, version: str, *, target: str) -> Path: return payload +def _bundle_with_hardlink(app: Path, version: str, *, target: str) -> Path: + payload = app / "incoming" / f"martyrology-{version}-linux-x86_64-cp312.tar.gz" + source = app / "manifest.json" + source.write_text("{}", encoding="utf-8") + with tarfile.open(payload, "w:gz") as archive: + archive.add(source, arcname="manifest.json") + link = tarfile.TarInfo(name="sibling-hardlink") + link.type = tarfile.LNKTYPE + link.linkname = target + archive.addfile(link) + source.unlink() + _write_checksum(payload) + return payload + + +def _extract_link_regex() -> str: + """Pulls the ERE used by the link-target screen directly out of + deploy.sh's current source, so a white-box test of that regex cannot + silently drift from what the script actually runs.""" + text = SCRIPT.read_text(encoding="utf-8") + lines = text.splitlines() + marker = 'die "bundle contains a link pointing outside the release tree"' + marker_idx = next(i for i, line in enumerate(lines) if marker in line) + grep_line = lines[marker_idx - 1] + match = re.search(r"grep -Eq '(.+)' <<<", grep_line) + assert match, f"could not find the link-screen grep line before: {grep_line!r}" + return match.group(1) + + +def _grep_matches(pattern: str, text: str) -> bool: + result = subprocess.run(["grep", "-Eq", pattern], input=text, text=True) + return result.returncode == 0 + + +def _extract_rollback_harness_pieces() -> tuple[str, str, str]: + """Pulls the rollback_on_failure() function body and its two + trap-arming lines directly out of deploy.sh's current source, for + splicing into the signal-handling test harness below. Extracting at + test-run time (instead of hand-duplicating the logic) means a later + edit to those exact lines in deploy.sh changes what the harness + exercises too.""" + text = SCRIPT.read_text(encoding="utf-8") + lines = text.splitlines() + start_idx = next(i for i, line in enumerate(lines) if line == "rollback_on_failure() {") + end_idx = next(i for i in range(start_idx + 1, len(lines)) if lines[i] == "}") + function_text = "\n".join(lines[start_idx : end_idx + 1]) + + exit_trap_line = next(line for line in lines if line.strip() == "trap rollback_on_failure EXIT") + signal_trap_line = next( + line for line in lines if line.strip() == "trap 'rollback_on_failure 143' INT TERM" + ) + return function_text, exit_trap_line.strip(), signal_trap_line.strip() + + +def _build_signal_harness(tmp_path: Path) -> Path: + function_text, exit_trap_line, signal_trap_line = _extract_rollback_harness_pieces() + harness = tmp_path / "harness.sh" + harness.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + 'VERSION="harness-test"\n' + 'PREVIOUS=""\n' + "ROLLBACK_ARMED=0\n" + "\n" + f"{function_text}\n" + "\n" + "ROLLBACK_ARMED=1\n" + f"{exit_trap_line}\n" + f"{signal_trap_line}\n" + "\n" + "sleep 2\n" + 'echo "harness: sleep completed without a signal" >&2\n' + "ROLLBACK_ARMED=0\n" + "trap - EXIT INT TERM\n" + "exit 0\n", + encoding="utf-8", + ) + return harness + + def _run(app: Path, *args: str) -> subprocess.CompletedProcess[str]: return subprocess.run( ["bash", str(SCRIPT), *args], @@ -295,3 +378,150 @@ def test_rejects_an_unrecognised_extra_argument(tmp_path: Path): result = _run(app, "--dry-run", "1.0.0", "extra") assert result.returncode != 0 assert "usage" in result.stderr.lower() + + +def test_rejects_a_symlink_target_of_bare_dotdot(tmp_path: Path): + # The round-2 regex required a trailing "/" after ".." (".*\.\./"), so a + # target of exactly ".." -- which still walks up one directory -- was not + # caught. Verified end-to-end via a real archive: GNU tar does not + # sanitize symlink targets, so this reaches the check unmodified. + app = _app_dir(tmp_path) + _bundle_with_symlink(app, "1.0.0", target="..") + result = _run(app, "--dry-run", "1.0.0") + assert result.returncode != 0 + assert "link pointing outside the release tree" in result.stderr + + +def test_rejects_a_symlink_target_ending_in_dotdot_with_no_trailing_slash(tmp_path: Path): + # Same gap, for a target ending in a "../"-less ".." component + # ("a/.."), which also walks back up past "a". + app = _app_dir(tmp_path) + _bundle_with_symlink(app, "1.0.0", target="a/..") + result = _run(app, "--dry-run", "1.0.0") + assert result.returncode != 0 + assert "link pointing outside the release tree" in result.stderr + + +def test_accepts_a_hardlink_member_that_stays_inside_the_release_tree(tmp_path: Path): + # Real integration positive control for the widened link regex: a + # hardlink to a sibling file already in the bundle must not be + # rejected as a false positive. + app = _app_dir(tmp_path) + _bundle_with_hardlink(app, "1.0.0", target="manifest.json") + result = _run(app, "--dry-run", "1.0.0") + assert result.returncode == 0, result.stderr + assert "dry-run" in result.stdout + + +def test_link_screen_regex_rejects_an_absolute_hardlink_target(): + """White-box test, not a full script/tar integration test -- and + deliberately so, per investigation: + + GNU tar (1.35, as installed here; confirmed via `tar --version`) + proactively normalizes a hard link's target during `tar -tv` listing + itself, stripping any leading "/" and fully collapsing every ".." + component before the line is ever displayed. Verified empirically: a + hardlink target of "/etc/passwd", "../../etc/passwd", and even + "safe/../../etc/passwd" (a non-leading traversal) all list as plain + "etc/passwd", each with a `tar: Removing leading ...` warning on + stderr. Running the actual martyrology-api deploy.sh --dry-run against + a real archive built this way confirmed it: the bundle is accepted + (exit 0) both before and after the round-3 fix, because the + dangerous-looking text never reaches the screen in the first place on + this tar implementation. + + That means no real archive built with this system's tar can + discriminate old vs. new code here the way the other regression tests + in this file do -- there is no "current form fails, fixed form + passes" to demonstrate through the real script. Instead, this pulls + the actual link-screening regex out of deploy.sh's current source + (see _extract_link_regex) and runs it, via the real `grep -E` binary + deploy.sh itself uses, against a hand-written listing line in the + "name link to target" form a hardlink-to-/etc/passwd member would take + under a tar implementation that does not normalize hard link targets + (bsdtar/libarchive-based tars are known to differ here), or a future + GNU tar release that stops doing so. This proves the regex extension + itself is correct on its own terms; it does not prove today's real + script rejects today's real archives built with today's tar, because + -- on this tar implementation -- there is nothing dangerous left for + it to reject by the time it looks. + + Paired with test_accepts_a_hardlink_member_that_stays_inside_the_release_tree + (real integration, positive control) to also confirm the widened regex + does not reject a benign hardlink. + """ + regex = _extract_link_regex() + dangerous_line = ( + "hrw-r--r-- 0/0 0 1970-01-01 01:00 evil-hardlink link to /etc/passwd" + ) + safe_line = ( + "hrw-r--r-- 0/0 0 1970-01-01 01:00 sibling-hardlink link to manifest.json" + ) + assert _grep_matches(regex, dangerous_line) + assert not _grep_matches(regex, safe_line) + + +def test_rejects_a_corrupt_bundle_with_a_clear_message(tmp_path: Path): + app = _app_dir(tmp_path) + bundle = _bundle(app, "1.0.0") + # Truncate well inside the gzip stream so `tar -tzf` fails outright + # (rather than just listing an incomplete member); re-checksum the + # truncated bytes so the corruption is caught by the tar-listing step + # under test, not the earlier checksum-mismatch step. + bundle.write_bytes(bundle.read_bytes()[:10]) + _write_checksum(bundle) + result = _run(app, "--dry-run", "1.0.0") + assert result.returncode != 0 + assert "failed to list bundle contents" in result.stderr + assert str(bundle) in result.stderr + + +def test_signal_during_flip_window_rolls_back_instead_of_exiting_zero(tmp_path: Path): + """Regression test for: giving INT/TERM their own explicit status so a + signal can never present as 0 and skip rollback. + + This does not exercise deploy.sh directly: reaching the real flip + window requires a working venv, sudo, and systemd, none of which are + available here (see the module-level constraints noted in the task + brief). Per the coordinator's own guidance for this exact situation, + it instead tests the handler semantics directly, via a small harness + that splices the actual rollback_on_failure() function body and its + two trap-arming lines -- extracted at test-run time from deploy.sh's + current source, not hand-duplicated (see _extract_rollback_harness_pieces) + -- into a standalone script with a bare `sleep 2` standing in for the + `sleep 1` inside wait_healthy. + + This was verified to genuinely fail on a revert: temporarily restoring + the old `trap rollback_on_failure EXIT INT TERM` wiring (no + signal-specific argument) in deploy.sh and re-running only this test + reproduced exactly the bug report's shape -- exit 0, no "rolling back" + message in stderr -- before the wiring was restored to the fixed form. + + What this covers: the documented bash behavior that a signal trap does + not run until the current foreground command completes, and that $? + at that point reflects the completed command's own status, not the + signal -- which is what let a TERM delivered to the script process + alone (a CI cancellation, an ssh disconnect) read as a clean exit. + What it does not cover: the real ln/mv/systemctl relink, or rolling + back to an actual previous release -- PREVIOUS="" here, so the + harness's own rollback_on_failure takes its "No previous release to + roll back to" branch, which is enough to prove the handler received a + non-zero status and attempted rollback at all, rather than silently + exiting 0. + """ + harness = _build_signal_harness(tmp_path) + proc = subprocess.Popen( + ["bash", str(harness)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + time.sleep(0.3) + proc.send_signal(signal.SIGTERM) + stdout, stderr = proc.communicate(timeout=5) + assert proc.returncode != 0, ( + f"expected a non-zero exit after SIGTERM, got {proc.returncode}; " + f"stdout={stdout!r} stderr={stderr!r}" + ) + assert "activation of harness-test failed" in stderr + assert "rolling back" in stderr From 9b8819036ae0e764e3818a39aac3ec43fa476543 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 01:47:38 +0200 Subject: [PATCH 13/25] Fix deploy.sh: signal during smoke phase completed the deploy; screen links pre-tar-sanitization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smoke phase registered one `trap '' EXIT INT TERM`. A bash signal trap returns control to the script rather than terminating it, so a TERM anywhere in the smoke window ran the cleanup and then carried on into the flip, the systemctl restart and the prune — finishing a cancelled deploy and exiting 0. Before that wiring existed bash's default disposition exited 143, so the trap had turned a loud failure into a silent success. Split it the way the rollback trap already is: a named smoke_cleanup that clears its own traps first, `trap smoke_cleanup EXIT`, and `trap 'smoke_cleanup; exit 143' INT TERM`. Also take the two tar *listing* captures with -P. Without it GNU tar rewrites the listing before the screen sees it — a hard link target of `/etc/passwd` or `../../etc/passwd` lists as a harmless `etc/passwd` — so the link guard was depending on tar's own sanitization, which is exactly what these checks exist not to depend on. Extraction deliberately keeps running without -P, so tar's stripping stays as a last line of defense. The hardlink test is now a real black-box test through the script instead of a white-box regex test. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/deploy/deploy.sh | 86 +++++++++++----- tests/test_deploy_script.py | 199 ++++++++++++++++++++++++------------ 2 files changed, 195 insertions(+), 90 deletions(-) diff --git a/scripts/deploy/deploy.sh b/scripts/deploy/deploy.sh index 91be6ed..dd843c7 100755 --- a/scripts/deploy/deploy.sh +++ b/scripts/deploy/deploy.sh @@ -166,32 +166,44 @@ CHECKSUM_ACTUAL="$(sha256sum "$BUNDLE" | awk '{print $1}')" # and the guard never fires. Capturing to a variable first makes tar always # run to completion before anything is screened. # -# BUNDLE_NAMES (plain `tar -t`) is one member name per line, verbatim, and -# is grepped whole-line — not split into fields — so a name containing a -# space is still screened as a unit. GNU tar itself strips a leading "/" -# or "../" from member names by default on extraction, so this check is -# mostly defense-in-depth over tar's own behavior; it exists because not -# every tar implementation does that, and because relying on it silently -# would be exactly the kind of assumption this script exists to avoid. +# Both listings are taken with -P ("absolute names"), which tells tar to +# report what the archive actually stores rather than what tar would +# choose to install. Without it GNU tar silently rewrites the listing +# before this script can look at it — most importantly it collapses a hard +# link target of "/etc/passwd" or "../../etc/passwd" down to a harmless +# "etc/passwd" — so the screen would be checking tar's sanitized rendering +# instead of the bundle's real contents, i.e. depending on exactly the +# behavior these checks exist not to depend on. -P is deliberately NOT +# passed to the extraction below: there, tar's sanitization is a wanted +# last line of defense, and turning it off would let an absolute member +# name write outside the release tree. # -# BUNDLE_MEMBERS (`tar -tv`) is the verbose listing, needed separately +# BUNDLE_NAMES (plain `tar -P -t`) is one member name per line, verbatim, +# and is grepped whole-line — not split into fields — so a name containing +# a space is still screened as a unit. GNU tar strips a leading "/" or +# "../" from member names when it extracts (that part is unaffected by -P +# here, since extraction runs without it), so this check is defense in +# depth over tar's own behavior; it exists because not every tar +# implementation does that, and because relying on it silently would be +# exactly the kind of assumption this script exists to avoid. +# +# BUNDLE_MEMBERS (`tar -P -tv`) is the verbose listing, needed separately # because `tar -t` never prints where a symlink or hardlink points — only -# its own name — and a symlink's target gets no sanitization from tar at -# all, on extraction or otherwise. GNU tar does eagerly normalize hard -# link targets (both here and at extraction), but that is this tar -# implementation's behavior, not a guarantee this script can rely on, so -# both link kinds are screened the same way regardless. The verbose -# listing renders a symlink as "name -> target" and a hardlink as -# "name link to target"; the check below matches either rendering and +# its own name. A symlink's target gets no sanitization from tar at all, +# on extraction or otherwise; a hardlink's does, but only as this tar +# implementation's behavior, not a guarantee, and -P is what keeps that +# rewriting out of the listing so the real target is what gets screened. +# The verbose listing renders a symlink as "name -> target" and a hardlink +# as "name link to target"; the check below matches either rendering and # reads the text right after it directly, rather than splitting the line # into whitespace fields, so a target containing a space is still # screened correctly. The escape patterns themselves catch an absolute # target, a "../" anywhere in it, and also a bare ".." (or a component # ending in "..", e.g. "a/..") with nothing after it — not just one # followed by a slash — since that also walks up a directory. -BUNDLE_NAMES="$(tar -tzf "$BUNDLE")" \ +BUNDLE_NAMES="$(tar -P -tzf "$BUNDLE")" \ || die "failed to list bundle contents: $BUNDLE (corrupt or truncated?)" -BUNDLE_MEMBERS="$(tar -tvzf "$BUNDLE")" \ +BUNDLE_MEMBERS="$(tar -P -tvzf "$BUNDLE")" \ || die "failed to list bundle contents: $BUNDLE (corrupt or truncated?)" if grep -Eq '^/|(^|/)\.\.(/|$)' <<<"$BUNDLE_NAMES"; then @@ -219,6 +231,9 @@ fi echo "Installing $VERSION to $RELEASE" rm -rf "$RELEASE" mkdir -p "$RELEASE" +# No -P here, unlike the listings above: extraction keeps tar's own +# stripping of leading "/" and "../" as a last line of defense behind the +# screen that has already run. tar -xzf "$BUNDLE" -C "$RELEASE" echo "Building venv (offline)" @@ -243,13 +258,34 @@ SMOKE_PORT="$("$RELEASE/venv/bin/python" -c \ 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')" SMOKE_LOG="$(mktemp)" SMOKE_PID="" + # Armed right after the temp file is created, before the background # process even starts, so SMOKE_LOG cannot leak if something between here # and the `&` below exits the script; $SMOKE_PID is expanded when the trap -# actually fires, so it picks up the real pid once one exists. Also covers -# INT/TERM, not just EXIT, so a signal during the smoke phase still kills -# the smoke uvicorn and removes the log instead of orphaning both. -trap 'kill "$SMOKE_PID" 2>/dev/null || true; rm -f "$SMOKE_LOG"' EXIT INT TERM +# actually fires, so it picks up the real pid once one exists. +# +# INT/TERM are registered separately from EXIT, and their handler ends in +# an explicit `exit 143`, for the same reason the rollback trap below +# splits them: a bash trap for a signal does NOT terminate the script. It +# runs and then returns control to wherever execution was, so a single +# `trap '' EXIT INT TERM` would clean up on a TERM and then carry +# straight on into the flip, the systemctl restart and the prune — turning +# a cancelled deploy into an apparently successful one. Only the EXIT case +# ends the script on its own. With no trap installed at all, bash's +# default disposition would already have exited 143 here, so this wiring +# has to reproduce that explicitly rather than weaken it. +# +# The handler's first line clears all three traps so it cannot run twice +# on a signal (once for the signal, once for the EXIT that follows). The +# body is idempotent, so that is belt-and-braces rather than load-bearing, +# but it is stated rather than assumed. +smoke_cleanup() { + trap - EXIT INT TERM + kill "$SMOKE_PID" 2>/dev/null || true + rm -f "$SMOKE_LOG" +} +trap smoke_cleanup EXIT +trap 'smoke_cleanup; exit 143' INT TERM MARTYROLOGY_MANIFEST_PATH="$RELEASE/manifest.json" \ MARTYROLOGY_DATA_PATH="$RELEASE/data/editions:$RELEASE/data/texts" \ @@ -270,9 +306,11 @@ EDITIONS="$(curl -fsS "http://127.0.0.1:${SMOKE_PORT}/healthz" \ [ "$EDITIONS" -gt 0 ] || die "smoke check served zero editions; $VERSION was not activated" echo "Smoke check passed: $EDITIONS editions" -kill "$SMOKE_PID" 2>/dev/null || true -rm -f "$SMOKE_LOG" -trap - EXIT INT TERM +# Same handler on the success path, so the teardown has exactly one +# definition and cannot drift from what the traps run; it clears its own +# traps first, which is also the disarm this phase needs before the +# rollback trap below is armed. +smoke_cleanup if [ -L "$APP_DIR/current" ]; then PREVIOUS="$(readlink "$APP_DIR/current")" diff --git a/tests/test_deploy_script.py b/tests/test_deploy_script.py index 241a5b0..de230eb 100644 --- a/tests/test_deploy_script.py +++ b/tests/test_deploy_script.py @@ -1,6 +1,5 @@ import hashlib import io -import re import signal import subprocess import tarfile @@ -122,25 +121,6 @@ def _bundle_with_hardlink(app: Path, version: str, *, target: str) -> Path: return payload -def _extract_link_regex() -> str: - """Pulls the ERE used by the link-target screen directly out of - deploy.sh's current source, so a white-box test of that regex cannot - silently drift from what the script actually runs.""" - text = SCRIPT.read_text(encoding="utf-8") - lines = text.splitlines() - marker = 'die "bundle contains a link pointing outside the release tree"' - marker_idx = next(i for i, line in enumerate(lines) if marker in line) - grep_line = lines[marker_idx - 1] - match = re.search(r"grep -Eq '(.+)' <<<", grep_line) - assert match, f"could not find the link-screen grep line before: {grep_line!r}" - return match.group(1) - - -def _grep_matches(pattern: str, text: str) -> bool: - result = subprocess.run(["grep", "-Eq", pattern], input=text, text=True) - return result.returncode == 0 - - def _extract_rollback_harness_pieces() -> tuple[str, str, str]: """Pulls the rollback_on_failure() function body and its two trap-arming lines directly out of deploy.sh's current source, for @@ -187,6 +167,59 @@ def _build_signal_harness(tmp_path: Path) -> Path: return harness +def _extract_smoke_harness_pieces() -> tuple[str, list[str]]: + """Pulls the smoke_cleanup() function body and every trap line that + arms it out of deploy.sh's current source, for splicing into the + smoke-phase signal harness below. + + The trap lines are matched loosely (any `trap ...` line mentioning + smoke_cleanup, in source order) rather than by exact text, on purpose: + that way a regression that keeps the handler but rewires the traps -- + e.g. back to a single `trap smoke_cleanup EXIT INT TERM`, or dropping + the `exit 143` from the signal handler -- still splices cleanly into + the harness and is caught by the harness's *behavioral* assertion, + instead of failing early on a text lookup that proves nothing about + what the script does at runtime. + """ + text = SCRIPT.read_text(encoding="utf-8") + lines = text.splitlines() + start_idx = next(i for i, line in enumerate(lines) if line == "smoke_cleanup() {") + end_idx = next(i for i in range(start_idx + 1, len(lines)) if lines[i] == "}") + function_text = "\n".join(lines[start_idx : end_idx + 1]) + + trap_lines = [ + line.strip() + for line in lines + if line.strip().startswith("trap ") and "smoke_cleanup" in line + ] + assert trap_lines, "found no trap line arming smoke_cleanup in deploy.sh" + return function_text, trap_lines + + +def _build_smoke_signal_harness(tmp_path: Path) -> Path: + function_text, trap_lines = _extract_smoke_harness_pieces() + harness = tmp_path / "smoke-harness.sh" + harness.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + 'SMOKE_LOG="$(mktemp)"\n' + 'SMOKE_PID=""\n' + 'echo "$SMOKE_LOG"\n' + "\n" + f"{function_text}\n" + "\n" + "\n".join(trap_lines) + "\n" + "\n" + "sleep 2\n" + # Stands in for everything deploy.sh does after a passing smoke + # check: the flip, the systemctl restart, the prune, exit 0. + 'echo "harness: CONTINUED PAST SMOKE PHASE" >&2\n' + "smoke_cleanup\n" + "exit 0\n", + encoding="utf-8", + ) + return harness + + def _run(app: Path, *args: str) -> subprocess.CompletedProcess[str]: return subprocess.run( ["bash", str(SCRIPT), *args], @@ -413,52 +446,31 @@ def test_accepts_a_hardlink_member_that_stays_inside_the_release_tree(tmp_path: assert "dry-run" in result.stdout -def test_link_screen_regex_rejects_an_absolute_hardlink_target(): - """White-box test, not a full script/tar integration test -- and - deliberately so, per investigation: - - GNU tar (1.35, as installed here; confirmed via `tar --version`) - proactively normalizes a hard link's target during `tar -tv` listing - itself, stripping any leading "/" and fully collapsing every ".." - component before the line is ever displayed. Verified empirically: a - hardlink target of "/etc/passwd", "../../etc/passwd", and even - "safe/../../etc/passwd" (a non-leading traversal) all list as plain - "etc/passwd", each with a `tar: Removing leading ...` warning on - stderr. Running the actual martyrology-api deploy.sh --dry-run against - a real archive built this way confirmed it: the bundle is accepted - (exit 0) both before and after the round-3 fix, because the - dangerous-looking text never reaches the screen in the first place on - this tar implementation. - - That means no real archive built with this system's tar can - discriminate old vs. new code here the way the other regression tests - in this file do -- there is no "current form fails, fixed form - passes" to demonstrate through the real script. Instead, this pulls - the actual link-screening regex out of deploy.sh's current source - (see _extract_link_regex) and runs it, via the real `grep -E` binary - deploy.sh itself uses, against a hand-written listing line in the - "name link to target" form a hardlink-to-/etc/passwd member would take - under a tar implementation that does not normalize hard link targets - (bsdtar/libarchive-based tars are known to differ here), or a future - GNU tar release that stops doing so. This proves the regex extension - itself is correct on its own terms; it does not prove today's real - script rejects today's real archives built with today's tar, because - -- on this tar implementation -- there is nothing dangerous left for - it to reject by the time it looks. - - Paired with test_accepts_a_hardlink_member_that_stays_inside_the_release_tree - (real integration, positive control) to also confirm the widened regex - does not reject a benign hardlink. - """ - regex = _extract_link_regex() - dangerous_line = ( - "hrw-r--r-- 0/0 0 1970-01-01 01:00 evil-hardlink link to /etc/passwd" - ) - safe_line = ( - "hrw-r--r-- 0/0 0 1970-01-01 01:00 sibling-hardlink link to manifest.json" - ) - assert _grep_matches(regex, dangerous_line) - assert not _grep_matches(regex, safe_line) +def test_rejects_a_hardlink_member_with_an_absolute_target(tmp_path: Path): + # Real end-to-end test through the actual script, made possible by the + # `-P` on deploy.sh's two *listing* captures. Without -P, GNU tar + # rewrites a hard link target of "/etc/passwd" down to a harmless + # "etc/passwd" in its own listing output before the screen can look at + # it -- so the guard was silently depending on tar's sanitization, and + # this case could previously only be tested white-box against the + # regex. With -P the listing shows "... link to /etc/passwd" verbatim + # and the real script rejects the real bundle. + app = _app_dir(tmp_path) + _bundle_with_hardlink(app, "1.0.0", target="/etc/passwd") + result = _run(app, "--dry-run", "1.0.0") + assert result.returncode != 0 + assert "link pointing outside the release tree" in result.stderr + + +def test_rejects_a_hardlink_member_with_a_parent_relative_target(tmp_path: Path): + # Companion to the absolute case: GNU tar collapses "../../etc/passwd" + # to "etc/passwd" in a non-`-P` listing too, so this is likewise only + # reachable end-to-end because the listings are taken with -P. + app = _app_dir(tmp_path) + _bundle_with_hardlink(app, "1.0.0", target="../../etc/passwd") + result = _run(app, "--dry-run", "1.0.0") + assert result.returncode != 0 + assert "link pointing outside the release tree" in result.stderr def test_rejects_a_corrupt_bundle_with_a_clear_message(tmp_path: Path): @@ -525,3 +537,58 @@ def test_signal_during_flip_window_rolls_back_instead_of_exiting_zero(tmp_path: ) assert "activation of harness-test failed" in stderr assert "rolling back" in stderr + + +def test_signal_during_smoke_phase_stops_the_deploy_instead_of_continuing(tmp_path: Path): + """Regression test for: a bash trap for a signal does not terminate the + script, it returns control to where execution was. + + With the smoke phase's cleanup registered as one + `trap '' EXIT INT TERM`, a SIGTERM landing anywhere in the + smoke window ran the cleanup and then carried straight on -- past the + smoke phase, into the flip, the systemctl restart and the prune -- + finishing a cancelled deploy and exiting 0. With no trap installed at + all (the state before that wiring was added) bash's default + disposition exited 143, so the trap had actively turned a loud failure + into a silent success. The fix registers INT/TERM separately with a + handler that ends in an explicit `exit 143`. + + Same harness technique, and same limits, as + test_signal_during_flip_window_rolls_back_instead_of_exiting_zero: + reaching the real smoke phase needs a working venv (uvicorn, the + app's data files), which is not available here, so this splices the + actual smoke_cleanup() body and every trap line arming it -- extracted + from deploy.sh at test-run time, not hand-duplicated -- into a + standalone script whose `sleep 2` stands in for the smoke window + (wait_healthy's `sleep 1` loop, or the curl|python editions pipeline + that follows it), and whose "CONTINUED PAST SMOKE PHASE" line stands + in for everything deploy.sh does after a passing smoke check. + + What it covers: that a TERM in the smoke window terminates the script + with a non-zero status and never reaches the post-smoke work, and that + the smoke log is still cleaned up on that path. What it does not + cover: killing a real uvicorn child (SMOKE_PID is empty in the + harness, so the `kill` is exercised only as a no-op), or the real + flip/restart that the "CONTINUED PAST" marker stands for. + """ + harness = _build_smoke_signal_harness(tmp_path) + proc = subprocess.Popen( + ["bash", str(harness)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + time.sleep(0.3) + proc.send_signal(signal.SIGTERM) + stdout, stderr = proc.communicate(timeout=5) + + assert "CONTINUED PAST SMOKE PHASE" not in stderr, ( + "the signal handler ran but execution continued past the smoke phase; " + f"stdout={stdout!r} stderr={stderr!r}" + ) + assert proc.returncode == 143, ( + f"expected exit 143 after SIGTERM, got {proc.returncode}; " + f"stdout={stdout!r} stderr={stderr!r}" + ) + smoke_log = Path(stdout.strip().splitlines()[0]) + assert not smoke_log.exists(), f"smoke log {smoke_log} was left behind after the signal" From c71f3874f77794db54b644a2922d9b025ca81ac6 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 01:57:23 +0200 Subject: [PATCH 14/25] Add VPS provisioning script for martyrology-api deploys --- scripts/deploy/setup-vps-deploy-user.sh | 140 ++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100755 scripts/deploy/setup-vps-deploy-user.sh diff --git a/scripts/deploy/setup-vps-deploy-user.sh b/scripts/deploy/setup-vps-deploy-user.sh new file mode 100755 index 0000000..74add84 --- /dev/null +++ b/scripts/deploy/setup-vps-deploy-user.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# +# setup-vps-deploy-user.sh — provision the VPS for martyrology-api deploys. +# +# Run ONCE on the VPS as root. Idempotent: re-runs are safe. +# +# Creates two identities with different jobs: +# martyrology-deploy — the GitHub Actions identity. Owns $APP_DIR, has no +# password and no sudo beyond two exact systemctl commands. +# martyrology — the service account the unit runs as. Read-only on the +# release tree, no login. +# +# Secrets live in /etc/martyrology/api.env (root:root 0600), which the deploy +# identity cannot read; systemd loads it as root before dropping privileges. +# Non-secret settings live in $APP_DIR/config/runtime.env, which the deploy +# script reads to learn the live port. + +set -euo pipefail + +DEPLOY_USER="martyrology-deploy" +SERVICE_USER="martyrology" +APP_DIR="/opt/martyrology" +SECRET_ENV="/etc/martyrology/api.env" +RUNTIME_ENV="$APP_DIR/config/runtime.env" +PORT="${MARTYROLOGY_PORT:-8412}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +[ "$(id -u)" -eq 0 ] || { echo "ERROR: run as root (try: sudo $0)" >&2; exit 1; } +[ -f "$SCRIPT_DIR/deploy.sh" ] || { echo "ERROR: deploy.sh not beside this script" >&2; exit 1; } + +for user in "$DEPLOY_USER" "$SERVICE_USER"; do + if ! id -u "$user" >/dev/null 2>&1; then + echo "Creating user: $user" + useradd --create-home --shell /bin/bash "$user" + passwd --lock "$user" >/dev/null + else + echo "User already exists: $user" + fi +done + +SSH_DIR="/home/$DEPLOY_USER/.ssh" +mkdir -p "$SSH_DIR" +touch "$SSH_DIR/authorized_keys" +chmod 700 "$SSH_DIR" +chmod 600 "$SSH_DIR/authorized_keys" +chown -R "$DEPLOY_USER:$DEPLOY_USER" "$SSH_DIR" + +mkdir -p "$APP_DIR"/{bin,config,incoming,releases} +chown -R "$DEPLOY_USER:$DEPLOY_USER" "$APP_DIR" +chmod 755 "$APP_DIR" + +install -o "$DEPLOY_USER" -g "$DEPLOY_USER" -m 755 "$SCRIPT_DIR/deploy.sh" "$APP_DIR/bin/deploy.sh" + +if [ ! -f "$RUNTIME_ENV" ]; then + echo "Writing $RUNTIME_ENV" + cat >"$RUNTIME_ENV" <"$SECRET_ENV" <<'EOF' +MARTYROLOGY_ZITADEL_ISSUER= +MARTYROLOGY_ZITADEL_CLIENT_ID= +MARTYROLOGY_ZITADEL_CLIENT_SECRET= +MARTYROLOGY_OPENFGA_API_URL= +MARTYROLOGY_OPENFGA_STORE_ID= +MARTYROLOGY_OPENFGA_MODEL_ID= +MARTYROLOGY_GITHUB_TOKEN= +EOF +else + echo "Keeping existing $SECRET_ENV" +fi +chown root:root "$SECRET_ENV" +chmod 600 "$SECRET_ENV" + +# Validate before installing: a malformed sudoers file breaks sudo host-wide. +SUDOERS_TMP="$(mktemp)" +cat >"$SUDOERS_TMP" </dev/null || { rm -f "$SUDOERS_TMP"; echo "ERROR: generated sudoers is invalid" >&2; exit 1; } +install -o root -g root -m 440 "$SUDOERS_TMP" /etc/sudoers.d/martyrology-deploy +rm -f "$SUDOERS_TMP" + +cat >/etc/systemd/system/martyrology-api.service < +5. Confirm port $PORT is free: + ss -ltnp | sort -t: -k2 -n +6. Add the nginx proxy directives for the domain in Plesk (spec §6). +7. In the martyrology-api repo settings: + Secrets: VPS_HOST, VPS_SSH_KEY (private half), VPS_USERNAME=$DEPLOY_USER, + SUBMODULE_TOKEN + Variables: VPS_HOST_KEY (ssh-keyscan output), APP_DIR=$APP_DIR +8. Publish a GitHub release to trigger the first deploy. +EOF From a81d79c0c5d604fbf07cb1960b3fecaa6c0813dd Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 02:07:20 +0200 Subject: [PATCH 15/25] Fix round 1: harden VPS provisioning script - chmod the APP_DIR subdirectories explicitly (not just APP_DIR itself) so the service account's read access doesn't depend on the deploy user's umask or CI tar member modes; add a post-provision self-check that fails loudly if the service account can't traverse APP_DIR or read runtime.env. - Check for python3.12, curl, tar, sha256sum, and python3.12-venv up front so a missing prerequisite fails at provisioning time, not mid-deploy. - Give the service account no login shell (--system --shell /usr/sbin/nologin --no-create-home), matching the spec and the script's own header comment; the deploy account keeps its shell. - Verify /etc/sudoers pulls in /etc/sudoers.d before installing the sudoers drop-in, so a missing includedir fails here instead of at the first deploy's sudo call. - Print the port actually in force (read back from runtime.env) instead of the process default on re-runs, note that overriding it needs `sudo -E`, and warn in the banner that an unfilled secrets file leaves auth/authz disabled. --- scripts/deploy/setup-vps-deploy-user.sh | 59 +++++++++++++++++++++---- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/scripts/deploy/setup-vps-deploy-user.sh b/scripts/deploy/setup-vps-deploy-user.sh index 74add84..8e22491 100755 --- a/scripts/deploy/setup-vps-deploy-user.sh +++ b/scripts/deploy/setup-vps-deploy-user.sh @@ -28,15 +28,34 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" [ "$(id -u)" -eq 0 ] || { echo "ERROR: run as root (try: sudo $0)" >&2; exit 1; } [ -f "$SCRIPT_DIR/deploy.sh" ] || { echo "ERROR: deploy.sh not beside this script" >&2; exit 1; } -for user in "$DEPLOY_USER" "$SERVICE_USER"; do - if ! id -u "$user" >/dev/null 2>&1; then - echo "Creating user: $user" - useradd --create-home --shell /bin/bash "$user" - passwd --lock "$user" >/dev/null - else - echo "User already exists: $user" - fi +# deploy.sh needs these at deploy time; fail loudly now rather than mid-deploy. +for cmd in python3.12 curl tar sha256sum; do + command -v "$cmd" >/dev/null || { echo "ERROR: required command not found: $cmd" >&2; exit 1; } done +python3.12 -m venv --help >/dev/null 2>&1 \ + || { echo "ERROR: python3.12-venv is not installed (apt install python3.12-venv)" >&2; exit 1; } + +# The sudoers drop-in below is useless if the host's sudoers doesn't pull in +# /etc/sudoers.d — catch that now instead of at first deploy. +grep -qE '^[#@]includedir[[:space:]]+/etc/sudoers\.d' /etc/sudoers \ + || { echo "ERROR: /etc/sudoers has no includedir for /etc/sudoers.d" >&2; exit 1; } + +if ! id -u "$DEPLOY_USER" >/dev/null 2>&1; then + echo "Creating user: $DEPLOY_USER" + useradd --create-home --shell /bin/bash "$DEPLOY_USER" + passwd --lock "$DEPLOY_USER" >/dev/null +else + echo "User already exists: $DEPLOY_USER" +fi + +# No login: the service account only ever runs the unit, never a shell. +if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then + echo "Creating user: $SERVICE_USER" + useradd --system --shell /usr/sbin/nologin --no-create-home "$SERVICE_USER" + passwd --lock "$SERVICE_USER" >/dev/null +else + echo "User already exists: $SERVICE_USER" +fi SSH_DIR="/home/$DEPLOY_USER/.ssh" mkdir -p "$SSH_DIR" @@ -48,6 +67,10 @@ chown -R "$DEPLOY_USER:$DEPLOY_USER" "$SSH_DIR" mkdir -p "$APP_DIR"/{bin,config,incoming,releases} chown -R "$DEPLOY_USER:$DEPLOY_USER" "$APP_DIR" chmod 755 "$APP_DIR" +# martyrology and martyrology-deploy share no group, so traversal into the +# release tree depends entirely on these world bits — don't leave them to +# the deploy user's umask or the CI runner's tar member modes. +chmod 755 "$APP_DIR"/{bin,config,incoming,releases} install -o "$DEPLOY_USER" -g "$DEPLOY_USER" -m 755 "$SCRIPT_DIR/deploy.sh" "$APP_DIR/bin/deploy.sh" @@ -115,22 +138,40 @@ EOF systemctl daemon-reload systemctl enable martyrology-api.service +# Fail loudly now if the service account can't read what it needs, rather +# than at first start with a bare "Permission denied" from ExecStart. +if ! sudo -u "$SERVICE_USER" test -x "$APP_DIR" || ! sudo -u "$SERVICE_USER" test -r "$RUNTIME_ENV"; then + echo "ERROR: $SERVICE_USER cannot traverse $APP_DIR or read $RUNTIME_ENV — check the host umask" >&2 + exit 1 +fi + +# Report the port actually in force, not the default — a re-run that keeps +# an existing runtime.env may have a different value than $PORT. +ACTUAL_PORT="$(grep -E '^MARTYROLOGY_PORT=' "$RUNTIME_ENV" | cut -d= -f2 || true)" +ACTUAL_PORT="${ACTUAL_PORT:-$PORT}" + cat < -5. Confirm port $PORT is free: +5. Confirm port $ACTUAL_PORT is free: ss -ltnp | sort -t: -k2 -n + (to change the port on a re-run, use "sudo -E MARTYROLOGY_PORT= $0" + — plain sudo resets the environment and MARTYROLOGY_PORT would be lost) 6. Add the nginx proxy directives for the domain in Plesk (spec §6). 7. In the martyrology-api repo settings: Secrets: VPS_HOST, VPS_SSH_KEY (private half), VPS_USERNAME=$DEPLOY_USER, From 0cba733b1b31a9ed8f248115d54b3eb3abfad27d Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 02:18:20 +0200 Subject: [PATCH 16/25] Normalise world permissions on the release tree at deploy time The martyrology service account shares no group with martyrology-deploy, so its ability to traverse and read releases// depends entirely on world permission bits. Provisioning now locks down APP_DIR and its immediate subdirectories, but the release tree itself is created here, taking its modes from this script's umask and from CI-runner tar member modes -- neither guaranteed permissive. On a hardened host (UMASK 027, pam_umask) the deploy reports success and systemd then fails ExecStart with Permission denied. chmod -R a+rX "$RELEASE" after the venv is built normalises this; capital X keeps data files from becoming spuriously executable. A follow-up self-check (find + die) proves the chmod actually stuck, without requiring any sudo grant beyond what's already provisioned. --- scripts/deploy/deploy.sh | 28 ++++++++ tests/test_deploy_script.py | 138 ++++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/scripts/deploy/deploy.sh b/scripts/deploy/deploy.sh index dd843c7..d84d99b 100755 --- a/scripts/deploy/deploy.sh +++ b/scripts/deploy/deploy.sh @@ -241,6 +241,34 @@ python3.12 -m venv "$RELEASE/venv" "$RELEASE/venv/bin/pip" install --quiet --no-index \ --find-links "$RELEASE/wheels" martyrology-api +# The service account runs the app but shares no group with the deploy user, +# so its access depends on world bits. Tar member modes come from the CI +# runner and directory modes from this script's umask, neither of which is +# guaranteed permissive; normalise them here rather than discover it when +# systemd fails to exec. Capital X (not lowercase x) only sets the execute +# bit on directories and on files that already have an execute bit +# somewhere, so it does not make every JSON data file executable. Runs once, +# after the tree is complete, not per-file or in a loop. +chmod -R a+rX "$RELEASE" + +# The chmod above is the fix; this proves it stuck, without impersonating +# the service account (this script has no sudo grant for that — see the +# provisioning script's own `sudo -u martyrology test -x/-r` check, which +# runs as root at provisioning time, not here). find's own recursion already +# refuses to descend into a directory it cannot execute, so if a directory +# were left non-traversable, find would surface exactly the entries below +# it that it could still see, plus its own "Permission denied" on stderr; +# either way the failure is captured here rather than left to be discovered +# by systemd. `|| true` on the capture is deliberate: it exists so a nonzero +# exit from find (e.g. that same permission error) still reaches the +# is-empty check below instead of tripping `set -e` and discarding the +# diagnostic before it can be printed. +UNREADABLE="$(find "$RELEASE" \( -type d ! -perm -o+x \) -o \( -type f ! -perm -o+r \) 2>&1)" || true +if [ -n "$UNREADABLE" ]; then + echo "$UNREADABLE" >&2 + die "release tree is not fully world-readable/traversable after chmod" +fi + # Validate the manifest with the reader the app itself uses, so a bundle whose # manifest this release cannot parse is rejected before it is ever activated. "$RELEASE/venv/bin/python" - "$RELEASE/manifest.json" <<'PY' || die "manifest validation failed" diff --git a/tests/test_deploy_script.py b/tests/test_deploy_script.py index de230eb..e919dc8 100644 --- a/tests/test_deploy_script.py +++ b/tests/test_deploy_script.py @@ -121,6 +121,37 @@ def _bundle_with_hardlink(app: Path, version: str, *, target: str) -> Path: return payload +def _extract_line(exact_text: str) -> str: + """Pulls one exact line out of deploy.sh's current source, stripped of + leading indentation, at test-run time rather than hand-duplicating it — + same rationale as _extract_rollback_harness_pieces below. Raises (and so + fails the test) if the line is not found, e.g. because it was reworded + or removed.""" + text = SCRIPT.read_text(encoding="utf-8") + line = next(line for line in text.splitlines() if line.strip() == exact_text) + return line.strip() + + +def _extract_die_function() -> str: + text = SCRIPT.read_text(encoding="utf-8") + lines = text.splitlines() + start_idx = next(i for i, line in enumerate(lines) if line == "die() {") + end_idx = next(i for i in range(start_idx + 1, len(lines)) if lines[i] == "}") + return "\n".join(lines[start_idx : end_idx + 1]) + + +def _extract_world_readability_selfcheck() -> str: + """Pulls the UNREADABLE=... / if / echo / die / fi block that follows + the `chmod -R a+rX "$RELEASE"` line out of deploy.sh's current source.""" + text = SCRIPT.read_text(encoding="utf-8") + lines = text.splitlines() + start_idx = next( + i for i, line in enumerate(lines) if line.startswith('UNREADABLE="$(find "$RELEASE"') + ) + end_idx = next(i for i in range(start_idx + 1, len(lines)) if lines[i].strip() == "fi") + return "\n".join(lines[start_idx : end_idx + 1]) + + def _extract_rollback_harness_pieces() -> tuple[str, str, str]: """Pulls the rollback_on_failure() function body and its two trap-arming lines directly out of deploy.sh's current source, for @@ -488,6 +519,113 @@ def test_rejects_a_corrupt_bundle_with_a_clear_message(tmp_path: Path): assert str(bundle) in result.stderr +def test_chmod_normalises_world_permissions_on_the_release_tree(tmp_path: Path): + """Regression test for the missing-world-bits fix: on a host with a + restrictive umask (e.g. UMASK 027) or tar member modes that came out of + the CI runner non-permissive, the release tree's directories and files + would not be traversable/readable by the martyrology service account, + which shares no group with the deploy user and depends entirely on + world bits. The deploy completes and reports success; the unit then + dies with "Permission denied" on ExecStart. + + Reaching this line through a real end-to-end `deploy.sh ` run + would require a working `python3.12 -m venv` plus a real installable + martyrology-api wheel for `pip install --no-index`, neither available + in this suite (the same constraint noted for the venv/systemd-dependent + paths elsewhere in this file). Instead this splices the literal + `chmod -R a+rX "$RELEASE"` line out of deploy.sh's current source + (extracted at test-run time, not hand-duplicated, via the same pattern + used for the rollback and smoke harnesses above) and runs it directly + against a tree built under a restrictive umask, so a revert of that + exact line is what makes this test fail. + + Also proves the capital-X distinction the fix depends on: a plain data + file with no execute bit anywhere must NOT gain one (that's what + lowercase `x` would have done, making every JSON file "executable"), + while a file that already had an owner execute bit does gain the + world execute bit, and both directories become traversable. + """ + chmod_line = _extract_line('chmod -R a+rX "$RELEASE"') + + release = tmp_path / "release" + (release / "sub").mkdir(parents=True) + data_file = release / "sub" / "manifest.json" + script_file = release / "sub" / "run.sh" + + data_file.write_text("{}", encoding="utf-8") + script_file.write_text("#!/bin/sh\n", encoding="utf-8") + script_file.chmod(0o700) + data_file.chmod(0o600) + (release / "sub").chmod(0o700) + release.chmod(0o700) + + result = subprocess.run( + ["bash", "-c", f"RELEASE={release}\n{chmod_line}\n"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + + assert release.stat().st_mode & 0o007 == 0o005, "release dir must be world r-x" + assert (release / "sub").stat().st_mode & 0o007 == 0o005, "subdir must be world r-x" + assert data_file.stat().st_mode & 0o007 == 0o004, ( + "a data file with no execute bit must gain world-read only, " + "never world-execute (that would mean lowercase x was used, not X)" + ) + assert script_file.stat().st_mode & 0o007 == 0o005, ( + "a file that already had an owner execute bit must gain world-execute too" + ) + + +def test_world_readability_selfcheck_fails_loudly_on_a_non_traversable_tree(tmp_path: Path): + """The chmod above is the fix; this exercises the belt-and-braces + self-check that follows it in deploy.sh (the UNREADABLE=... / die + block), on its own, against a tree that was deliberately left + non-traversable -- standing in for the chmod silently not taking full + effect (e.g. a filesystem quirk, or a later code change that adds a + step after the chmod without re-running it). Splices the literal + die() function and the literal self-check block out of deploy.sh's + current source, same rationale as the harnesses above: a revert of + either piece is what makes this test fail, not a hand-duplicated + stand-in that could drift from the real script. + """ + die_fn = _extract_die_function() + selfcheck = _extract_world_readability_selfcheck() + + release = tmp_path / "release" + (release / "locked").mkdir(parents=True) + (release / "locked").chmod(0o700) # not world-traversable, deliberately + release.chmod(0o755) + + script = f'RELEASE={release}\n{die_fn}\n{selfcheck}\necho "REACHED END" >&2\n' + result = subprocess.run(["bash", "-c", script], capture_output=True, text=True) + + assert result.returncode != 0, result.stderr + assert "not fully world-readable" in result.stderr + assert "REACHED END" not in result.stderr + + +def test_world_readability_selfcheck_passes_once_chmod_has_run(tmp_path: Path): + """Companion positive control: the same self-check block, on the same + kind of deliberately-locked-down tree, but this time preceded by the + real chmod line -- proving the two pieces work together as they do in + the real script, not just each in isolation.""" + chmod_line = _extract_line('chmod -R a+rX "$RELEASE"') + die_fn = _extract_die_function() + selfcheck = _extract_world_readability_selfcheck() + + release = tmp_path / "release" + (release / "locked").mkdir(parents=True) + (release / "locked").chmod(0o700) + release.chmod(0o755) + + script = f'RELEASE={release}\n{die_fn}\n{chmod_line}\n{selfcheck}\necho "REACHED END" >&2\n' + result = subprocess.run(["bash", "-c", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert "REACHED END" in result.stderr + + def test_signal_during_flip_window_rolls_back_instead_of_exiting_zero(tmp_path: Path): """Regression test for: giving INT/TERM their own explicit status so a signal can never present as 0 and skip rollback. From cf89d0006dcd55fdd815d257bd38f16a942f2d3f Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 02:20:20 +0200 Subject: [PATCH 17/25] Track uv.lock so release builds resolve deterministically --- uv.lock | 823 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 823 insertions(+) create mode 100644 uv.lock diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..2e25787 --- /dev/null +++ b/uv.lock @@ -0,0 +1,823 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[[package]] +name = "fastapi" +version = "0.139.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428, upload-time = "2026-07-16T15:06:17.912Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234, upload-time = "2026-07-16T15:06:19.557Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/4e/fc534e5c8e9d6df4598cda50bd3df936ba9af495f932dc93376597b2f6db/httpcore2-2.9.0.tar.gz", hash = "sha256:03077a578e26d6166d831f68319326bf1d92cb851946292d2158a85e9da1896a", size = 67072, upload-time = "2026-07-23T14:09:49.949Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/00/bf7357a92f1807f654c3bb303b3e158294ec6a1313c79511fc9f1a45de2b/httpcore2-2.9.0-py3-none-any.whl", hash = "sha256:e51e54521809628d2014da8d67eecd4de738d54f556d646069fa9cb63fc9a9dc", size = 82800, upload-time = "2026-07-23T14:09:46.868Z" }, +] + +[[package]] +name = "httpx2" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/18/b79756b348664b13253a5811230daa1f33b3f0e86df4409b26199f5e9c3e/httpx2-2.9.0.tar.gz", hash = "sha256:85e33a04e7b0ad24044a62affa916140f363f19ba38e15fbc7e6aba1f1fb2775", size = 95340, upload-time = "2026-07-23T14:09:51.094Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/7d/354002b85409c6fe6f3f6b97624f1d11a4ddf701cd9a7ed2e161c73f0d86/httpx2-2.9.0-py3-none-any.whl", hash = "sha256:cc8e82fa3dc02fa91586dc6ccb3c9bd802a5a48adf4626d66553e793de67cbb4", size = 91107, upload-time = "2026-07-23T14:09:48.434Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-path" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/79/cd02a4df6d9270efdc7d3feefe6edd730b0820c39eeaa107a2faee8322d5/jsonschema_path-0.5.0.tar.gz", hash = "sha256:493b156ba895c97602655b620a8456caa2ce08c1aa389f5a7addec065e6e855c", size = 19597, upload-time = "2026-05-19T20:45:00.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/2c/9e69d73c4297508be9e3b64a970ea3971b3eb8db64ffc5802d40bd25981f/jsonschema_path-0.5.0-py3-none-any.whl", hash = "sha256:2790a070bc7abb08ea3dbe4d340ece4efadf639223001f020c7503229ba068e2", size = 24077, upload-time = "2026-05-19T20:44:59.225Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "lazy-object-proxy" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/a2/69df9c6ba6d316cfd81fe2381e464db3e6de5db45f8c43c6a23504abf8cb/lazy_object_proxy-1.12.0.tar.gz", hash = "sha256:1f5a462d92fd0cfb82f1fab28b51bfb209fabbe6aabf7f0d51472c0c124c0c61", size = 43681, upload-time = "2025-08-22T13:50:06.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/1b/b5f5bd6bda26f1e15cd3232b223892e4498e34ec70a7f4f11c401ac969f1/lazy_object_proxy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ee0d6027b760a11cc18281e702c0309dd92da458a74b4c15025d7fc490deede", size = 26746, upload-time = "2025-08-22T13:42:37.572Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/314889b618075c2bfc19293ffa9153ce880ac6153aacfd0a52fcabf21a66/lazy_object_proxy-1.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4ab2c584e3cc8be0dfca422e05ad30a9abe3555ce63e9ab7a559f62f8dbc6ff9", size = 71457, upload-time = "2025-08-22T13:42:38.743Z" }, + { url = "https://files.pythonhosted.org/packages/11/53/857fc2827fc1e13fbdfc0ba2629a7d2579645a06192d5461809540b78913/lazy_object_proxy-1.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14e348185adbd03ec17d051e169ec45686dcd840a3779c9d4c10aabe2ca6e1c0", size = 71036, upload-time = "2025-08-22T13:42:40.184Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/e581ffed864cd33c1b445b5763d617448ebb880f48675fc9de0471a95cbc/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c4fcbe74fb85df8ba7825fa05eddca764138da752904b378f0ae5ab33a36c308", size = 69329, upload-time = "2025-08-22T13:42:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/78/be/15f8f5a0b0b2e668e756a152257d26370132c97f2f1943329b08f057eff0/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:563d2ec8e4d4b68ee7848c5ab4d6057a6d703cb7963b342968bb8758dda33a23", size = 70690, upload-time = "2025-08-22T13:42:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/5d/aa/f02be9bbfb270e13ee608c2b28b8771f20a5f64356c6d9317b20043c6129/lazy_object_proxy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:53c7fd99eb156bbb82cbc5d5188891d8fdd805ba6c1e3b92b90092da2a837073", size = 26563, upload-time = "2025-08-22T13:42:43.685Z" }, + { url = "https://files.pythonhosted.org/packages/f4/26/b74c791008841f8ad896c7f293415136c66cc27e7c7577de4ee68040c110/lazy_object_proxy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:86fd61cb2ba249b9f436d789d1356deae69ad3231dc3c0f17293ac535162672e", size = 26745, upload-time = "2025-08-22T13:42:44.982Z" }, + { url = "https://files.pythonhosted.org/packages/9b/52/641870d309e5d1fb1ea7d462a818ca727e43bfa431d8c34b173eb090348c/lazy_object_proxy-1.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81d1852fb30fab81696f93db1b1e55a5d1ff7940838191062f5f56987d5fcc3e", size = 71537, upload-time = "2025-08-22T13:42:46.141Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/919118e99d51c5e76e8bf5a27df406884921c0acf2c7b8a3b38d847ab3e9/lazy_object_proxy-1.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9045646d83f6c2664c1330904b245ae2371b5c57a3195e4028aedc9f999655", size = 71141, upload-time = "2025-08-22T13:42:47.375Z" }, + { url = "https://files.pythonhosted.org/packages/e5/47/1d20e626567b41de085cf4d4fb3661a56c159feaa73c825917b3b4d4f806/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:67f07ab742f1adfb3966c40f630baaa7902be4222a17941f3d85fd1dae5565ff", size = 69449, upload-time = "2025-08-22T13:42:48.49Z" }, + { url = "https://files.pythonhosted.org/packages/58/8d/25c20ff1a1a8426d9af2d0b6f29f6388005fc8cd10d6ee71f48bff86fdd0/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ba769017b944fcacbf6a80c18b2761a1795b03f8899acdad1f1c39db4409be", size = 70744, upload-time = "2025-08-22T13:42:49.608Z" }, + { url = "https://files.pythonhosted.org/packages/c0/67/8ec9abe15c4f8a4bcc6e65160a2c667240d025cbb6591b879bea55625263/lazy_object_proxy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:7b22c2bbfb155706b928ac4d74c1a63ac8552a55ba7fff4445155523ea4067e1", size = 26568, upload-time = "2025-08-22T13:42:57.719Z" }, + { url = "https://files.pythonhosted.org/packages/23/12/cd2235463f3469fd6c62d41d92b7f120e8134f76e52421413a0ad16d493e/lazy_object_proxy-1.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4a79b909aa16bde8ae606f06e6bbc9d3219d2e57fb3e0076e17879072b742c65", size = 27391, upload-time = "2025-08-22T13:42:50.62Z" }, + { url = "https://files.pythonhosted.org/packages/60/9e/f1c53e39bbebad2e8609c67d0830cc275f694d0ea23d78e8f6db526c12d3/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:338ab2f132276203e404951205fe80c3fd59429b3a724e7b662b2eb539bb1be9", size = 80552, upload-time = "2025-08-22T13:42:51.731Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/6c513693448dcb317d9d8c91d91f47addc09553613379e504435b4cc8b3e/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c40b3c9faee2e32bfce0df4ae63f4e73529766893258eca78548bac801c8f66", size = 82857, upload-time = "2025-08-22T13:42:53.225Z" }, + { url = "https://files.pythonhosted.org/packages/12/1c/d9c4aaa4c75da11eb7c22c43d7c90a53b4fca0e27784a5ab207768debea7/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:717484c309df78cedf48396e420fa57fc8a2b1f06ea889df7248fdd156e58847", size = 80833, upload-time = "2025-08-22T13:42:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ae/29117275aac7d7d78ae4f5a4787f36ff33262499d486ac0bf3e0b97889f6/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b7ea5ea1ffe15059eb44bcbcb258f97bcb40e139b88152c40d07b1a1dfc9ac", size = 79516, upload-time = "2025-08-22T13:42:55.812Z" }, + { url = "https://files.pythonhosted.org/packages/19/40/b4e48b2c38c69392ae702ae7afa7b6551e0ca5d38263198b7c79de8b3bdf/lazy_object_proxy-1.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:08c465fb5cd23527512f9bd7b4c7ba6cec33e28aad36fbbe46bf7b858f9f3f7f", size = 27656, upload-time = "2025-08-22T13:42:56.793Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3a/277857b51ae419a1574557c0b12e0d06bf327b758ba94cafc664cb1e2f66/lazy_object_proxy-1.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c9defba70ab943f1df98a656247966d7729da2fe9c2d5d85346464bf320820a3", size = 26582, upload-time = "2025-08-22T13:49:49.366Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/c5e0fa43535bb9c87880e0ba037cdb1c50e01850b0831e80eb4f4762f270/lazy_object_proxy-1.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6763941dbf97eea6b90f5b06eb4da9418cc088fce0e3883f5816090f9afcde4a", size = 71059, upload-time = "2025-08-22T13:49:50.488Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/7dcad19c685963c652624702f1a968ff10220b16bfcc442257038216bf55/lazy_object_proxy-1.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdc70d81235fc586b9e3d1aeef7d1553259b62ecaae9db2167a5d2550dcc391a", size = 71034, upload-time = "2025-08-22T13:49:54.224Z" }, + { url = "https://files.pythonhosted.org/packages/12/ac/34cbfb433a10e28c7fd830f91c5a348462ba748413cbb950c7f259e67aa7/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0a83c6f7a6b2bfc11ef3ed67f8cbe99f8ff500b05655d8e7df9aab993a6abc95", size = 69529, upload-time = "2025-08-22T13:49:55.29Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6a/11ad7e349307c3ca4c0175db7a77d60ce42a41c60bcb11800aabd6a8acb8/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:256262384ebd2a77b023ad02fbcc9326282bcfd16484d5531154b02bc304f4c5", size = 70391, upload-time = "2025-08-22T13:49:56.35Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/9b410ed8fbc6e79c1ee8b13f8777a80137d4bc189caf2c6202358e66192c/lazy_object_proxy-1.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7601ec171c7e8584f8ff3f4e440aa2eebf93e854f04639263875b8c2971f819f", size = 26988, upload-time = "2025-08-22T13:49:57.302Z" }, +] + +[[package]] +name = "martyrology-api" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "httpx2" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "uvicorn" }, +] + +[package.optional-dependencies] +dev = [ + { name = "openapi-spec-validator" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.111" }, + { name = "httpx2", specifier = ">=2.0" }, + { name = "openapi-spec-validator", marker = "extra == 'dev'", specifier = ">=0.7" }, + { name = "pydantic", specifier = ">=2.7" }, + { name = "pydantic-settings", specifier = ">=2.3" }, + { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" }, + { name = "uvicorn", specifier = ">=0.30" }, +] +provides-extras = ["dev"] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "openapi-schema-validator" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "jsonschema-specifications" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "referencing" }, + { name = "rfc3339-validator" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/e8/ab3f27dbca54ec645f7fab714b640907d5d36c2ebb07e87eebd30bd5c81b/openapi_schema_validator-0.9.0.tar.gz", hash = "sha256:b72db64315b89d21834cd3ffef37e3e6893bc876327be2d366e8424b1029afd3", size = 24686, upload-time = "2026-04-27T17:31:27.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/c0/5467967d95378b2cfce312e09cbd0c9ab64354a0922379b734f793edd04f/openapi_schema_validator-0.9.0-py3-none-any.whl", hash = "sha256:faa3bbe7c3aa8ca2087ad83f709dc3b7d920283153a570c03e24ea182558aa25", size = 19980, upload-time = "2026-04-27T17:31:25.965Z" }, +] + +[[package]] +name = "openapi-spec-validator" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "jsonschema-path" }, + { name = "lazy-object-proxy" }, + { name = "openapi-schema-validator" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/d2/640b5149cd5688bc0ad1fdbb4df6a2f7b84a093c8d787c27d566132f8b8b/openapi_spec_validator-0.9.0.tar.gz", hash = "sha256:6d648cff6490ebb799dcfe273792f2941c050158854c721f086599d845da78b8", size = 1756839, upload-time = "2026-05-20T09:23:18.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/d8/321ff889330acca2e3097f3d4f80a40bcc41b6d34d302978ab32c449520b/openapi_spec_validator-0.9.0-py3-none-any.whl", hash = "sha256:222fecffc7714f6d0a6ad62c0e4b66cc2b7dbfafb7b93acfc6c308abbdb51af8", size = 50328, upload-time = "2026-05-20T09:23:17.017Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathable" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/f3/5a20387de9bcd0607871bfc2198ee0e15836da7baa4592ccd7f24c27c986/pathable-0.6.0.tar.gz", hash = "sha256:6404b8b82aef5ff0fd478934137128b99b12212ba35afdde5525ca4f8388ea58", size = 18970, upload-time = "2026-05-19T18:15:11.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983, upload-time = "2026-05-19T18:15:10.728Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.411" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] From 9f083eb68b04b6eedafb3d49cade16bc600330ac Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 02:23:23 +0200 Subject: [PATCH 18/25] Add release deploy workflow, shellcheck CI job and token expiry watch --- .github/workflows/ci.yml | 10 ++ .github/workflows/deploy.yml | 174 +++++++++++++++++++++++ .github/workflows/token-expiry-watch.yml | 86 +++++++++++ 3 files changed, 270 insertions(+) create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/token-expiry-watch.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5ae90c..c0acea7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,3 +54,13 @@ jobs: slug: CatholicOS/martyrology-api report_type: test_results files: junit.xml + + shellcheck: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: shellcheck + run: shellcheck scripts/deploy/*.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..73c134c --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,174 @@ +name: Deploy + +# Builds a release bundle (api wheel + offline wheelhouse + the three pinned +# data trees + manifest.json), ships it to the VPS, and activates it. +# +# All ${{ ... }} interpolations are repo secrets/vars (trusted). No untrusted +# github.event.* field is used. +# +# See docs/superpowers/specs/2026-08-01-continuous-deployment-design.md + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: deploy-martyrology + cancel-in-progress: false + +jobs: + deploy: + # Pinned, never ubuntu-latest: the VPS is Ubuntu 24.04 / glibc 2.39 and the + # wheelhouse ABI must match it. + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + token: ${{ secrets.SUBMODULE_TOKEN }} + persist-credentials: false + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Resolve version + id: version + run: | + VERSION="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')" + echo "value=$VERSION" >> "$GITHUB_OUTPUT" + echo "Building martyrology-api $VERSION" + + - name: Build wheel and offline wheelhouse + run: | + pip install uv + mkdir -p staging/wheels + uv build --wheel --out-dir dist + uv export --no-dev --no-emit-project --format requirements-txt -o requirements.txt + pip wheel -r requirements.txt -w staging/wheels + cp dist/*.whl staging/wheels/ + + - name: Stage data trees + run: | + mkdir -p staging/data + cp -a data/editions staging/data/editions + cp -a vendor/texts staging/data/texts + cp -a vendor/crmedr staging/data/crmedr + cp -a vendor/clbdr staging/data/clbdr + rm -rf staging/data/*/.git + + - name: Assemble bundle + id: bundle + env: + VERSION: ${{ steps.version.outputs.value }} + run: | + mkdir -p out + BUNDLE="$(python scripts/deploy/build_bundle.py \ + --version "$VERSION" --api-version "$VERSION" \ + --staging staging --out out --repo-root .)" + NAME="$(basename "$BUNDLE")" + # Generated from inside out/ so the checksum file names the bundle + # bare; deploy.sh runs `sha256sum -c` from the incoming/ directory. + (cd out && sha256sum "$NAME" > "$NAME.sha256") + echo "path=$BUNDLE" >> "$GITHUB_OUTPUT" + ls -la out + + - name: Setup SSH + env: + VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }} + VPS_HOST_KEY: ${{ vars.VPS_HOST_KEY }} + run: | + if [ -z "$VPS_SSH_KEY" ] || [ -z "$VPS_HOST_KEY" ]; then + echo "ERROR: secrets.VPS_SSH_KEY or vars.VPS_HOST_KEY is empty." + exit 1 + fi + mkdir -p ~/.ssh + chmod 700 ~/.ssh + echo "$VPS_SSH_KEY" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + echo "$VPS_HOST_KEY" > ~/.ssh/known_hosts + chmod 644 ~/.ssh/known_hosts + + - name: Verify the pinned host key covers the target + env: + VPS_HOST: ${{ secrets.VPS_HOST }} + run: | + if ! ssh-keygen -F "$VPS_HOST" -f ~/.ssh/known_hosts >/dev/null; then + echo "ERROR: vars.VPS_HOST_KEY has no key for $VPS_HOST." + exit 1 + fi + + - name: Sanity-check the pinned key against DNS SSHFP records + continue-on-error: true + env: + VPS_HOST: ${{ secrets.VPS_HOST }} + VPS_HOST_KEY: ${{ vars.VPS_HOST_KEY }} + run: | + # Non-fatal drift detector, mirroring cdcf-website's deploy workflow. + # The pinned key is the trust anchor; DNS is only corroboration, so a + # mismatch warns rather than blocks (DNS may simply lag a rotation). + PIN_FPS=$(printf '%s\n' "$VPS_HOST_KEY" \ + | awk '$1 ~ /^(ssh-|ecdsa-)/ || $2 ~ /^(ssh-|ecdsa-)/' \ + | ssh-keygen -l -f - 2>/dev/null \ + | awk '{print $2}' | sed 's/^SHA256://' | sort -u) + if [ -z "$PIN_FPS" ]; then + echo "::warning::Could not derive fingerprints from VPS_HOST_KEY; skipping drift check." + exit 0 + fi + DNS_FPS=$(dig +short SSHFP "$VPS_HOST" 2>/dev/null | awk '{print toupper($3)}' | sort -u) + if [ -z "$DNS_FPS" ]; then + echo "::warning::No SSHFP records published for $VPS_HOST; skipping drift check." + exit 0 + fi + for fp in $PIN_FPS; do + echo "$DNS_FPS" | grep -qi "$fp" \ + || echo "::warning::Pinned key $fp not advertised in SSHFP for $VPS_HOST. Either DNS lags reality or VPS_HOST_KEY is stale." + done + + - name: Upload bundle + env: + VPS_USERNAME: ${{ secrets.VPS_USERNAME }} + VPS_HOST: ${{ secrets.VPS_HOST }} + APP_DIR: ${{ vars.APP_DIR }} + BUNDLE: ${{ steps.bundle.outputs.path }} + run: | + if [ -z "$VPS_USERNAME" ] || [ -z "$VPS_HOST" ] || [ -z "$APP_DIR" ]; then + echo "ERROR: VPS_USERNAME / VPS_HOST / APP_DIR is empty." + exit 1 + fi + for attempt in 1 2 3; do + echo "Upload attempt $attempt..." + if scp -i ~/.ssh/deploy_key \ + -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=2 \ + "$BUNDLE" "$BUNDLE.sha256" \ + "${VPS_USERNAME}@${VPS_HOST}:${APP_DIR}/incoming/"; then + echo "Upload succeeded on attempt $attempt" + exit 0 + fi + [ "$attempt" -lt 3 ] && echo "Retrying in 15s..." && sleep 15 + done + echo "All upload attempts failed" + exit 1 + + - name: Activate release + env: + VPS_USERNAME: ${{ secrets.VPS_USERNAME }} + VPS_HOST: ${{ secrets.VPS_HOST }} + APP_DIR: ${{ vars.APP_DIR }} + VERSION: ${{ steps.version.outputs.value }} + run: | + ssh -i ~/.ssh/deploy_key \ + -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=2 \ + "${VPS_USERNAME}@${VPS_HOST}" \ + "bash ${APP_DIR}/bin/deploy.sh ${VERSION}" + + - name: Attach manifest to the release + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.event.release.tag_name }} + run: gh release upload "$TAG" staging/manifest.json --clobber diff --git a/.github/workflows/token-expiry-watch.yml b/.github/workflows/token-expiry-watch.yml new file mode 100644 index 0000000..f7d071b --- /dev/null +++ b/.github/workflows/token-expiry-watch.yml @@ -0,0 +1,86 @@ +name: Token expiry watch + +# SUBMODULE_TOKEN gates the release workflow's private-submodule checkout. +# GitHub returns a fine-grained PAT's expiry in the +# GitHub-Authentication-Token-Expiration response header, so this reads the +# real expiry off the token instead of tracking a date by hand. + +on: + schedule: + - cron: "0 7 * * 1" + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + check: + runs-on: ubuntu-24.04 + steps: + - name: Check SUBMODULE_TOKEN health and expiry + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }} + REPO: ${{ github.repository }} + WATCHED: CatholicOS/martyrology-texts + WARN_DAYS: "30" + run: | + set -euo pipefail + + open_issue() { + local title="$1" body="$2" + if gh issue list --repo "$REPO" --state open --search "in:title $title" \ + --json title --jq '.[].title' | grep -Fxq "$title"; then + echo "Issue already open: $title" + return 0 + fi + gh issue create --repo "$REPO" --title "$title" --body "$body" --label dependencies + } + + status="$(curl -sS -o /dev/null -D headers.txt -w '%{http_code}' \ + -H "Authorization: Bearer $SUBMODULE_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/$WATCHED")" + + if [ "$status" != "200" ]; then + open_issue "SUBMODULE_TOKEN is not working (HTTP $status)" \ + "The scheduled token check could not read \`$WATCHED\` (HTTP $status). + + The release workflow's \`actions/checkout\` step will fail at the + \`vendor/texts\` submodule until this is fixed. Likely causes: the PAT + expired, was revoked, or its organization approval was withdrawn. + + Fix: mint a new fine-grained PAT (resource owner \`CatholicOS\`, + Contents: Read-only on \`$WATCHED\`), approve it in the org's pending + requests, then \`gh secret set SUBMODULE_TOKEN --repo $REPO --app actions\`." + exit 1 + fi + + expiry="$(grep -i '^github-authentication-token-expiration:' headers.txt \ + | sed 's/^[^:]*: *//' | tr -d '\r' || true)" + + if [ -z "$expiry" ]; then + echo "::notice::Token reports no expiration date; nothing to warn about." + exit 0 + fi + + expiry_epoch="$(date -d "$expiry" +%s)" + days_left=$(( (expiry_epoch - $(date +%s)) / 86400 )) + echo "SUBMODULE_TOKEN expires $expiry ($days_left days)" + + if [ "$days_left" -le "$WARN_DAYS" ]; then + open_issue "SUBMODULE_TOKEN expires in $days_left days ($expiry)" \ + "\`SUBMODULE_TOKEN\` expires on **$expiry** — $days_left days from now. + + When it lapses, the release workflow fails at the \`vendor/texts\` + submodule checkout, and because deploys only run on published releases + you will discover it mid-release. + + Renew: mint a fine-grained PAT (resource owner \`CatholicOS\`, + Contents: Read-only on \`$WATCHED\`), approve it in the org's pending + requests, then \`gh secret set SUBMODULE_TOKEN --repo $REPO --app actions\`. + + Longer term, an org-owned GitHub App installation token removes this + expiry cycle entirely (see the deployment spec, §3)." + fi From a120f7340f4bc9d3900c30c4900e43bbaa9cddf4 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 02:34:56 +0200 Subject: [PATCH 19/25] Fix data-tree layout, deploy permissions, and token-watch issues from review --- .github/workflows/deploy.yml | 44 ++++++++++++++++++++++-- .github/workflows/token-expiry-watch.yml | 37 ++++++++++++++++---- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 73c134c..8bf7b29 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -25,6 +25,11 @@ jobs: # Pinned, never ubuntu-latest: the VPS is Ubuntu 24.04 / glibc 2.39 and the # wheelhouse ABI must match it. runs-on: ubuntu-24.04 + # contents: write is required by the final "Attach manifest to the release" + # step's `gh release upload`. Without it that step 403s after activation + # has already succeeded, marking a successful deploy as a failed run. + permissions: + contents: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -48,7 +53,7 @@ jobs: pip install uv mkdir -p staging/wheels uv build --wheel --out-dir dist - uv export --no-dev --no-emit-project --format requirements-txt -o requirements.txt + uv export --frozen --no-dev --no-emit-project --format requirements-txt -o requirements.txt pip wheel -r requirements.txt -w staging/wheels cp dist/*.whl staging/wheels/ @@ -56,11 +61,46 @@ jobs: run: | mkdir -p staging/data cp -a data/editions staging/data/editions - cp -a vendor/texts staging/data/texts + # Store.__init__ (src/martyrology_api/store.py) walks the *children* + # of each MARTYROLOGY_DATA_PATH entry looking for one holding + # MM.json files, i.e. it expects a directory-of-editions, not the + # vendor/texts repo root. crmedr and clbdr are NOT like this — they + # are consumed whole (Registry.load reads /data/*.json and + # /i18n/) — so only texts is unwrapped here. + cp -a vendor/texts/data/editions staging/data/texts cp -a vendor/crmedr staging/data/crmedr cp -a vendor/clbdr staging/data/clbdr rm -rf staging/data/*/.git + - name: Verify staged data shape + run: | + set -euo pipefail + fail() { echo "::error::$1"; exit 1; } + + # staging/data/texts must be a directory-of-editions: at least one + # child directory containing 01.json. A glob that matches nothing + # must fail loudly, not pass vacuously, so this counts matches + # explicitly rather than trusting an unexpanded glob literal. + shopt -s nullglob + texts_editions=(staging/data/texts/*/01.json) + shopt -u nullglob + [ "${#texts_editions[@]}" -gt 0 ] \ + || fail "staging/data/texts has no /01.json; texts staging is wrong (vendor/texts copied whole instead of vendor/texts/data/editions?)" + + [ -f staging/data/crmedr/data/martyrology_ids.json ] \ + || fail "staging/data/crmedr/data/martyrology_ids.json is missing" + + [ -f staging/data/clbdr/data/editions.json ] \ + || fail "staging/data/clbdr/data/editions.json is missing" + + shopt -s nullglob + wheels=(staging/wheels/*.whl) + shopt -u nullglob + [ "${#wheels[@]}" -gt 0 ] \ + || fail "staging/wheels contains no .whl files" + + echo "Staged data shape OK: ${#texts_editions[@]} text edition(s), ${#wheels[@]} wheel(s)." + - name: Assemble bundle id: bundle env: diff --git a/.github/workflows/token-expiry-watch.yml b/.github/workflows/token-expiry-watch.yml index f7d071b..61b352b 100644 --- a/.github/workflows/token-expiry-watch.yml +++ b/.github/workflows/token-expiry-watch.yml @@ -30,18 +30,35 @@ jobs: open_issue() { local title="$1" body="$2" - if gh issue list --repo "$REPO" --state open --search "in:title $title" \ - --json title --jq '.[].title' | grep -Fxq "$title"; then + # Two separate steps, not one piped command: `grep -q` exits as + # soon as it finds a match, which can SIGPIPE a still-writing + # `gh issue list` — under `pipefail` that makes the pipeline + # non-zero, so `if pipeline; then …` reads false and a duplicate + # issue gets created anyway. Capturing to a variable first (same + # pattern as deploy.sh's tar-listing checks) makes `gh` always + # run to completion before anything screens its output. + # Also: no `--search "in:title …"` — the title contains `(`, `)` + # and `:`, which GitHub's search syntax isn't guaranteed to treat + # literally, so open issues are listed and matched in the shell + # instead. + local existing + existing="$(gh issue list --repo "$REPO" --state open --json title --jq '.[].title')" + if printf '%s\n' "$existing" | grep -Fxq "$title"; then echo "Issue already open: $title" return 0 fi gh issue create --repo "$REPO" --title "$title" --body "$body" --label dependencies } - status="$(curl -sS -o /dev/null -D headers.txt -w '%{http_code}' \ - -H "Authorization: Bearer $SUBMODULE_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - "https://api.github.com/repos/$WATCHED")" + # SUBMODULE_TOKEN is passed to curl via --config on stdin rather + # than as a -H argv value, so it never appears in the runner's + # process listing (argv is readable via /proc by other processes + # on the same host; a curl config file passed over a pipe is not). + status="$( + printf 'header = "Authorization: Bearer %s"\nheader = "Accept: application/vnd.github+json"\nurl = "https://api.github.com/repos/%s"\n' \ + "$SUBMODULE_TOKEN" "$WATCHED" \ + | curl -sS -o /dev/null -D headers.txt -w '%{http_code}' --config - + )" if [ "$status" != "200" ]; then open_issue "SUBMODULE_TOKEN is not working (HTTP $status)" \ @@ -70,7 +87,13 @@ jobs: echo "SUBMODULE_TOKEN expires $expiry ($days_left days)" if [ "$days_left" -le "$WARN_DAYS" ]; then - open_issue "SUBMODULE_TOKEN expires in $days_left days ($expiry)" \ + # $expiry, not $days_left, is the title's identifier: days_left + # decreases every weekly run (30, 23, 16, ...) so a title built + # from it would never match its own prior issue and duplicates + # would pile up across the warning window. $expiry is stable + # until the token is actually rotated, which is what dedup here + # is meant to key on. + open_issue "SUBMODULE_TOKEN expires soon ($expiry)" \ "\`SUBMODULE_TOKEN\` expires on **$expiry** — $days_left days from now. When it lapses, the release workflow fails at the \`vendor/texts\` From 16f61790d7a1be45477b25909b616c39f74f7d28 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 02:40:06 +0200 Subject: [PATCH 20/25] Mark the CD plan executed and superseded by the committed files Several code blocks in the plan contained defects that review caught only after implementation, so re-executing it verbatim would reintroduce them. Records what each defect was, so the document remains a useful history rather than a trap. Co-Authored-By: Claude Opus 5 (1M context) --- .../plans/2026-08-01-continuous-deployment.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/superpowers/plans/2026-08-01-continuous-deployment.md b/docs/superpowers/plans/2026-08-01-continuous-deployment.md index a5e85c6..5be3cb0 100644 --- a/docs/superpowers/plans/2026-08-01-continuous-deployment.md +++ b/docs/superpowers/plans/2026-08-01-continuous-deployment.md @@ -1,5 +1,33 @@ # Continuous Deployment Implementation Plan +> **STATUS: EXECUTED AND SUPERSEDED.** All six tasks were implemented and reviewed +> on branch `deployment-design`. **The committed files are authoritative; the code +> blocks below are not.** Several of them contained defects that review caught only +> after implementation, so re-executing this document verbatim would reintroduce +> them. Read `scripts/deploy/`, `.github/workflows/` and the spec at +> `docs/superpowers/specs/2026-08-01-continuous-deployment-design.md` instead. +> +> Defects that were in this plan's own code, kept here as a record of what review +> caught rather than as instructions to follow: +> +> | Where | Defect | +> |---|---| +> | Task 3 `assemble()` | Its test called `assemble()` without `write_manifest()`, so no manifest reached the tarball. Now raises instead. | +> | Task 4 traversal guard | `tar \| grep -q` under `pipefail` made tar die of SIGPIPE, so the guard silently never fired. | +> | Task 4 `rm -rf "$RELEASE"` | Ran while `current` still pointed at that release, destroying the live deployment on a redeploy. | +> | Task 4 link screening | `tar -t` prints names, never link targets, so escaping symlinks and hardlinks passed. | +> | Task 4 venv build | `pip install --upgrade pip` reached PyPI despite the "fully offline" claim. | +> | Task 4 rollback | No coverage between the symlink flip and the health check; rollback also reported success without verifying it. | +> | Task 4 checksum | `sha256sum -c` verified whatever filename the `.sha256` named, not the bundle. | +> | Task 5 permissions | Only `$APP_DIR` got an explicit mode, so the service account's access depended on the host umask. | +> | Task 5 prerequisites | `python3.12-venv` and `curl` were neither installed nor checked. | +> | Task 6 texts staging | `cp -a vendor/texts` copied the repo root, so **zero** private editions loaded while the deploy still reported success. | +> | Task 6 permissions | `contents: read` with a `gh release upload` step made every release run end red after a successful deploy. | +> +> Two further defects were introduced by fix rounds and caught by scoped re-review: +> the SIGPIPE fix broke the name screen via `awk '{print $NF}'`, and adding +> `INT TERM` to two traps made signalled deploys exit 0 with `current` left flipped. + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ship `martyrology-api` to the Plesk-managed VPS automatically on every published GitHub release, bundling the private text corpus and the two public registries into one verifiable, rollback-able artifact. From 70920a6914336d4c0698b85060c4179c5b264b26 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 03:08:16 +0200 Subject: [PATCH 21/25] Deploy: group-scoped release tree, APP_DIR passthrough, tag/version guard The release tree was left world-readable (`chmod -R a+rX`, plus 0755 directories from provisioning), which published the licensed martyrology-texts corpus under releases//data/texts to every local account on a Plesk host, where each hosted subscription runs its own non-chrooted uid. That is the disclosure the private-submodule architecture exists to prevent. The world bits were there for a real reason: the service account shared no group with the deploy user, so a hardened umask left systemd unable to read its own release. Fix both halves instead of trading one for the other. setup-vps-deploy-user.sh now creates the martyrology group explicitly, pins the service account to it, adds martyrology-deploy to it, owns the tree martyrology-deploy:martyrology and drops every "other" bit: 0750 on $APP_DIR/bin/config, 2750 on releases/ so new release directories inherit the group, 0700 on incoming/ (which holds the corpus in bundle form), 0640 on runtime.env. Its recursive chmod and the runtime.env chown/chmod run unconditionally, so a re-run retracts the world bits from trees an earlier version already wrote. deploy.sh chgrp's and chmod's each release to u+rwX,g+rX,o-rwx, and tightens the uploaded bundle to 0600 as soon as its path is verified -- it is only deleted on success, so a failed deploy used to leave a permissive copy behind indefinitely. Both permission self-checks now assert both halves, and each half fails on its own: group bits present, no "other" bit anywhere, and the group really being martyrology. Symlinks are excluded from the mode arms, since a symlink's mode is inert and chmod -R does not follow it. The provisioning script additionally proves the deploy user is in the group and that the service account cannot read incoming/. Also in the workflow: APP_DIR is exported to the remote (deploy.sh otherwise fell back to its own /opt/martyrology default, so any other vars.APP_DIR uploaded to one directory and looked in another); a release tag that does not match the pyproject.toml version now fails loudly instead of building a bundle the host silently refuses; and the staged shape check counts staging/data/editions/*/01.json, the one tree whose presence previously masked the private corpus going missing. Finally, deploy.sh arms the rollback trap before the symlink flip rather than after, closing the window in which a TERM left `current` flipped with the service never restarted. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/deploy.yml | 58 ++++- ...2026-08-01-continuous-deployment-design.md | 54 +++- scripts/deploy/deploy.sh | 110 ++++++-- scripts/deploy/setup-vps-deploy-user.sh | 134 ++++++++-- tests/test_deploy_script.py | 245 ++++++++++++++---- 5 files changed, 494 insertions(+), 107 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8bf7b29..cbb5c10 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,8 +3,9 @@ name: Deploy # Builds a release bundle (api wheel + offline wheelhouse + the three pinned # data trees + manifest.json), ships it to the VPS, and activates it. # -# All ${{ ... }} interpolations are repo secrets/vars (trusted). No untrusted -# github.event.* field is used. +# Every ${{ ... }} interpolation is a repo secret/var (trusted). The two +# github.event.release.tag_name uses are bound to an `env:` name and referenced +# as a shell variable, never interpolated into a command line. # # See docs/superpowers/specs/2026-08-01-continuous-deployment-design.md @@ -43,8 +44,31 @@ jobs: - name: Resolve version id: version + env: + EVENT_NAME: ${{ github.event_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | + set -euo pipefail VERSION="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')" + + # The bundle is named, installed and recorded in manifest.json from + # pyproject.toml, never from the tag. Publishing v0.2.0 without + # bumping pyproject.toml therefore builds and deploys 0.1.0: on a + # host already running 0.1.0, deploy.sh refuses it as the active + # release and the release silently never ships, while the manifest + # attached to v0.2.0 claims api_version 0.1.0. Fail here instead. + # workflow_dispatch has no tag and is deliberately exempt. + if [ "$EVENT_NAME" = "release" ]; then + if [ -z "${RELEASE_TAG:-}" ]; then + echo "::error::release event carried no tag_name" + exit 1 + fi + if [ "${RELEASE_TAG#v}" != "$VERSION" ]; then + echo "::error::Release tag ${RELEASE_TAG} does not match the pyproject.toml version ${VERSION}. Bump pyproject.toml (or retag the release) and publish again." + exit 1 + fi + fi + echo "value=$VERSION" >> "$GITHUB_OUTPUT" echo "Building martyrology-api $VERSION" @@ -87,6 +111,18 @@ jobs: [ "${#texts_editions[@]}" -gt 0 ] \ || fail "staging/data/texts has no /01.json; texts staging is wrong (vendor/texts copied whole instead of vendor/texts/data/editions?)" + # staging/data/editions is the public tree from this repo, and has + # the same directory-of-editions shape. It is checked for exactly the + # reason texts is: its presence is what made the earlier disappearance + # of the private corpus survive a green build, because the app came up + # healthy serving editions alone. An unchecked tree is a tree that can + # vanish silently, so it gets the same explicit count. + shopt -s nullglob + public_editions=(staging/data/editions/*/01.json) + shopt -u nullglob + [ "${#public_editions[@]}" -gt 0 ] \ + || fail "staging/data/editions has no /01.json; the public editions tree is missing or mis-shaped" + [ -f staging/data/crmedr/data/martyrology_ids.json ] \ || fail "staging/data/crmedr/data/martyrology_ids.json is missing" @@ -99,7 +135,7 @@ jobs: [ "${#wheels[@]}" -gt 0 ] \ || fail "staging/wheels contains no .whl files" - echo "Staged data shape OK: ${#texts_editions[@]} text edition(s), ${#wheels[@]} wheel(s)." + echo "Staged data shape OK: ${#texts_editions[@]} text edition(s), ${#public_editions[@]} public edition(s), ${#wheels[@]} wheel(s)." - name: Assemble bundle id: bundle @@ -201,10 +237,24 @@ jobs: APP_DIR: ${{ vars.APP_DIR }} VERSION: ${{ steps.version.outputs.value }} run: | + set -euo pipefail + if [ -z "$VPS_USERNAME" ] || [ -z "$VPS_HOST" ] || [ -z "$APP_DIR" ]; then + echo "ERROR: VPS_USERNAME / VPS_HOST / APP_DIR is empty." + exit 1 + fi + # deploy.sh defaults APP_DIR to /opt/martyrology when it is unset, and + # nothing here used to export it — so pointing vars.APP_DIR anywhere + # else uploaded the bundle to one directory and looked for it in + # another, failing every deploy with "bundle not found". Pass it + # explicitly. printf %q quotes each value for the remote bash, so a + # path or version containing a space or a shell metacharacter is + # passed as one literal word rather than being re-split there. + REMOTE_APP_DIR="$(printf '%q' "$APP_DIR")" + REMOTE_VERSION="$(printf '%q' "$VERSION")" ssh -i ~/.ssh/deploy_key \ -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=2 \ "${VPS_USERNAME}@${VPS_HOST}" \ - "bash ${APP_DIR}/bin/deploy.sh ${VERSION}" + "APP_DIR=${REMOTE_APP_DIR} bash ${REMOTE_APP_DIR}/bin/deploy.sh ${REMOTE_VERSION}" - name: Attach manifest to the release if: github.event_name == 'release' diff --git a/docs/superpowers/specs/2026-08-01-continuous-deployment-design.md b/docs/superpowers/specs/2026-08-01-continuous-deployment-design.md index e0fb704..afa9cc6 100644 --- a/docs/superpowers/specs/2026-08-01-continuous-deployment-design.md +++ b/docs/superpowers/specs/2026-08-01-continuous-deployment-design.md @@ -196,7 +196,10 @@ different private repository. variable is empty; write `VPS_SSH_KEY` to `~/.ssh/deploy_key` mode 0600; write the pinned `VPS_HOST_KEY` to `~/.ssh/known_hosts`; run the DNS SSHFP drift check. 5. `scp` the bundle and its `.sha256` to `${APP_DIR}/incoming/`. -6. `ssh … "bash ${APP_DIR}/bin/deploy.sh "`. +6. `ssh … "APP_DIR=${APP_DIR} bash ${APP_DIR}/bin/deploy.sh "` — `APP_DIR` + is exported explicitly, because `deploy.sh` otherwise falls back to its own + `/opt/martyrology` default and would look for the bundle somewhere other than + where step 5 put it. Each network step is wrapped in the established 3-attempt / 15-second retry loop. @@ -213,8 +216,16 @@ handshake. | User | Role | Rights | |---|---|---| -| `martyrology-deploy` | GitHub Actions deploy identity | owns `/opt/martyrology`; no password; no sudo except the two rules below | -| `martyrology` | systemd service account | read-only on the release tree; no login | +| `martyrology-deploy` | GitHub Actions deploy identity | owns `/opt/martyrology`; supplementary member of group `martyrology`; no password; no sudo except the two rules below | +| `martyrology` | systemd service account | primary group `martyrology`; read-only on the release tree via that group; no login | + +The two accounts share exactly one thing: the `martyrology` group. That is what +grants the service account read access to a tree owned by the deploy user, and +it is the reason the tree does not need — and must not have — world bits. The +release tree contains the licensed `martyrology-texts` corpus, and this is a +Plesk-managed host on which every other hosted subscription runs its own +non-chrooted uid; a world-readable `data/texts/` would publish the corpus to +every one of them, defeating the private-submodule architecture entirely. The Plesk-chrooted subscription user is **not** used. A dedicated non-chrooted user (the `cdcfinfra-deploy` pattern) can execute one command over ssh, which @@ -225,16 +236,38 @@ Plesk may rearrange things underneath it. ### Directory layout +Everything under `/opt/martyrology` is owned `martyrology-deploy:martyrology`, +and no path anywhere in it carries an "other" bit. + ``` -/opt/martyrology/ - bin/deploy.sh installed by the setup script - config/runtime.env deploy-readable 0644, non-secret settings - incoming/ scp target +/opt/martyrology/ martyrology-deploy:martyrology 0750 + bin/ 0750 + bin/deploy.sh 0750, installed by the setup script + config/ 0750 + config/runtime.env 0640, non-secret settings + incoming/ 0700 — scp target; the bundle *contains* + the corpus, and only the deploy user needs + it, so the group is excluded here too + releases/ 2750 — setgid, so release directories + created by deploy.sh inherit the + martyrology group releases//{venv,data,manifest.json} + u=rwX, g=rX, o= (deploy.sh normalises the + whole tree after extraction and then + asserts it) current -> releases/ /etc/martyrology/api.env root:root 0600, secrets only ``` +Both scripts assert this rather than assume it. `deploy.sh` walks the freshly +extracted release and fails if any entry is not group-readable, is not owned by +the `martyrology` group, or has any "other" bit set. `setup-vps-deploy-user.sh` +impersonates the service account to prove it can traverse `$APP_DIR` and +`releases/` and read `runtime.env`, then separately proves that nothing under +`$APP_DIR` is other-accessible and that the service account cannot read +`incoming/`. The two halves fail independently: a world-readable tree passes the +first and fails the second. + ### Two environment files, split by secrecy systemd accepts multiple `EnvironmentFile=` lines, so the service's configuration @@ -246,8 +279,8 @@ is split by who is allowed to read it: cannot read them. This mirrors step 4 of `setup-vps-sync-user.sh`, which restores `ubuntu` ownership and mode 0600 on `.env.production` after the recursive chown. -- **`/opt/martyrology/config/runtime.env`** — owned by the deploy user, 0644. - `MARTYROLOGY_PORT`, `MARTYROLOGY_MANIFEST_PATH` and the three data paths. All +- **`/opt/martyrology/config/runtime.env`** — `martyrology-deploy:martyrology`, + 0640. `MARTYROLOGY_PORT`, `MARTYROLOGY_MANIFEST_PATH` and the three data paths. All point through the stable `current` symlink, so this file is written once at provisioning and never changes. @@ -287,6 +320,9 @@ header comment about keeping script changes out of the automatic path. 5. `python3.12 -m venv releases//venv`, then `pip install --no-index --find-links wheels …`. Fully offline — a GitHub or PyPI outage cannot break a deploy. + Then `chgrp -R martyrology` and `chmod -R u+rwX,g+rX,o-rwx` the release tree, + and assert the result (see §4) — CI tar member modes and the host umask are + not to be trusted in either direction. 6. **Smoke check before committing:** start the new release on a random free loopback port, assert `/healthz` returns 200 with the expected edition set, then kill it. diff --git a/scripts/deploy/deploy.sh b/scripts/deploy/deploy.sh index d84d99b..76606e4 100755 --- a/scripts/deploy/deploy.sh +++ b/scripts/deploy/deploy.sh @@ -16,6 +16,11 @@ set -euo pipefail APP_DIR="${APP_DIR:-/opt/martyrology}" +# The group shared with the service account, created by +# setup-vps-deploy-user.sh, which also adds this (deploy) user to it. Every +# release tree is chgrp'ed to it, and that group is the *only* way anything +# other than the deploy user reaches the licensed corpus under data/texts. +SERVICE_GROUP="${SERVICE_GROUP:-martyrology}" SERVICE="martyrology-api.service" RUNTIME_ENV="$APP_DIR/config/runtime.env" KEEP_RELEASES=5 @@ -50,9 +55,9 @@ get_live_port() { printf '%s' "$port" } -# Armed immediately after `current` is flipped to the new release and +# Armed immediately before `current` is flipped to the new release and # disarmed only once the live health check passes, so any failure in -# between — a failed restart, a missing runtime.env, an unset +# between — a failed flip, a failed restart, a missing runtime.env, an unset # MARTYROLOGY_PORT, an unhealthy service, or a signal — restores the # previous release instead of leaving the flip half-done. set -e can exit # the script at any of those points, and EXIT traps do not fire on their @@ -146,6 +151,14 @@ BUNDLE="$APP_DIR/incoming/martyrology-${VERSION}-linux-x86_64-cp312.tar.gz" [ -f "$BUNDLE" ] || die "bundle not found: $BUNDLE" [ -f "$BUNDLE.sha256" ] || die "checksum not found: $BUNDLE.sha256" +# The bundle contains the licensed corpus, and scp created it with whatever +# umask the deploy user's ssh session had. Tighten it the moment the path is +# known to exist and before anything else touches it: incoming/ is 0700 so the +# directory already gates access, but the bundle is only removed on the success +# path far below, so a failed deploy would otherwise leave a permissive copy +# sitting there until the next successful one. +chmod 600 "$BUNDLE" "$BUNDLE.sha256" + # Verify the digest directly against the bundle's own bytes, and assert the # checksum file actually names this bundle — `sha256sum -c` only checks that # the digest matches whatever filename is written in the .sha256 file, so a @@ -241,32 +254,59 @@ python3.12 -m venv "$RELEASE/venv" "$RELEASE/venv/bin/pip" install --quiet --no-index \ --find-links "$RELEASE/wheels" martyrology-api -# The service account runs the app but shares no group with the deploy user, -# so its access depends on world bits. Tar member modes come from the CI -# runner and directory modes from this script's umask, neither of which is -# guaranteed permissive; normalise them here rather than discover it when -# systemd fails to exec. Capital X (not lowercase x) only sets the execute -# bit on directories and on files that already have an execute bit -# somewhere, so it does not make every JSON data file executable. Runs once, -# after the tree is complete, not per-file or in a loop. -chmod -R a+rX "$RELEASE" - -# The chmod above is the fix; this proves it stuck, without impersonating -# the service account (this script has no sudo grant for that — see the -# provisioning script's own `sudo -u martyrology test -x/-r` check, which -# runs as root at provisioning time, not here). find's own recursion already -# refuses to descend into a directory it cannot execute, so if a directory -# were left non-traversable, find would surface exactly the entries below -# it that it could still see, plus its own "Permission denied" on stderr; -# either way the failure is captured here rather than left to be discovered -# by systemd. `|| true` on the capture is deliberate: it exists so a nonzero -# exit from find (e.g. that same permission error) still reaches the -# is-empty check below instead of tripping `set -e` and discarding the -# diagnostic before it can be printed. -UNREADABLE="$(find "$RELEASE" \( -type d ! -perm -o+x \) -o \( -type f ! -perm -o+r \) 2>&1)" || true +# The service account runs the app, and reaches this tree solely through the +# shared $SERVICE_GROUP. Tar member modes come from the CI runner, directory +# modes from this script's umask, and the group from whatever releases/'s +# setgid bit propagated — none of which is guaranteed, so normalise all three +# here rather than discover it when systemd fails to exec. +# +# What must NOT happen is the obvious `a+rX`: data/texts holds the licensed +# martyrology-texts corpus, and this is a shared Plesk host where every other +# subscription runs its own non-chrooted uid. A world-readable release tree +# publishes the corpus to all of them, which is exactly what the private +# submodule exists to prevent. Group in, everyone else out. +# +# chgrp needs the deploy user to be a member of $SERVICE_GROUP — +# setup-vps-deploy-user.sh's `usermod -aG` is what makes that true, and its own +# membership check is what makes a missing membership loud there rather than +# here. Capital X (not lowercase x) only sets the execute bit on directories +# and on files that already have an execute bit somewhere, so it does not make +# every JSON data file executable. Both run once, after the tree is complete, +# not per-file or in a loop. +chgrp -R "$SERVICE_GROUP" "$RELEASE" +chmod -R u+rwX,g+rX,o-rwx "$RELEASE" + +# The two lines above are the fix; this proves they stuck, without +# impersonating the service account (this script has no sudo grant for that — +# see the provisioning script's own `sudo -u martyrology test -x/-r` check, +# which runs as root at provisioning time, not here). It asserts both halves, +# and neither can mask the other: group bits present *and* every "other" bit +# absent *and* the group actually being $SERVICE_GROUP. A tree left +# world-readable fails it just as loudly as one the service account cannot +# read — which is the point, since the world-readable case is the one that +# leaks the corpus while looking like a healthy deploy. +# +# Symlinks are excluded because on Linux a symlink's own mode is inert (always +# lrwxrwxrwx, and chmod -R does not follow them); access is decided by the +# target, which find visits in its own right. Without this exclusion every venv +# symlink would trip the "other bits set" arm and the check would fail always, +# for no real reason — a check that always fires teaches operators to ignore it. +# +# find descends fully here because u+rwX above guarantees this user can +# traverse everything it owns, so nothing is skipped unexamined. `|| true` on +# the capture is deliberate: it exists so a nonzero exit from find (e.g. a +# permission error on something this user somehow does not own) still reaches +# the is-empty check below instead of tripping `set -e` and discarding the +# diagnostic before it can be printed — the stderr text is captured into the +# same variable, so such a failure reports rather than passes. +UNREADABLE="$(find "$RELEASE" ! -type l \( \ + \( -type d ! -perm -0050 \) -o \ + \( -type f ! -perm -0040 \) -o \ + -perm /0007 -o \ + ! -group "$SERVICE_GROUP" \) 2>&1)" || true if [ -n "$UNREADABLE" ]; then echo "$UNREADABLE" >&2 - die "release tree is not fully world-readable/traversable after chmod" + die "release tree is not group-readable by $SERVICE_GROUP with all other-access denied" fi # Validate the manifest with the reader the app itself uses, so a bundle whose @@ -345,13 +385,27 @@ if [ -L "$APP_DIR/current" ]; then fi echo "Activating $VERSION" -ln -sfn "$RELEASE" "$APP_DIR/current.new" -mv -Tf "$APP_DIR/current.new" "$APP_DIR/current" +# Armed BEFORE the flip, not after. Arming afterwards left a window between +# `mv -Tf` and the `trap` lines in which a TERM (a cancelled CI run, an ssh +# disconnect) exited with `current` already pointing at the new release and the +# service never restarted — no rollback, because the trap did not exist yet. +# +# Arming early is safe in both directions. $PREVIOUS is captured just above, so +# the handler always knows where to go back to, and it has three independent +# early exits for the "nothing to roll back" cases: ROLLBACK_ARMED not 1, +# status 0, and an empty or vanished $PREVIOUS. The only new paths this opens +# are a failing `ln` or `mv` — i.e. `current` never moved — where the handler +# relinks `current` to the value it already has and restarts the service. That +# is a redundant restart of an already-correct release, not a wrong state, and +# it still exits with the original failure's status. ROLLBACK_ARMED=1 trap rollback_on_failure EXIT trap 'rollback_on_failure 143' INT TERM +ln -sfn "$RELEASE" "$APP_DIR/current.new" +mv -Tf "$APP_DIR/current.new" "$APP_DIR/current" + sudo /usr/bin/systemctl restart "$SERVICE" LIVE_PORT="$(get_live_port)" || die "could not determine MARTYROLOGY_PORT from $RUNTIME_ENV" diff --git a/scripts/deploy/setup-vps-deploy-user.sh b/scripts/deploy/setup-vps-deploy-user.sh index 8e22491..b59ff22 100755 --- a/scripts/deploy/setup-vps-deploy-user.sh +++ b/scripts/deploy/setup-vps-deploy-user.sh @@ -10,6 +10,14 @@ # martyrology — the service account the unit runs as. Read-only on the # release tree, no login. # +# The two identities meet in one place only: the martyrology group. The deploy +# user is added to it, the tree is owned martyrology-deploy:martyrology, and +# every "other" bit is stripped. That is deliberate and load-bearing — the +# release tree contains the licensed martyrology-texts corpus, and this host is +# Plesk-managed, so every other hosted subscription runs its own non-chrooted +# uid on the same box. A world-readable release tree would hand that corpus to +# all of them. Group bits let the service account in; nothing else gets in. +# # Secrets live in /etc/martyrology/api.env (root:root 0600), which the deploy # identity cannot read; systemd loads it as root before dropping privileges. # Non-secret settings live in $APP_DIR/config/runtime.env, which the deploy @@ -19,6 +27,9 @@ set -euo pipefail DEPLOY_USER="martyrology-deploy" SERVICE_USER="martyrology" +# The service account's own group; both identities share it. Kept as its own +# variable because deploy.sh chgrp's each release tree to exactly this name. +SERVICE_GROUP="martyrology" APP_DIR="/opt/martyrology" SECRET_ENV="/etc/martyrology/api.env" RUNTIME_ENV="$APP_DIR/config/runtime.env" @@ -48,15 +59,37 @@ else echo "User already exists: $DEPLOY_USER" fi +# The shared group has to exist before the service account is created, so the +# account can be pinned to it explicitly rather than relying on useradd's +# distro-dependent USERGROUPS_ENAB default to conjure one of the same name. +if ! getent group "$SERVICE_GROUP" >/dev/null; then + echo "Creating group: $SERVICE_GROUP" + groupadd --system "$SERVICE_GROUP" +else + echo "Group already exists: $SERVICE_GROUP" +fi + # No login: the service account only ever runs the unit, never a shell. if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then echo "Creating user: $SERVICE_USER" - useradd --system --shell /usr/sbin/nologin --no-create-home "$SERVICE_USER" + useradd --system --gid "$SERVICE_GROUP" --shell /usr/sbin/nologin \ + --no-create-home "$SERVICE_USER" passwd --lock "$SERVICE_USER" >/dev/null else echo "User already exists: $SERVICE_USER" fi +# The single point of contact between the two identities. Supplementary group +# membership is read at session setup, so an ssh session the deploy user +# already holds will not see it — irrelevant here because deploys open a fresh +# session, but the reason a re-run is safe rather than merely idempotent. +if ! id -nG "$DEPLOY_USER" | tr ' ' '\n' | grep -qx "$SERVICE_GROUP"; then + echo "Adding $DEPLOY_USER to group $SERVICE_GROUP" + usermod -aG "$SERVICE_GROUP" "$DEPLOY_USER" +else + echo "$DEPLOY_USER is already in group $SERVICE_GROUP" +fi + SSH_DIR="/home/$DEPLOY_USER/.ssh" mkdir -p "$SSH_DIR" touch "$SSH_DIR/authorized_keys" @@ -65,14 +98,32 @@ chmod 600 "$SSH_DIR/authorized_keys" chown -R "$DEPLOY_USER:$DEPLOY_USER" "$SSH_DIR" mkdir -p "$APP_DIR"/{bin,config,incoming,releases} -chown -R "$DEPLOY_USER:$DEPLOY_USER" "$APP_DIR" -chmod 755 "$APP_DIR" -# martyrology and martyrology-deploy share no group, so traversal into the -# release tree depends entirely on these world bits — don't leave them to -# the deploy user's umask or the CI runner's tar member modes. -chmod 755 "$APP_DIR"/{bin,config,incoming,releases} +chown -R "$DEPLOY_USER:$SERVICE_GROUP" "$APP_DIR" + +# Recursive first, then the per-directory modes below, so this cannot undo +# them. On a re-run over a tree provisioned by an earlier version of this +# script, this is what actually retracts the world bits from release trees that +# were previously chmod'ed a+rX — the exposure does not fix itself just because +# new releases are written correctly. Symlinks are skipped by chmod -R, which +# is correct: on Linux a symlink's own mode is inert. +chmod -R u+rwX,g+rX,o-rwx "$APP_DIR/releases" -install -o "$DEPLOY_USER" -g "$DEPLOY_USER" -m 755 "$SCRIPT_DIR/deploy.sh" "$APP_DIR/bin/deploy.sh" +# 0750, not 0755: the service account reaches the release tree through the +# shared martyrology group, and no other local uid has any business here. +chmod 0750 "$APP_DIR" +chmod 0750 "$APP_DIR/bin" "$APP_DIR/config" +# setgid, so release directories created later by deploy.sh inherit the +# martyrology group from the parent instead of the deploy user's primary group. +# deploy.sh chgrp's as well; this makes the inherited case the default rather +# than the repaired one, and covers anything created outside that chgrp (the +# venv, pip's caches) between extraction and the chgrp itself. +chmod 2750 "$APP_DIR/releases" +# 0700, not 0750: incoming/ holds the uploaded bundle, which *contains* the +# licensed corpus. Only the deploy user ever needs it; the service account +# reads the extracted release, never the tarball. +chmod 0700 "$APP_DIR/incoming" + +install -o "$DEPLOY_USER" -g "$SERVICE_GROUP" -m 750 "$SCRIPT_DIR/deploy.sh" "$APP_DIR/bin/deploy.sh" if [ ! -f "$RUNTIME_ENV" ]; then echo "Writing $RUNTIME_ENV" @@ -83,11 +134,15 @@ MARTYROLOGY_DATA_PATH=$APP_DIR/current/data/editions:$APP_DIR/current/data/texts MARTYROLOGY_CRMEDR_PATH=$APP_DIR/current/data/crmedr MARTYROLOGY_CLBDR_PATH=$APP_DIR/current/data/clbdr EOF - chown "$DEPLOY_USER:$DEPLOY_USER" "$RUNTIME_ENV" - chmod 644 "$RUNTIME_ENV" else echo "Keeping existing $RUNTIME_ENV" fi +# Outside the branch above: a re-run over a file written by an earlier version +# of this script must still have its mode retracted from 0644. 0640, not 0644 — +# systemd reads EnvironmentFile= as root, and the deploy script reads it as the +# owner; nothing else on the host needs the live port and paths. +chown "$DEPLOY_USER:$SERVICE_GROUP" "$RUNTIME_ENV" +chmod 0640 "$RUNTIME_ENV" mkdir -p "$(dirname "$SECRET_ENV")" if [ ! -f "$SECRET_ENV" ]; then @@ -138,10 +193,53 @@ EOF systemctl daemon-reload systemctl enable martyrology-api.service -# Fail loudly now if the service account can't read what it needs, rather -# than at first start with a bare "Permission denied" from ExecStart. -if ! sudo -u "$SERVICE_USER" test -x "$APP_DIR" || ! sudo -u "$SERVICE_USER" test -r "$RUNTIME_ENV"; then - echo "ERROR: $SERVICE_USER cannot traverse $APP_DIR or read $RUNTIME_ENV — check the host umask" >&2 +# Two halves, both of which have to hold and each of which fails loudly on its +# own. Neither can be satisfied by the other going wrong: the first asserts +# access the group grants, the second asserts the absence of access nobody +# should have. A tree that is world-readable passes half one and fails half +# two; a tree that is 0700 passes half two and fails half one. +# +# Half one — the service account really can reach what it needs. Asserted by +# impersonating it (this script runs as root, so it can; deploy.sh cannot, +# which is why deploy.sh asserts modes instead). Without this, the first +# failure is a bare "Permission denied" from ExecStart at unit start. +if ! sudo -u "$SERVICE_USER" test -x "$APP_DIR" \ + || ! sudo -u "$SERVICE_USER" test -x "$APP_DIR/releases" \ + || ! sudo -u "$SERVICE_USER" test -r "$RUNTIME_ENV"; then + echo "ERROR: $SERVICE_USER cannot traverse $APP_DIR or $APP_DIR/releases," >&2 + echo " or cannot read $RUNTIME_ENV. Check group membership and modes." >&2 + exit 1 +fi + +# Half two — nothing else can. The release tree holds the licensed corpus and +# this is a shared, Plesk-managed host, so any surviving "other" bit is a +# disclosure. Checked with find rather than by impersonation because there is +# no unrelated account to impersonate; the mode is what the kernel consults for +# a uid that is neither the owner nor in the group. +WORLD_ACCESSIBLE="$(find "$APP_DIR" ! -type l -perm /0007 2>&1)" || true +if [ -n "$WORLD_ACCESSIBLE" ]; then + echo "$WORLD_ACCESSIBLE" >&2 + echo "ERROR: the paths above under $APP_DIR are accessible to every local" >&2 + echo " account. The release tree contains licensed texts; refusing." >&2 + exit 1 +fi + +# Half two, continued: the deploy user must actually be in the shared group, or +# the deploy-time chgrp silently has nothing to chgrp to and every release lands +# unreadable by the service account. +if ! id -nG "$DEPLOY_USER" | tr ' ' '\n' | grep -qx "$SERVICE_GROUP"; then + echo "ERROR: $DEPLOY_USER is not a member of group $SERVICE_GROUP" >&2 + exit 1 +fi + +# And the service account must not be able to reach the uploaded bundle, which +# is a second copy of the same corpus sitting in incoming/. This is the one +# check here whose *failure* is the pass, so it would be satisfied by `sudo -u` +# simply not working — but half one above required the same `sudo -u +# "$SERVICE_USER" test` invocation to succeed and exited if it did not, so by +# this point the mechanism is known to work and a false here means denied. +if sudo -u "$SERVICE_USER" test -r "$APP_DIR/incoming"; then + echo "ERROR: $APP_DIR/incoming is readable by $SERVICE_USER; expected 0700" >&2 exit 1 fi @@ -153,9 +251,13 @@ ACTUAL_PORT="${ACTUAL_PORT:-$PORT}" cat < str: return "\n".join(lines[start_idx : end_idx + 1]) -def _extract_world_readability_selfcheck() -> str: - """Pulls the UNREADABLE=... / if / echo / die / fi block that follows - the `chmod -R a+rX "$RELEASE"` line out of deploy.sh's current source.""" +def _extract_permission_selfcheck() -> str: + """Pulls the UNREADABLE=... / if / echo / die / fi block that follows the + chgrp/chmod pair out of deploy.sh's current source. The `find` invocation + spans several lines, so the block runs from the `UNREADABLE=` line to the + first `fi` after it.""" text = SCRIPT.read_text(encoding="utf-8") lines = text.splitlines() start_idx = next( @@ -152,6 +154,19 @@ def _extract_world_readability_selfcheck() -> str: return "\n".join(lines[start_idx : end_idx + 1]) +def _extract_permission_selfcheck_block() -> tuple[str, str]: + """The two pieces the permission tests below splice: the literal chmod + line from deploy.sh, and the self-check block that follows it.""" + return _extract_line('chmod -R u+rwX,g+rX,o-rwx "$RELEASE"'), _extract_permission_selfcheck() + + +def _own_group() -> str: + """The test user's own primary group, used as a stand-in for the + martyrology service group: it is the one group this process is + guaranteed to be able to chgrp to and to be a member of.""" + return subprocess.run(["id", "-gn"], capture_output=True, text=True, check=True).stdout.strip() + + def _extract_rollback_harness_pieces() -> tuple[str, str, str]: """Pulls the rollback_on_failure() function body and its two trap-arming lines directly out of deploy.sh's current source, for @@ -519,44 +534,85 @@ def test_rejects_a_corrupt_bundle_with_a_clear_message(tmp_path: Path): assert str(bundle) in result.stderr -def test_chmod_normalises_world_permissions_on_the_release_tree(tmp_path: Path): - """Regression test for the missing-world-bits fix: on a host with a - restrictive umask (e.g. UMASK 027) or tar member modes that came out of - the CI runner non-permissive, the release tree's directories and files - would not be traversable/readable by the martyrology service account, - which shares no group with the deploy user and depends entirely on - world bits. The deploy completes and reports success; the unit then - dies with "Permission denied" on ExecStart. +def test_tightens_the_uploaded_bundle_to_0600(tmp_path: Path): + """The bundle scp'd into incoming/ *contains* the licensed corpus, and + scp writes it with whatever umask the deploy user's ssh session had. + deploy.sh only deletes it on the success path, so a deploy that fails + anywhere after upload would leave a permissive copy sitting in + incoming/ until the next successful deploy. Tightening happens as soon + as the path is known to exist -- before the checksum is even read -- + so it covers every failure path after that point, which is all of them. + + Uses --dry-run: it returns before the venv/systemd-dependent work this + suite cannot reach, but after the chmod. + """ + app = _app_dir(tmp_path) + bundle = _bundle(app, "1.0.0") + checksum = bundle.parent / f"{bundle.name}.sha256" + bundle.chmod(0o644) + checksum.chmod(0o644) + + result = _run(app, "--dry-run", "1.0.0") + + assert result.returncode == 0, result.stderr + assert bundle.stat().st_mode & 0o777 == 0o600, "bundle must not be readable by other accounts" + assert checksum.stat().st_mode & 0o777 == 0o600 + + +def test_chmod_grants_group_access_and_denies_every_other_account(tmp_path: Path): + """Regression test for the release-tree permission fix, which has to get + two opposite things right at once. + + The service account (martyrology) must be able to read the tree: it + shares no *primary* group with the deploy user, and on a host with a + restrictive umask (e.g. UMASK 027) or non-permissive tar member modes + from the CI runner, the deploy completes and reports success while the + unit dies with "Permission denied" on ExecStart. + + And nothing else must: releases//data/texts holds the licensed + martyrology-texts corpus, and the VPS is Plesk-managed, where every + other hosted subscription runs its own non-chrooted uid on the same + box. The earlier `chmod -R a+rX` fixed the first problem by creating + the second -- it published the corpus to every local account, which is + precisely what the private-submodule architecture exists to prevent. + So this asserts the group bits are present AND that no "other" bit + survives anywhere. Reaching this line through a real end-to-end `deploy.sh ` run would require a working `python3.12 -m venv` plus a real installable martyrology-api wheel for `pip install --no-index`, neither available in this suite (the same constraint noted for the venv/systemd-dependent - paths elsewhere in this file). Instead this splices the literal - `chmod -R a+rX "$RELEASE"` line out of deploy.sh's current source - (extracted at test-run time, not hand-duplicated, via the same pattern - used for the rollback and smoke harnesses above) and runs it directly - against a tree built under a restrictive umask, so a revert of that - exact line is what makes this test fail. + paths elsewhere in this file). Instead this splices the literal chmod + line out of deploy.sh's current source (extracted at test-run time, not + hand-duplicated, via the same pattern used for the rollback and smoke + harnesses above) and runs it against a tree built both too tight (a + 0600 file, 0700 dirs) and too loose (a 0644 file, a 0755 dir), so a + revert to `a+rX` fails on the loose entries and a removal of the chmod + entirely fails on the tight ones. Also proves the capital-X distinction the fix depends on: a plain data file with no execute bit anywhere must NOT gain one (that's what lowercase `x` would have done, making every JSON file "executable"), - while a file that already had an owner execute bit does gain the - world execute bit, and both directories become traversable. + while a file that already had an owner execute bit does gain the group + execute bit, and directories become group-traversable. """ - chmod_line = _extract_line('chmod -R a+rX "$RELEASE"') + chmod_line = _extract_line('chmod -R u+rwX,g+rX,o-rwx "$RELEASE"') release = tmp_path / "release" (release / "sub").mkdir(parents=True) + (release / "loose").mkdir() data_file = release / "sub" / "manifest.json" script_file = release / "sub" / "run.sh" + corpus_file = release / "loose" / "01.json" data_file.write_text("{}", encoding="utf-8") script_file.write_text("#!/bin/sh\n", encoding="utf-8") + corpus_file.write_text("{}", encoding="utf-8") script_file.chmod(0o700) data_file.chmod(0o600) + corpus_file.chmod(0o644) # deliberately world-readable going in (release / "sub").chmod(0o700) + (release / "loose").chmod(0o755) # deliberately world-traversable going in release.chmod(0o700) result = subprocess.run( @@ -566,60 +622,149 @@ def test_chmod_normalises_world_permissions_on_the_release_tree(tmp_path: Path): ) assert result.returncode == 0, result.stderr - assert release.stat().st_mode & 0o007 == 0o005, "release dir must be world r-x" - assert (release / "sub").stat().st_mode & 0o007 == 0o005, "subdir must be world r-x" - assert data_file.stat().st_mode & 0o007 == 0o004, ( - "a data file with no execute bit must gain world-read only, " - "never world-execute (that would mean lowercase x was used, not X)" + for path in (release, release / "sub", release / "loose"): + mode = path.stat().st_mode & 0o777 + assert mode & 0o050 == 0o050, f"{path} must be group r-x, got {mode:04o}" + assert mode & 0o007 == 0, f"{path} must deny all other access, got {mode:04o}" + for path in (data_file, script_file, corpus_file): + mode = path.stat().st_mode & 0o777 + assert mode & 0o040 == 0o040, f"{path} must be group-readable, got {mode:04o}" + assert mode & 0o007 == 0, ( + f"{path} must deny all other access (the licensed corpus must not be " + f"world-readable on a shared host), got {mode:04o}" + ) + assert data_file.stat().st_mode & 0o010 == 0, ( + "a data file with no execute bit must gain group-read only, " + "never group-execute (that would mean lowercase x was used, not X)" ) - assert script_file.stat().st_mode & 0o007 == 0o005, ( - "a file that already had an owner execute bit must gain world-execute too" + assert script_file.stat().st_mode & 0o010 == 0o010, ( + "a file that already had an owner execute bit must gain group-execute too" ) -def test_world_readability_selfcheck_fails_loudly_on_a_non_traversable_tree(tmp_path: Path): +def _run_selfcheck(release: Path, service_group: str) -> subprocess.CompletedProcess[str]: + """Runs deploy.sh's literal permission self-check block against a tree, + with $SERVICE_GROUP bound to the given group. `REACHED END` is echoed + afterwards so a check that fails to abort is caught rather than read as + a pass.""" + die_fn = _extract_die_function() + selfcheck = _extract_permission_selfcheck() + script = ( + f"RELEASE={release}\nSERVICE_GROUP={service_group}\n" + f'{die_fn}\n{selfcheck}\necho "REACHED END" >&2\n' + ) + return subprocess.run(["bash", "-c", script], capture_output=True, text=True) + + +def test_permission_selfcheck_fails_loudly_on_a_group_unreadable_tree(tmp_path: Path): """The chmod above is the fix; this exercises the belt-and-braces self-check that follows it in deploy.sh (the UNREADABLE=... / die block), on its own, against a tree that was deliberately left - non-traversable -- standing in for the chmod silently not taking full - effect (e.g. a filesystem quirk, or a later code change that adds a - step after the chmod without re-running it). Splices the literal - die() function and the literal self-check block out of deploy.sh's - current source, same rationale as the harnesses above: a revert of - either piece is what makes this test fail, not a hand-duplicated - stand-in that could drift from the real script. + non-traversable by the service group -- standing in for the chmod + silently not taking full effect (e.g. a filesystem quirk, or a later + code change that adds a step after the chmod without re-running it). + Splices the literal die() function and the literal self-check block out + of deploy.sh's current source, same rationale as the harnesses above: a + revert of either piece is what makes this test fail, not a + hand-duplicated stand-in that could drift from the real script. """ - die_fn = _extract_die_function() - selfcheck = _extract_world_readability_selfcheck() - release = tmp_path / "release" (release / "locked").mkdir(parents=True) - (release / "locked").chmod(0o700) # not world-traversable, deliberately + (release / "locked").chmod(0o700) # not group-traversable, deliberately + release.chmod(0o750) + + result = _run_selfcheck(release, _own_group()) + + assert result.returncode != 0, result.stderr + assert "not group-readable" in result.stderr + assert "REACHED END" not in result.stderr + + +def test_permission_selfcheck_fails_loudly_on_a_world_readable_tree(tmp_path: Path): + """The other half, and the one that matters for the licensing exposure: + a tree the service account can read perfectly well, but which every + other local account can read too. Under the previous `a+rX` this was + the *expected* state and the old self-check asserted it, so this test + is what stops a revert to world-readable from passing silently. + + Deliberately group-correct throughout, so the only reason it can fail + is the "other" bits -- if this test passes, it is not passing by + accident of some unrelated tightness. + """ + release = tmp_path / "release" + (release / "data").mkdir(parents=True) + corpus = release / "data" / "01.json" + corpus.write_text("{}", encoding="utf-8") + corpus.chmod(0o644) + (release / "data").chmod(0o755) release.chmod(0o755) - script = f'RELEASE={release}\n{die_fn}\n{selfcheck}\necho "REACHED END" >&2\n' - result = subprocess.run(["bash", "-c", script], capture_output=True, text=True) + result = _run_selfcheck(release, _own_group()) assert result.returncode != 0, result.stderr - assert "not fully world-readable" in result.stderr + assert "other-access denied" in result.stderr + assert str(corpus) in result.stderr assert "REACHED END" not in result.stderr -def test_world_readability_selfcheck_passes_once_chmod_has_run(tmp_path: Path): - """Companion positive control: the same self-check block, on the same - kind of deliberately-locked-down tree, but this time preceded by the - real chmod line -- proving the two pieces work together as they do in - the real script, not just each in isolation.""" - chmod_line = _extract_line('chmod -R a+rX "$RELEASE"') +def test_permission_selfcheck_fails_loudly_when_the_tree_is_not_in_the_service_group( + tmp_path: Path, +): + """Group bits are only worth anything if the group is the one the + service account is in. `root` stands in for "some group that is not + $SERVICE_GROUP": it exists on every Linux host and the tree is + certainly not in it, so the ! -group arm must fire. Without this arm a + tree left in the deploy user's own primary group -- what happens if + releases/'s setgid bit is lost and the chgrp is dropped -- would sail + through with textbook-correct 0750/0640 modes and be unreadable to the + service account at runtime. + """ + release = tmp_path / "release" + (release / "data").mkdir(parents=True) + (release / "data" / "01.json").write_text("{}", encoding="utf-8") + subprocess.run( + ["bash", "-c", f'chmod -R u+rwX,g+rX,o-rwx "{release}"'], + capture_output=True, + text=True, + check=True, + ) + + result = _run_selfcheck(release, "root") + + assert result.returncode != 0, result.stderr + assert "not group-readable" in result.stderr + assert "REACHED END" not in result.stderr + + +def test_permission_selfcheck_passes_once_chmod_has_run(tmp_path: Path): + """Companion positive control: the same self-check block, on a tree + that is both too tight (0700 subdir) and too loose (0644 file) to + begin with, but this time preceded by the real chmod line -- proving + the two pieces work together as they do in the real script, not just + each in isolation, and that the check is satisfiable at all rather + than failing unconditionally. + + A dangling symlink is included on purpose: a symlink's own mode is + always lrwxrwxrwx on Linux and chmod -R does not follow it, so without + the `! -type l` exclusion in the self-check every real release would + fail here on its venv's python symlink. + """ + chmod_line, selfcheck = _extract_permission_selfcheck_block() die_fn = _extract_die_function() - selfcheck = _extract_world_readability_selfcheck() release = tmp_path / "release" (release / "locked").mkdir(parents=True) + loose = release / "loose.json" + loose.write_text("{}", encoding="utf-8") + loose.chmod(0o644) (release / "locked").chmod(0o700) + (release / "python").symlink_to("/usr/bin/python3.12") release.chmod(0o755) - script = f'RELEASE={release}\n{die_fn}\n{chmod_line}\n{selfcheck}\necho "REACHED END" >&2\n' + script = ( + f"RELEASE={release}\nSERVICE_GROUP={_own_group()}\n" + f'{die_fn}\n{chmod_line}\n{selfcheck}\necho "REACHED END" >&2\n' + ) result = subprocess.run(["bash", "-c", script], capture_output=True, text=True) assert result.returncode == 0, result.stderr From 4922b16550afd36d0b1a6d8c1068db8eb62b0b81 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 03:15:15 +0200 Subject: [PATCH 22/25] Record parked deployment follow-ups from the final review Moves the adjudicated residuals out of untracked scratch and into the repo's follow-ups doc, so they survive the working directory. Co-Authored-By: Claude Opus 5 (1M context) --- docs/follow-ups.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/docs/follow-ups.md b/docs/follow-ups.md index 4c28e0a..2cbd183 100644 --- a/docs/follow-ups.md +++ b/docs/follow-ups.md @@ -103,3 +103,34 @@ Each should become its own issue before being picked up. `GET /elogia/...`. This is a documented limitation, not a bug, but it means a curator reviewing a draft cross-edition placement via the by-canonical-id endpoint always sees published data. + +## Deployment + +Parked after the continuous-deployment branch's final review. None reintroduces +the corpus exposure or a failure-reports-success path; all are low or cosmetic. + +- **`scp` destination interpolates `${APP_DIR}` unquoted** in + `.github/workflows/deploy.yml`, while the `ssh` step is `printf %q`-safe. + Only bites on an `APP_DIR` containing whitespace, and `vars.APP_DIR` is trusted. +- **A stale bundle left in `incoming/` by a pre-fix failed deploy is detected, + not remediated.** `setup-vps-deploy-user.sh` retracts world bits from + `releases/` only; a leftover 0644 bundle aborts provisioning with a message + naming the path instead of being fixed in place. Exposure is contained by + `incoming/` being 0700. +- **Anything written into `$RELEASE` after the permission normalisation is not + re-checked** — realistically only `__pycache__` from the smoke check, and pip + byte-compiles at install time, so in practice nothing new appears. +- **`deploy.sh`'s permission self-check covers `$RELEASE` only.** `$APP_DIR` and + `releases/` modes are asserted at provisioning time alone, so a later manual + `chmod 0755 /opt/martyrology` would go unnoticed by subsequent deploys. +- **A TERM during the sub-millisecond window between `trap -` and `kill`/`rm` in + the smoke-check teardown** orphans the smoke uvicorn and leaks its temp log. + Still exits 143, so it is loud. +- **The token-expiry watch's dedup uses `printf | grep -Fxq`.** Safe below 64 KiB + of issue titles (~1000+ open issues); a here-string eliminates it structurally. +- **`deploy.sh` never asserts that `/healthz`'s `version` equals the deployed + version** after restart, so a restart that did not actually swap processes + reads as success. `HealthOut.version` is already available for this. +- **Scheduled workflows only run from the default branch**, and GitHub disables + them after 60 days of repository inactivity — so the token-expiry watch stops + warning in exactly the scenario where it would matter most. From 585c8214de79a717ee710266e79faaf2f84ebbb1 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 12:04:59 +0200 Subject: [PATCH 23/25] Apply the parked deployment follow-ups Seven items triaged as low or cosmetic during the final review, applied now that the branch is otherwise settled: - quote the scp destination's APP_DIR, matching the ssh step - remediate a stale world-readable bundle in incoming/ instead of aborting provisioning over it - re-normalise and re-assert release permissions after the smoke check, which can write __pycache__ into the tree - assert $APP_DIR, releases/ and incoming/ carry no "other" bits at deploy time, not only at provisioning time - close the signal window in the smoke teardown by disarming last - replace the token-watch dedup's `printf | grep -q` with a here-string, the same SIGPIPE class already documented for the tar listings - assert /healthz reports the deployed version after restart, so a flip that silently did not take no longer reads as a successful deploy The remaining item is GitHub platform behaviour with no in-repo fix and stays documented in docs/follow-ups.md. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/deploy.yml | 9 +- .github/workflows/token-expiry-watch.yml | 10 +- docs/follow-ups.md | 34 +- scripts/deploy/deploy.sh | 175 ++++-- scripts/deploy/setup-vps-deploy-user.sh | 16 + tests/test_deploy_script.py | 701 ++++++++++++++++++++++- 6 files changed, 871 insertions(+), 74 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cbb5c10..e142d0f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -216,12 +216,19 @@ jobs: echo "ERROR: VPS_USERNAME / VPS_HOST / APP_DIR is empty." exit 1 fi + # scp's remote destination is not a local path: it is handed to the + # remote end as a shell word and expanded there, exactly like the + # command in the "Activate release" step below. So it needs the same + # quoting dialect — printf %q renders $APP_DIR as a single literal + # word for the remote bash, instead of letting whitespace or a shell + # metacharacter in it re-split the destination there. + REMOTE_APP_DIR="$(printf '%q' "$APP_DIR")" for attempt in 1 2 3; do echo "Upload attempt $attempt..." if scp -i ~/.ssh/deploy_key \ -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=2 \ "$BUNDLE" "$BUNDLE.sha256" \ - "${VPS_USERNAME}@${VPS_HOST}:${APP_DIR}/incoming/"; then + "${VPS_USERNAME}@${VPS_HOST}:${REMOTE_APP_DIR}/incoming/"; then echo "Upload succeeded on attempt $attempt" exit 0 fi diff --git a/.github/workflows/token-expiry-watch.yml b/.github/workflows/token-expiry-watch.yml index 61b352b..694f1da 100644 --- a/.github/workflows/token-expiry-watch.yml +++ b/.github/workflows/token-expiry-watch.yml @@ -41,9 +41,17 @@ jobs: # and `:`, which GitHub's search syntax isn't guaranteed to treat # literally, so open issues are listed and matched in the shell # instead. + # The match itself is a here-string, not `printf … | grep -Fxq`, + # for the same reason and the same defect class: `grep -q` exits on + # its first match, which SIGPIPEs a `printf` still writing the rest + # of the payload once it exceeds the 64 KiB pipe buffer — under + # `pipefail` the whole pipeline then reads non-zero, the `if` takes + # the false branch, and a duplicate issue is filed precisely when + # the repo has enough open issues for it to matter. A here-string + # has no writer to signal. local existing existing="$(gh issue list --repo "$REPO" --state open --json title --jq '.[].title')" - if printf '%s\n' "$existing" | grep -Fxq "$title"; then + if grep -Fxq "$title" <<<"$existing"; then echo "Issue already open: $title" return 0 fi diff --git a/docs/follow-ups.md b/docs/follow-ups.md index 2cbd183..ccef311 100644 --- a/docs/follow-ups.md +++ b/docs/follow-ups.md @@ -106,31 +106,15 @@ Each should become its own issue before being picked up. ## Deployment -Parked after the continuous-deployment branch's final review. None reintroduces -the corpus exposure or a failure-reports-success path; all are low or cosmetic. - -- **`scp` destination interpolates `${APP_DIR}` unquoted** in - `.github/workflows/deploy.yml`, while the `ssh` step is `printf %q`-safe. - Only bites on an `APP_DIR` containing whitespace, and `vars.APP_DIR` is trusted. -- **A stale bundle left in `incoming/` by a pre-fix failed deploy is detected, - not remediated.** `setup-vps-deploy-user.sh` retracts world bits from - `releases/` only; a leftover 0644 bundle aborts provisioning with a message - naming the path instead of being fixed in place. Exposure is contained by - `incoming/` being 0700. -- **Anything written into `$RELEASE` after the permission normalisation is not - re-checked** — realistically only `__pycache__` from the smoke check, and pip - byte-compiles at install time, so in practice nothing new appears. -- **`deploy.sh`'s permission self-check covers `$RELEASE` only.** `$APP_DIR` and - `releases/` modes are asserted at provisioning time alone, so a later manual - `chmod 0755 /opt/martyrology` would go unnoticed by subsequent deploys. -- **A TERM during the sub-millisecond window between `trap -` and `kill`/`rm` in - the smoke-check teardown** orphans the smoke uvicorn and leaks its temp log. - Still exits 143, so it is loud. -- **The token-expiry watch's dedup uses `printf | grep -Fxq`.** Safe below 64 KiB - of issue titles (~1000+ open issues); a here-string eliminates it structurally. -- **`deploy.sh` never asserts that `/healthz`'s `version` equals the deployed - version** after restart, so a restart that did not actually swap processes - reads as success. `HealthOut.version` is already available for this. +Parked after the continuous-deployment branch's final review. The seven code +items recorded here — the unquoted `scp` destination, the unremediated stale +bundle in `incoming/`, the un-rechecked post-normalisation `$RELEASE`, the +`$RELEASE`-only permission self-check, the smoke-teardown signal window, the +`printf | grep -Fxq` dedup, and the missing served-version assertion after +restart — have since been applied, each with a covering test in +`tests/test_deploy_script.py`. Only the platform-behaviour item below remains +open. + - **Scheduled workflows only run from the default branch**, and GitHub disables them after 60 days of repository inactivity — so the token-expiry watch stops warning in exactly the scenario where it would matter most. diff --git a/scripts/deploy/deploy.sh b/scripts/deploy/deploy.sh index 76606e4..3c46b25 100755 --- a/scripts/deploy/deploy.sh +++ b/scripts/deploy/deploy.sh @@ -241,6 +241,32 @@ if [ -L "$APP_DIR/current" ] && [ "$(readlink "$APP_DIR/current")" = "$RELEASE" die "$VERSION is the currently active release; deactivate or bump the version before redeploying" fi +# $APP_DIR, releases/ and incoming/ get their modes from +# setup-vps-deploy-user.sh, which asserts them once — at provisioning time. +# Nothing re-asserted them afterwards, so a later `chmod 0755 /opt/martyrology` +# (an operator debugging a permission problem, a restore that did not preserve +# modes, a Plesk tool tidying up) went unnoticed by every subsequent deploy: the +# per-release normalisation below would keep reporting a correctly locked-down +# release tree while the directory above it published that tree — and the +# uploaded bundle in incoming/ — to every other uid on this shared host. +# +# Non-recursive on purpose. The contents of releases/ are covered by +# normalise_release_permissions() and the contents of incoming/ by the `chmod +# 600` on the bundle above; what is unowned by any other check is these three +# directories' own modes. Each is tested separately so the message names the one +# to fix rather than pointing at the tree in general. +# +# `find -L` so a symlinked $APP_DIR is judged by the mode of the directory it +# resolves to rather than by the link's own inert 0777; a broken link makes find +# write to stderr, which is captured into the same variable and therefore fails +# loudly instead of reading as "no other bits found". +for GUARDED_DIR in "$APP_DIR" "$APP_DIR/releases" "$APP_DIR/incoming"; do + [ -d "$GUARDED_DIR" ] || die "required directory is missing: $GUARDED_DIR" + WORLD_ACCESSIBLE="$(find -L "$GUARDED_DIR" -maxdepth 0 -perm /0007 2>&1)" || true + [ -z "$WORLD_ACCESSIBLE" ] || die \ + "$GUARDED_DIR is accessible to every local account; expected no 'other' permission bits (run setup-vps-deploy-user.sh)" +done + echo "Installing $VERSION to $RELEASE" rm -rf "$RELEASE" mkdir -p "$RELEASE" @@ -260,6 +286,14 @@ python3.12 -m venv "$RELEASE/venv" # setgid bit propagated — none of which is guaranteed, so normalise all three # here rather than discover it when systemd fails to exec. # +# A function rather than a straight-line block because it has to run more than +# once: everything written into $RELEASE *after* the first call — most +# realistically __pycache__ from the smoke check, which runs the app out of +# this very tree — would otherwise never be normalised and never be checked. +# One definition, called at both points, so the fix and its proof cannot drift +# apart between the two. It is idempotent by construction: chgrp/chmod restate +# the wanted end state rather than adjusting relative to the current one. +# # What must NOT happen is the obvious `a+rX`: data/texts holds the licensed # martyrology-texts corpus, and this is a shared Plesk host where every other # subscription runs its own non-chrooted uid. A world-readable release tree @@ -271,43 +305,50 @@ python3.12 -m venv "$RELEASE/venv" # membership check is what makes a missing membership loud there rather than # here. Capital X (not lowercase x) only sets the execute bit on directories # and on files that already have an execute bit somewhere, so it does not make -# every JSON data file executable. Both run once, after the tree is complete, -# not per-file or in a loop. -chgrp -R "$SERVICE_GROUP" "$RELEASE" -chmod -R u+rwX,g+rX,o-rwx "$RELEASE" - -# The two lines above are the fix; this proves they stuck, without -# impersonating the service account (this script has no sudo grant for that — -# see the provisioning script's own `sudo -u martyrology test -x/-r` check, -# which runs as root at provisioning time, not here). It asserts both halves, -# and neither can mask the other: group bits present *and* every "other" bit -# absent *and* the group actually being $SERVICE_GROUP. A tree left -# world-readable fails it just as loudly as one the service account cannot -# read — which is the point, since the world-readable case is the one that -# leaks the corpus while looking like a healthy deploy. -# -# Symlinks are excluded because on Linux a symlink's own mode is inert (always -# lrwxrwxrwx, and chmod -R does not follow them); access is decided by the -# target, which find visits in its own right. Without this exclusion every venv -# symlink would trip the "other bits set" arm and the check would fail always, -# for no real reason — a check that always fires teaches operators to ignore it. -# -# find descends fully here because u+rwX above guarantees this user can -# traverse everything it owns, so nothing is skipped unexamined. `|| true` on -# the capture is deliberate: it exists so a nonzero exit from find (e.g. a -# permission error on something this user somehow does not own) still reaches -# the is-empty check below instead of tripping `set -e` and discarding the -# diagnostic before it can be printed — the stderr text is captured into the -# same variable, so such a failure reports rather than passes. -UNREADABLE="$(find "$RELEASE" ! -type l \( \ - \( -type d ! -perm -0050 \) -o \ - \( -type f ! -perm -0040 \) -o \ - -perm /0007 -o \ - ! -group "$SERVICE_GROUP" \) 2>&1)" || true -if [ -n "$UNREADABLE" ]; then - echo "$UNREADABLE" >&2 - die "release tree is not group-readable by $SERVICE_GROUP with all other-access denied" -fi +# every JSON data file executable. Both run once per call, after the tree is +# complete, not per-file or in a loop. +normalise_release_permissions() { + chgrp -R "$SERVICE_GROUP" "$RELEASE" + chmod -R u+rwX,g+rX,o-rwx "$RELEASE" + + # The two lines above are the fix; this proves they stuck, without + # impersonating the service account (this script has no sudo grant for + # that — see the provisioning script's own `sudo -u martyrology test -x/-r` + # check, which runs as root at provisioning time, not here). It asserts + # both halves, and neither can mask the other: group bits present *and* + # every "other" bit absent *and* the group actually being $SERVICE_GROUP. A + # tree left world-readable fails it just as loudly as one the service + # account cannot read — which is the point, since the world-readable case + # is the one that leaks the corpus while looking like a healthy deploy. + # + # Symlinks are excluded because on Linux a symlink's own mode is inert + # (always lrwxrwxrwx, and chmod -R does not follow them); access is decided + # by the target, which find visits in its own right. Without this exclusion + # every venv symlink would trip the "other bits set" arm and the check + # would fail always, for no real reason — a check that always fires teaches + # operators to ignore it. + # + # find descends fully here because u+rwX above guarantees this user can + # traverse everything it owns, so nothing is skipped unexamined. `|| true` + # on the capture is deliberate: it exists so a nonzero exit from find (e.g. + # a permission error on something this user somehow does not own) still + # reaches the is-empty check below instead of tripping `set -e` and + # discarding the diagnostic before it can be printed — the stderr text is + # captured into the same variable, so such a failure reports rather than + # passes. UNREADABLE is deliberately not `local`: the tests splice this + # block out of the script verbatim and run it at top level. + UNREADABLE="$(find "$RELEASE" ! -type l \( \ + \( -type d ! -perm -0050 \) -o \ + \( -type f ! -perm -0040 \) -o \ + -perm /0007 -o \ + ! -group "$SERVICE_GROUP" \) 2>&1)" || true + if [ -n "$UNREADABLE" ]; then + echo "$UNREADABLE" >&2 + die "release tree is not group-readable by $SERVICE_GROUP with all other-access denied" + fi +} + +normalise_release_permissions # Validate the manifest with the reader the app itself uses, so a bundle whose # manifest this release cannot parse is rejected before it is ever activated. @@ -343,14 +384,21 @@ SMOKE_PID="" # default disposition would already have exited 143 here, so this wiring # has to reproduce that explicitly rather than weaken it. # -# The handler's first line clears all three traps so it cannot run twice -# on a signal (once for the signal, once for the EXIT that follows). The -# body is idempotent, so that is belt-and-braces rather than load-bearing, -# but it is stated rather than assumed. +# The handler clears all three traps LAST, not first. Clearing first left a +# window — between the `trap -` and the `kill`/`rm` — in which the traps +# were already gone but the cleanup had not happened yet, so a signal +# landing there got bash's default disposition and killed the script on the +# spot, orphaning the smoke uvicorn and leaving $SMOKE_LOG in /tmp. Small, +# but it is the one window in this phase where a signal loses the cleanup +# entirely. Clearing last means a signal arriving mid-body is still trapped +# and the handler simply runs again; the body is idempotent (`kill … || +# true`, `rm -f`), so a double run is harmless, whereas a missed run is +# not. The clear still happens before the function returns, so it remains +# the disarm the success path below relies on. smoke_cleanup() { - trap - EXIT INT TERM kill "$SMOKE_PID" 2>/dev/null || true rm -f "$SMOKE_LOG" + trap - EXIT INT TERM } trap smoke_cleanup EXIT trap 'smoke_cleanup; exit 143' INT TERM @@ -376,10 +424,19 @@ echo "Smoke check passed: $EDITIONS editions" # Same handler on the success path, so the teardown has exactly one # definition and cannot drift from what the traps run; it clears its own -# traps first, which is also the disarm this phase needs before the -# rollback trap below is armed. +# traps as its last act, which is also the disarm this phase needs before +# the rollback trap below is armed. smoke_cleanup +# Second call, and the reason this is a function. The smoke check ran the app +# out of $RELEASE, so python may have written __pycache__ directories and .pyc +# files into it since the first normalisation — created with this process's +# umask, not with the modes just asserted. Re-normalise and re-assert now that +# the smoke uvicorn is dead and nothing else will write here, so what gets +# activated below is the tree that was checked, not the tree as it was several +# steps ago. +normalise_release_permissions + if [ -L "$APP_DIR/current" ]; then PREVIOUS="$(readlink "$APP_DIR/current")" fi @@ -411,7 +468,35 @@ sudo /usr/bin/systemctl restart "$SERVICE" LIVE_PORT="$(get_live_port)" || die "could not determine MARTYROLOGY_PORT from $RUNTIME_ENV" wait_healthy "$LIVE_PORT" || die "$VERSION is unhealthy on port $LIVE_PORT" -echo "$VERSION is live and healthy on port $LIVE_PORT" +# wait_healthy only proves that *something* is answering /healthz on that port. +# If the restart did not actually swap processes — systemd reporting success +# while the old unit kept running, a restart racing an already-running +# instance, a `current` flip that silently did not take — the previous release +# answers, the poll passes, and the deploy reports success for a version that +# was never activated. So assert the served version is the one just installed, +# and do it BEFORE the rollback trap is disarmed below, so a mismatch takes the +# rollback path (via die → EXIT trap) rather than merely printing. +# +# The version is read the same way the smoke check reads its editions count: +# with the release's own python, which is the only JSON parser this script can +# rely on being present. A here-string rather than a pipe, so a parser that +# exits early can never SIGPIPE the writer and turn a failed check into a +# passed one under `pipefail`. +# +# $VERSION may arrive as "0.1.0" (the workflow passes the bare pyproject +# version) or as "v0.1.0" (a manual invocation; the argument regex accepts +# both), while HealthOut.version is always the bare form — so strip one leading +# "v" before comparing rather than comparing two different spellings. +LIVE_HEALTH="$(curl -fsS "http://127.0.0.1:${LIVE_PORT}/healthz")" \ + || die "$VERSION answered the health poll on port $LIVE_PORT but /healthz could not be re-read" +SERVED_VERSION="$("$RELEASE/venv/bin/python" -c \ + 'import json,sys; print(json.load(sys.stdin).get("version", ""))' <<<"$LIVE_HEALTH")" \ + || die "$VERSION is live on port $LIVE_PORT but /healthz did not parse as JSON" +EXPECTED_VERSION="${VERSION#v}" +[ "$SERVED_VERSION" = "$EXPECTED_VERSION" ] || die \ + "port $LIVE_PORT is serving version '$SERVED_VERSION', not the just-deployed '$EXPECTED_VERSION'; the restart did not swap processes" + +echo "$VERSION is live and healthy on port $LIVE_PORT (serving version $SERVED_VERSION)" ROLLBACK_ARMED=0 trap - EXIT INT TERM diff --git a/scripts/deploy/setup-vps-deploy-user.sh b/scripts/deploy/setup-vps-deploy-user.sh index b59ff22..a8cf4af 100755 --- a/scripts/deploy/setup-vps-deploy-user.sh +++ b/scripts/deploy/setup-vps-deploy-user.sh @@ -108,6 +108,22 @@ chown -R "$DEPLOY_USER:$SERVICE_GROUP" "$APP_DIR" # is correct: on Linux a symlink's own mode is inert. chmod -R u+rwX,g+rX,o-rwx "$APP_DIR/releases" +# And the same for incoming/, which the earlier version of this script left +# alone. A bundle scp'd there by a pre-fix deploy that then failed keeps the +# 0644 the deploy user's ssh umask gave it — deploy.sh only removes the bundle +# on its success path — and the half-two `find` check below would then abort +# provisioning with a message naming the file, i.e. detect the leftover without +# doing anything about it. Retracting it here means the check runs against a +# tree that has actually been fixed, rather than the operator being told to go +# and fix it by hand. +# +# `go-rwx`, not `g+rX`: releases/ is shared with the service account through +# the martyrology group, but incoming/ holds the *bundle*, which is a second +# copy of the licensed corpus in tarball form. Only the deploy user ever needs +# it — the service account reads the extracted release — so the group bits come +# off here too, and the check further down asserts exactly that. +chmod -R u+rwX,go-rwx "$APP_DIR/incoming" + # 0750, not 0755: the service account reaches the release tree through the # shared martyrology group, and no other local uid has any business here. chmod 0750 "$APP_DIR" diff --git a/tests/test_deploy_script.py b/tests/test_deploy_script.py index 7c1cfca..e378973 100644 --- a/tests/test_deploy_script.py +++ b/tests/test_deploy_script.py @@ -1,9 +1,14 @@ +import contextlib import hashlib import io import signal import subprocess +import sys import tarfile +import threading import time +from collections.abc import Iterator +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "deploy" / "deploy.sh" @@ -144,11 +149,18 @@ def _extract_permission_selfcheck() -> str: """Pulls the UNREADABLE=... / if / echo / die / fi block that follows the chgrp/chmod pair out of deploy.sh's current source. The `find` invocation spans several lines, so the block runs from the `UNREADABLE=` line to the - first `fi` after it.""" + first `fi` after it. + + Matched with lstrip() because the block now lives inside + normalise_release_permissions() and is therefore indented; the extracted + text is spliced into a `bash -c` script where leading whitespace is + immaterial, so it is kept verbatim rather than dedented.""" text = SCRIPT.read_text(encoding="utf-8") lines = text.splitlines() start_idx = next( - i for i, line in enumerate(lines) if line.startswith('UNREADABLE="$(find "$RELEASE"') + i + for i, line in enumerate(lines) + if line.lstrip().startswith('UNREADABLE="$(find "$RELEASE"') ) end_idx = next(i for i in range(start_idx + 1, len(lines)) if lines[i].strip() == "fi") return "\n".join(lines[start_idx : end_idx + 1]) @@ -266,6 +278,138 @@ def _build_smoke_signal_harness(tmp_path: Path) -> Path: return harness +def _build_smoke_teardown_window_harness(tmp_path: Path) -> tuple[Path, Path]: + """Harness for the teardown's own signal window: the stretch of + smoke_cleanup() between the `trap -` and the `kill`/`rm`. + + That window is sub-millisecond in the real script, so it is widened here + rather than raced: `kill` is overridden by a shell function (functions take + precedence over builtins in bash) that first touches a marker file the test + polls for, then blocks in `command sleep`. The test delivers its second + signal while the teardown is inside that block. + + With `trap -` first, the traps are already gone by then, the signal gets + bash's default disposition, and the process dies before `rm -f` ever + runs -- leaving the temp log behind. With `trap -` last, the signal is + still trapped, bash defers it to the end of the current foreground command + and re-runs the idempotent handler, which cleans up. + """ + function_text, trap_lines = _extract_smoke_harness_pieces() + marker = tmp_path / "in-cleanup" + harness = tmp_path / "smoke-window-harness.sh" + harness.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + 'SMOKE_LOG="$(mktemp)"\n' + 'SMOKE_PID=""\n' + 'echo "$SMOKE_LOG"\n' + "kill() {\n" + f' : >"{marker}"\n' + " command sleep 1\n" + " return 0\n" + "}\n" + "\n" + f"{function_text}\n" + "\n" + "\n".join(trap_lines) + "\n" + "\n" + "sleep 1\n" + 'echo "harness: CONTINUED PAST SMOKE PHASE" >&2\n' + "smoke_cleanup\n" + "exit 0\n", + encoding="utf-8", + ) + return harness, marker + + +def _extract_normalise_function() -> str: + """Pulls normalise_release_permissions() -- the chgrp/chmod pair plus the + self-check that proves they stuck -- out of deploy.sh's current source.""" + text = SCRIPT.read_text(encoding="utf-8") + lines = text.splitlines() + start_idx = next( + i for i, line in enumerate(lines) if line == "normalise_release_permissions() {" + ) + end_idx = next(i for i in range(start_idx + 1, len(lines)) if lines[i] == "}") + return "\n".join(lines[start_idx : end_idx + 1]) + + +def _extract_app_dir_guard() -> str: + """Pulls the `for GUARDED_DIR in ...; done` loop that asserts $APP_DIR, + releases/ and incoming/ carry no "other" bits.""" + text = SCRIPT.read_text(encoding="utf-8") + lines = text.splitlines() + start_idx = next(i for i, line in enumerate(lines) if line.startswith("for GUARDED_DIR in ")) + end_idx = next(i for i in range(start_idx + 1, len(lines)) if lines[i].strip() == "done") + return "\n".join(lines[start_idx : end_idx + 1]) + + +def _extract_version_assertion() -> str: + """Pulls the post-restart served-version assertion (LIVE_HEALTH= through + the SERVED_VERSION comparison) out of deploy.sh's current source.""" + text = SCRIPT.read_text(encoding="utf-8") + lines = text.splitlines() + start_idx = next(i for i, line in enumerate(lines) if line.startswith('LIVE_HEALTH="$(curl')) + end_idx = next( + i + for i in range(start_idx + 1, len(lines)) + if lines[i].startswith('echo "$VERSION is live and healthy') + ) + return "\n".join(lines[start_idx:end_idx]).rstrip() + + +@contextlib.contextmanager +def _healthz_server(payload: str) -> Iterator[int]: + """A throwaway HTTP server answering every GET with `payload`, standing in + for the restarted unit's /healthz. Yields the ephemeral port it bound.""" + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler's name + body = payload.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt: str, *args: object) -> None: + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server.server_port + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def _release_with_python(tmp_path: Path) -> Path: + """A $RELEASE stub whose venv/bin/python is this interpreter -- enough for + the served-version assertion, which uses the release's own python as its + JSON parser.""" + release = tmp_path / "release" + (release / "venv" / "bin").mkdir(parents=True) + (release / "venv" / "bin" / "python").symlink_to(sys.executable) + return release + + +def _run_version_assertion( + tmp_path: Path, *, version: str, served: str +) -> subprocess.CompletedProcess[str]: + release = _release_with_python(tmp_path) + block = _extract_version_assertion() + die_fn = _extract_die_function() + with _healthz_server(served) as port: + script = ( + "set -euo pipefail\n" + f"VERSION={version}\nLIVE_PORT={port}\nRELEASE={release}\n" + f'{die_fn}\n{block}\necho "REACHED END" >&2\n' + ) + return subprocess.run(["bash", "-c", script], capture_output=True, text=True) + + def _run(app: Path, *args: str) -> subprocess.CompletedProcess[str]: return subprocess.run( ["bash", str(SCRIPT), *args], @@ -875,3 +1019,556 @@ def test_signal_during_smoke_phase_stops_the_deploy_instead_of_continuing(tmp_pa ) smoke_log = Path(stdout.strip().splitlines()[0]) assert not smoke_log.exists(), f"smoke log {smoke_log} was left behind after the signal" + + +def test_permission_normalisation_is_reapplied_after_the_smoke_check(tmp_path: Path): + """The chgrp/chmod/self-check trio used to be a straight-line block that + ran once, immediately after `pip install`. The smoke check that follows it + then runs the app *out of that same tree*, so python can write + `__pycache__` directories and `.pyc` files into it afterwards, with this + process's umask rather than with the modes just asserted -- and what got + symlinked into `current` was therefore not the tree that was checked. + + Two halves, because the fix has two parts and each can be reverted on its + own: + + Structural -- that a `normalise_release_permissions` call actually sits + between the smoke check's result and the `ln -sfn` flip. Deleting the + second call site (leaving the function defined and called once) is exactly + what the original defect was, and nothing behavioural in this suite can see + it, because the real smoke check needs a working venv and uvicorn. + + Behavioural -- that the extracted function really is re-runnable and really + does catch a tree dirtied after a first, passing normalisation: the + self-check alone is run against the dirtied tree first (it must fail, so + the check is proven to be what notices), then the whole function (it must + fix and pass). If the check were toothless the first run would pass and + this test would fail. + """ + lines = SCRIPT.read_text(encoding="utf-8").splitlines() + smoke_idx = next(i for i, line in enumerate(lines) if line.startswith('EDITIONS="$(curl')) + flip_idx = next(i for i, line in enumerate(lines) if line.startswith('ln -sfn "$RELEASE"')) + assert any( + line.strip() == "normalise_release_permissions" for line in lines[smoke_idx:flip_idx] + ), ( + "deploy.sh must re-normalise and re-assert $RELEASE's permissions after the " + "smoke check has run the app out of that tree and before `current` is flipped" + ) + + normalise_fn = _extract_normalise_function() + selfcheck = _extract_permission_selfcheck() + die_fn = _extract_die_function() + group = _own_group() + + release = tmp_path / "release" + (release / "data").mkdir(parents=True) + (release / "data" / "01.json").write_text("{}", encoding="utf-8") + + preamble = f"RELEASE={release}\nSERVICE_GROUP={group}\n{die_fn}\n" + + first = subprocess.run( + ["bash", "-c", f'{preamble}{normalise_fn}\nnormalise_release_permissions\necho "OK" >&2\n'], + capture_output=True, + text=True, + ) + assert first.returncode == 0, first.stderr + + # Exactly what the smoke check leaves behind: a __pycache__ directory and a + # .pyc, created with a permissive umask after the tree was normalised. + pycache = release / "__pycache__" + pycache.mkdir() + pyc = pycache / "app.cpython-312.pyc" + pyc.write_bytes(b"\x00") + pyc.chmod(0o644) + pycache.chmod(0o755) + + stale = subprocess.run( + ["bash", "-c", f'{preamble}{selfcheck}\necho "REACHED END" >&2\n'], + capture_output=True, + text=True, + ) + assert stale.returncode != 0, ( + "the self-check must notice a tree dirtied after the first normalisation; " + f"stderr={stale.stderr!r}" + ) + assert "other-access denied" in stale.stderr + assert "REACHED END" not in stale.stderr + + second = subprocess.run( + [ + "bash", + "-c", + f'{preamble}{normalise_fn}\nnormalise_release_permissions\necho "REACHED END" >&2\n', + ], + capture_output=True, + text=True, + ) + assert second.returncode == 0, second.stderr + assert "REACHED END" in second.stderr + for path in (pycache, pyc): + assert path.stat().st_mode & 0o007 == 0, ( + f"{path} must deny all other access after re-normalisation, " + f"got {path.stat().st_mode & 0o777:04o}" + ) + + +def _run_app_dir_guard(app: Path) -> subprocess.CompletedProcess[str]: + """Runs deploy.sh's literal $APP_DIR/releases/incoming mode guard against a + tree. `REACHED END` afterwards so a guard that fails to abort is caught + rather than read as a pass.""" + die_fn = _extract_die_function() + guard = _extract_app_dir_guard() + script = f'APP_DIR={app}\n{die_fn}\n{guard}\necho "REACHED END" >&2\n' + return subprocess.run(["bash", "-c", script], capture_output=True, text=True) + + +def _provisioned_app_dir(tmp_path: Path) -> Path: + """$APP_DIR as setup-vps-deploy-user.sh leaves it: 0750 top and releases/, + 0700 incoming/.""" + app = tmp_path / "app" + (app / "releases").mkdir(parents=True) + (app / "incoming").mkdir() + app.chmod(0o750) + (app / "releases").chmod(0o2750) + (app / "incoming").chmod(0o700) + return app + + +def test_app_dir_guard_passes_on_a_correctly_provisioned_tree(tmp_path: Path): + """Positive control: the guard has to be satisfiable by the modes the + provisioning script actually sets, including releases/'s setgid bit, or it + would fail every deploy and teach operators to ignore it.""" + result = _run_app_dir_guard(_provisioned_app_dir(tmp_path)) + + assert result.returncode == 0, result.stderr + assert "REACHED END" in result.stderr + + +def test_app_dir_guard_fails_loudly_on_a_world_accessible_directory(tmp_path: Path): + """The modes of $APP_DIR, releases/ and incoming/ were asserted once, at + provisioning time, and never again. A later `chmod 0755 /opt/martyrology` + -- an operator debugging a permission problem, a restore that did not + preserve modes -- therefore went unnoticed by every subsequent deploy, + which kept reporting a correctly locked-down *release* tree while the + directory above it published that tree, and the bundle in incoming/, to + every other uid on this shared Plesk host. + + Each of the three is loosened in turn, so a guard that checks only one (or + that names the wrong one) fails here. `stat` reports the mode actually set, + to make a test failure diagnosable. + """ + for relative in ("", "releases", "incoming"): + app = _provisioned_app_dir(tmp_path / f"case-{relative or 'top'}") + target = app / relative if relative else app + target.chmod(0o755) + + result = _run_app_dir_guard(app) + + assert result.returncode != 0, ( + f"{target} at 0755 must abort the deploy; stderr={result.stderr!r}" + ) + assert str(target) in result.stderr, ( + f"the failure must name the path to fix; stderr={result.stderr!r}" + ) + assert "REACHED END" not in result.stderr + + +def test_app_dir_guard_fails_loudly_on_a_missing_directory(tmp_path: Path): + """`find` on a path that does not exist finds nothing, so a guard that + only looked at find's *stdout* would read an unprovisioned tree as "no + other bits" and let the deploy proceed -- a failure reporting success, + which is the shape of defect this file exists to prevent. + + Two independent things stop that, and this asserts the outcome rather than + which of them fired: the explicit `-d` test, and folding find's stderr into + the same variable that is checked for emptiness. Dropping either alone + still fails loudly; dropping both is what this test catches.""" + app = _provisioned_app_dir(tmp_path) + (app / "incoming").rmdir() + + result = _run_app_dir_guard(app) + + assert result.returncode != 0, result.stderr + assert str(app / "incoming") in result.stderr + assert "REACHED END" not in result.stderr + + +def test_smoke_teardown_cleans_up_when_a_signal_lands_inside_it(tmp_path: Path): + """Regression test for moving `trap - EXIT INT TERM` to the END of + smoke_cleanup(). + + With it first, there was a window inside the handler -- after the traps + were cleared, before the `kill`/`rm` -- in which a signal got bash's + default disposition and killed the script outright, orphaning the smoke + uvicorn and leaving its temp log in /tmp. The body is idempotent + (`kill … || true`, `rm -f`), so clearing last costs at most a harmless + second run and closes the window. + + The real window is sub-millisecond, so the harness widens it rather than + racing it: it splices the actual smoke_cleanup() body and its trap lines + out of deploy.sh (same extraction as the smoke-signal test above) and + overrides `kill` with a shell function that marks its entry and then + blocks. The test sends one signal to enter the teardown and a second while + it is inside that block. + + What it covers: that a signal delivered mid-teardown still ends with the + temp log removed and a non-zero exit. What it does not cover -- same limits + as the other harness tests -- killing a real uvicorn child, or any of the + venv/systemd-dependent work the real script does around this phase. + """ + harness, marker = _build_smoke_teardown_window_harness(tmp_path) + proc = subprocess.Popen( + ["bash", str(harness)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + time.sleep(0.2) + proc.send_signal(signal.SIGTERM) + + deadline = time.monotonic() + 10 + while not marker.exists() and time.monotonic() < deadline: + if proc.poll() is not None: + break + time.sleep(0.02) + assert marker.exists(), "the harness never entered smoke_cleanup" + + proc.send_signal(signal.SIGTERM) + stdout, stderr = proc.communicate(timeout=20) + finally: + if proc.poll() is None: # pragma: no cover - only on an unexpected hang + proc.kill() + proc.communicate() + + assert "CONTINUED PAST SMOKE PHASE" not in stderr, ( + f"execution continued past the smoke phase; stdout={stdout!r} stderr={stderr!r}" + ) + assert proc.returncode != 0, ( + f"expected a non-zero exit after SIGTERM, got {proc.returncode}; " + f"stdout={stdout!r} stderr={stderr!r}" + ) + smoke_log = Path(stdout.strip().splitlines()[0]) + assert not smoke_log.exists(), ( + f"smoke log {smoke_log} was left behind by a signal delivered inside the teardown" + ) + + +def test_served_version_assertion_accepts_the_deployed_version(tmp_path: Path): + """Positive control for the post-restart served-version check: a unit that + really did swap to the new release reports it, and the deploy proceeds.""" + result = _run_version_assertion(tmp_path, version="0.1.0", served='{"version": "0.1.0"}') + + assert result.returncode == 0, result.stderr + assert "REACHED END" in result.stderr + + +def test_served_version_assertion_strips_a_leading_v(tmp_path: Path): + """The workflow passes the bare pyproject version, but the argument regex + also accepts `v0.1.0` for a manual invocation, while HealthOut.version is + always bare. Without the `${VERSION#v}` strip, every manual `deploy.sh + v0.1.0` would roll back a perfectly good release.""" + result = _run_version_assertion(tmp_path, version="v0.1.0", served='{"version": "0.1.0"}') + + assert result.returncode == 0, result.stderr + assert "REACHED END" in result.stderr + + +def test_served_version_assertion_rejects_a_stale_version(tmp_path: Path): + """The defect this closes: `wait_healthy` only proves *something* answers + /healthz on that port. A restart that did not actually swap processes -- + systemd reporting success while the old unit kept running, a flip that + silently did not take -- leaves the previous release answering, and the + deploy reported success for a version that was never activated. Failing + here, before the rollback trap is disarmed, routes it to the rollback path + instead.""" + result = _run_version_assertion(tmp_path, version="0.2.0", served='{"version": "0.1.0"}') + + assert result.returncode != 0, result.stderr + assert "0.1.0" in result.stderr and "0.2.0" in result.stderr + assert "REACHED END" not in result.stderr + + +def test_served_version_assertion_fails_on_unparseable_healthz(tmp_path: Path): + """A /healthz that answers 200 with something that is not JSON must abort + rather than compare against an empty string and, worse, match an empty + $VERSION. The parse failure is its own diagnostic.""" + result = _run_version_assertion(tmp_path, version="0.1.0", served="nope") + + assert result.returncode != 0, result.stderr + assert "REACHED END" not in result.stderr + + +SETUP_SCRIPT = ( + Path(__file__).resolve().parents[1] / "scripts" / "deploy" / "setup-vps-deploy-user.sh" +) + + +def _extract_setup_line(exact_text: str) -> str: + line = next( + line + for line in SETUP_SCRIPT.read_text(encoding="utf-8").splitlines() + if line.strip() == exact_text + ) + return line.strip() + + +def _extract_setup_world_check() -> str: + """Pulls setup-vps-deploy-user.sh's "half two" block -- the find over the + whole of $APP_DIR that refuses to finish provisioning while anything under + it is reachable by an unrelated local account.""" + lines = SETUP_SCRIPT.read_text(encoding="utf-8").splitlines() + start_idx = next( + i for i, line in enumerate(lines) if line.startswith('WORLD_ACCESSIBLE="$(find "$APP_DIR"') + ) + end_idx = next(i for i in range(start_idx + 1, len(lines)) if lines[i].strip() == "fi") + return "\n".join(lines[start_idx : end_idx + 1]) + + +def _stale_incoming_tree(tmp_path: Path) -> tuple[Path, Path]: + """$APP_DIR as a pre-fix failed deploy leaves it: correct directory modes + throughout, but a 0644 bundle still sitting in incoming/ because deploy.sh + only removes it on the success path.""" + app = tmp_path / "app" + (app / "releases").mkdir(parents=True) + (app / "incoming").mkdir() + bundle = app / "incoming" / "martyrology-1.0.0-linux-x86_64-cp312.tar.gz" + bundle.write_bytes(b"corpus") + bundle.chmod(0o644) + (app / "incoming" / f"{bundle.name}.sha256").write_text("x\n", encoding="utf-8") + (app / "incoming" / f"{bundle.name}.sha256").chmod(0o644) + (app / "incoming").chmod(0o700) + (app / "releases").chmod(0o2750) + app.chmod(0o750) + return app, bundle + + +def test_provisioning_remediates_a_stale_world_readable_bundle_in_incoming(tmp_path: Path): + """setup-vps-deploy-user.sh retracted world bits recursively from + releases/ but not from incoming/, so a bundle left there by a pre-fix + failed deploy kept the 0644 the deploy user's ssh umask gave it. The + script's own half-two `find` then *detected* it and aborted provisioning + with a message naming the file -- telling the operator to go and fix by + hand something the script was already in the business of fixing. + + Both halves are asserted, in the order the script runs them, and the check + is deliberately left untouched: the fix is the remediation, not a weaker + check. First that the check really does fire on the stale tree (otherwise + the second half would prove nothing), then that the spliced `chmod -R` line + fixes it in place and the same check passes afterwards. + + `go-rwx`, not releases/'s `g+rX`: the bundle is a second copy of the + licensed corpus in tarball form and only the deploy user ever needs it, so + the group bits must come off too -- which the mode assertions below pin. + """ + check = _extract_setup_world_check() + app, bundle = _stale_incoming_tree(tmp_path) + + before = subprocess.run( + ["bash", "-c", f'APP_DIR={app}\n{check}\necho "REACHED END" >&2\n'], + capture_output=True, + text=True, + ) + assert before.returncode != 0, ( + f"a 0644 bundle in incoming/ must abort provisioning; stderr={before.stderr!r}" + ) + assert str(bundle) in before.stderr + assert "REACHED END" not in before.stderr + + chmod_line = _extract_setup_line('chmod -R u+rwX,go-rwx "$APP_DIR/incoming"') + after = subprocess.run( + ["bash", "-c", f'APP_DIR={app}\n{chmod_line}\n{check}\necho "REACHED END" >&2\n'], + capture_output=True, + text=True, + ) + assert after.returncode == 0, after.stderr + assert "REACHED END" in after.stderr + + assert bundle.stat().st_mode & 0o077 == 0, ( + "the stale bundle must end up owner-only, not merely non-world-readable: " + f"got {bundle.stat().st_mode & 0o777:04o}" + ) + assert bundle.stat().st_mode & 0o600 == 0o600, "the deploy user must still be able to read it" + assert (app / "incoming").stat().st_mode & 0o777 == 0o700, "incoming/ itself must stay 0700" + + +TOKEN_WATCH_WORKFLOW = ( + Path(__file__).resolve().parents[1] / ".github" / "workflows" / "token-expiry-watch.yml" +) + + +def _extract_open_issue_function() -> str: + """Pulls open_issue() out of the token-expiry watch workflow's `run:` + block. Read as raw text and dedented rather than parsed as YAML, so the + test needs no YAML dependency and sees exactly the bytes the workflow + ships.""" + lines = TOKEN_WATCH_WORKFLOW.read_text(encoding="utf-8").splitlines() + start_idx = next(i for i, line in enumerate(lines) if line.strip() == "open_issue() {") + indent = len(lines[start_idx]) - len(lines[start_idx].lstrip()) + end_idx = next( + i + for i in range(start_idx + 1, len(lines)) + if lines[i].strip() == "}" and len(lines[i]) - len(lines[i].lstrip()) == indent + ) + return "\n".join( + line[indent:] if line.strip() else "" for line in lines[start_idx : end_idx + 1] + ) + + +def test_token_watch_dedup_survives_a_payload_larger_than_the_pipe_buffer(tmp_path: Path): + """Regression test for replacing `printf '%s\\n' "$existing" | grep -Fxq` + with a here-string -- the same defect class already screened for in + deploy.sh's tar listings. + + `grep -q` exits the moment it matches. If the writer still has data to + push, and the payload is larger than the 64 KiB pipe buffer, the writer is + still blocked in write() when the reader goes away and takes SIGPIPE. Under + `pipefail` the pipeline then reports non-zero, the `if` reads FALSE, and the + workflow files a duplicate issue -- precisely in the repository that has + enough open issues for the payload to get that big. A here-string has no + writer to signal. + + The harness stubs `gh` so `issue list` emits the matching title FIRST (so + grep exits at once, with the maximum left to write) followed by well over + 64 KiB of filler titles, and so `issue create` announces itself loudly. The + real open_issue() body is spliced out of the workflow, not re-typed. + """ + open_issue = _extract_open_issue_function() + harness = tmp_path / "dedup-harness.sh" + harness.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + 'REPO="owner/repo"\n' + 'TITLE="SUBMODULE_TOKEN expires soon (2026-09-01)"\n' + "gh() {\n" + ' if [ "${2:-}" = "list" ]; then\n' + ' printf "%s\\n" "$TITLE"\n' + " for i in $(seq 1 8000); do\n" + ' printf "filler issue title number %06d padded out a bit further\\n" "$i"\n' + " done\n" + " return 0\n" + " fi\n" + ' echo "CREATED DUPLICATE ISSUE" >&2\n' + "}\n" + "\n" + f"{open_issue}\n" + "\n" + 'open_issue "$TITLE" "body"\n', + encoding="utf-8", + ) + + result = subprocess.run(["bash", str(harness)], capture_output=True, text=True, timeout=120) + + assert "CREATED DUPLICATE ISSUE" not in result.stderr, ( + "an already-open issue was re-filed: the dedup match lost to SIGPIPE on a " + f"payload larger than the pipe buffer; stdout={result.stdout!r} stderr={result.stderr!r}" + ) + assert "Issue already open" in result.stdout, result.stdout + assert result.returncode == 0, result.stderr + + +DEPLOY_WORKFLOW = Path(__file__).resolve().parents[1] / ".github" / "workflows" / "deploy.yml" + + +def _extract_scp_destination_pieces() -> tuple[str, str]: + """Pulls the upload step's scp destination expression and the + REMOTE_APP_DIR assignment that must precede it out of deploy.yml. + + The assignment is found by searching *backwards* from the destination, so a + revert that drops it from the upload step is not silently satisfied by the + identical line in the "Activate release" step further down the file.""" + lines = DEPLOY_WORKFLOW.read_text(encoding="utf-8").splitlines() + dest_idx = next(i for i, line in enumerate(lines) if line.strip().endswith('/incoming/"; then')) + assign_idx = next( + i for i in range(dest_idx, -1, -1) if lines[i].strip().startswith("REMOTE_APP_DIR=") + ) + destination = lines[dest_idx].strip().removesuffix("; then") + return lines[assign_idx].strip(), destination + + +def test_scp_destination_is_quoted_for_the_remote_shell(tmp_path: Path): + """scp's destination is not a local path: everything after the colon is + handed to the remote end and expanded by the remote shell, exactly like the + ssh command in the "Activate release" step. The ssh step was already + `printf %q`-safe; the scp destination interpolated $APP_DIR raw, so an + APP_DIR containing whitespace was re-split remotely and the bundle landed + somewhere other than where the deploy script then looked for it. + + Simulated rather than asserted textually: the two real lines are spliced + out of the workflow, run with an APP_DIR containing a space, and the + resulting remote path is then word-split by a second bash -- standing in + for the remote shell. It must come back as exactly one word. + """ + assignment, destination = _extract_scp_destination_pieces() + harness = tmp_path / "scp-dest-harness.sh" + harness.write_text( + "#!/usr/bin/env bash\n" + "set -euo pipefail\n" + "APP_DIR='/opt/mar ty'\n" + "VPS_USERNAME=deployer\n" + "VPS_HOST=vps.example\n" + f"{assignment}\n" + f"DEST={destination}\n" + 'printf "%s" "${DEST#*:}"\n', + encoding="utf-8", + ) + remote_path = subprocess.run( + ["bash", str(harness)], capture_output=True, text=True, check=True + ).stdout + + words = subprocess.run( + ["bash", "-c", f'for w in {remote_path}; do printf "[%s]\\n" "$w"; done'], + capture_output=True, + text=True, + check=True, + ).stdout.splitlines() + + assert words == ["[/opt/mar ty/incoming/]"], ( + "the remote shell must see the destination as one literal word; " + f"got {words!r} from {remote_path!r}" + ) + + +def test_normalise_function_still_aborts_the_deploy_when_its_self_check_fails(tmp_path: Path): + """The self-check moved inside a function when it was made re-runnable, and + that move is exactly the kind that can turn a hard stop into a soft one: a + `return` where an `exit` was meant, or a caller that swallows the status, + would leave the deploy running on a tree that failed its own check -- + a failure reporting success, which is the recurring defect in this script's + history. + + `chgrp` and `chmod` are stubbed to no-ops (shell functions take precedence + over external commands) so the tree stays as built and the check has + something to catch; the real function body is spliced out of deploy.sh + unchanged. What is asserted is the *control flow*: the diagnostic is + printed, the process exits non-zero, and nothing after the call runs. + """ + normalise_fn = _extract_normalise_function() + die_fn = _extract_die_function() + + release = tmp_path / "release" + (release / "data").mkdir(parents=True) + corpus = release / "data" / "01.json" + corpus.write_text("{}", encoding="utf-8") + corpus.chmod(0o644) + (release / "data").chmod(0o755) + release.chmod(0o755) + + script = ( + "set -euo pipefail\n" + f"RELEASE={release}\nSERVICE_GROUP={_own_group()}\n" + "chgrp() { return 0; }\n" + "chmod() { return 0; }\n" + f"{die_fn}\n{normalise_fn}\n" + "normalise_release_permissions\n" + 'echo "REACHED END" >&2\n' + ) + result = subprocess.run(["bash", "-c", script], capture_output=True, text=True) + + assert result.returncode != 0, ( + f"a failing self-check must abort the deploy, not return to the caller; " + f"stderr={result.stderr!r}" + ) + assert "other-access denied" in result.stderr + assert str(corpus) in result.stderr + assert "REACHED END" not in result.stderr From 50d5e872e165172c6d4270b072054058845314c2 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 13:27:42 +0200 Subject: [PATCH 24/25] Fix duplicated bundle members and tighten deploy checks Addresses the reviewed CodeRabbit findings on PR #21. build_bundle.assemble() wrote every file into the tarball more than once: tarfile.add() recurses by default, so adding a directory added its whole subtree and the rglob walk then added each of those files again. On the test staging tree that was 10 members for 6 unique paths, with one file written three times; on a real bundle it multiplies the artifact size and makes extraction rewrite every file repeatedly. Pass recursive=False, and assert member-name uniqueness (plus the exact member set, so uniqueness cannot be bought by dropping entries). Byte-identical repeat builds are deliberately not attempted: w:gz embeds the current time and member metadata comes from the filesystem, and auditability here rests on the per-file sha256 map in manifest.json and the bundle checksum deploy.sh verifies, not on tarball byte-identity. deploy.sh now clears setuid/setgid on the extracted tree (a-s) and fails the self-check on any survivor (-perm /6000). Scope is $RELEASE only; $APP_DIR/releases keeps its deliberate 2750. Because releases/ is setgid, $RELEASE and every directory tar creates inside it inherit that bit, so the chmod is what lets the new check pass on a real deploy at all -- a test builds exactly that shape to keep the two from drifting apart. manifest.load_manifest() rejects a manifest whose data mapping lacks texts, crmedr or clbdr. Membership, not equality, so a fourth data repo does not break older readers. This is the reader-side counterpart to the workflow's staged-shape check and guards the one failure this project has hit: a data tree vanishing while everything still reported healthy. The hex-length and path-shape validation also proposed is not added -- the manifest lives inside a bundle whose sha256 is verified before anything is extracted, so a malformed manifest implies a CI bug, not tampering. deploy.yml refuses a non-deployable pyproject version (0.2.0rc1, 1.0.0.post1) in the Resolve version step rather than after building and uploading a whole bundle; pins uv==0.12.1, exercised locally against uv build --wheel and uv export --frozen; sets StrictHostKeyChecking=yes explicitly on scp and ssh; and corrects the stale comment claiming deploy.sh runs sha256sum -c. token-expiry-watch.yml passes --limit 500 to gh issue list, which otherwise defaults to 30 and would file a duplicate warning once the repo has more open issues than that. Tests: derive the "wrong" group at runtime instead of hard-coding root, which would have inverted into a silent pass wherever root is the primary group; and replace the fixed sleep before SIGTERM with a readiness marker the harness prints once its traps are armed. CodeRabbit's report that the token-watch issue bodies render as code blocks is a false positive: the leading spaces belong to the YAML block scalar and are stripped before the shell ever sees them. Verified by executing the extracted run script with gh stubbed and printing the body. Docs: spec Status reflects that the design is implemented and the VPS provisioned, with only the first release deploy outstanding; three untagged fences tagged; one blockquote made contiguous. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/deploy.yml | 29 ++- .github/workflows/token-expiry-watch.yml | 6 +- .../plans/2026-08-01-continuous-deployment.md | 2 +- ...2026-08-01-continuous-deployment-design.md | 11 +- scripts/deploy/build_bundle.py | 9 +- scripts/deploy/deploy.sh | 21 +- src/martyrology_api/manifest.py | 15 +- tests/test_build_bundle.py | 36 ++++ tests/test_deploy_script.py | 182 ++++++++++++++++-- tests/test_manifest.py | 28 ++- 10 files changed, 314 insertions(+), 25 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e142d0f..f1dfc97 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -51,6 +51,19 @@ jobs: set -euo pipefail VERSION="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')" + # deploy.sh accepts only ^v?[0-9]+(\.[0-9]+)*$ — the version becomes + # part of a path and of a filename on the VPS. A PEP 440 prerelease or + # postrelease ("0.2.0rc1", "1.0.0.post1") would otherwise build a full + # bundle, upload it, and be refused on the remote at the very last + # step, after the corpus has already been shipped. Refuse it here. + # A bash conditional, not `printf | grep`: no pipeline means no way + # for `pipefail` plus an early-exiting reader to turn this check's + # result into something other than what it matched. + if [[ ! "$VERSION" =~ ^[0-9]+(\.[0-9]+)*$ ]]; then + echo "::error::pyproject.toml version '${VERSION}' is not deployable: deploy.sh accepts only digits and dots (e.g. 0.2.0). Prerelease and postrelease versions (rc, a, b, .dev, .post) cannot be deployed." + exit 1 + fi + # The bundle is named, installed and recorded in manifest.json from # pyproject.toml, never from the tag. Publishing v0.2.0 without # bumping pyproject.toml therefore builds and deploys 0.1.0: on a @@ -74,7 +87,12 @@ jobs: - name: Build wheel and offline wheelhouse run: | - pip install uv + # Pinned deliberately. `uv export` output is an input to the + # wheelhouse, so an unpinned uv could change what gets bundled + # between two runs of the same commit — which is precisely the + # determinism tracking uv.lock exists to provide. Bump this + # deliberately, after exercising `uv build` and `uv export` locally. + pip install uv==0.12.1 mkdir -p staging/wheels uv build --wheel --out-dir dist uv export --frozen --no-dev --no-emit-project --format requirements-txt -o requirements.txt @@ -147,8 +165,11 @@ jobs: --version "$VERSION" --api-version "$VERSION" \ --staging staging --out out --repo-root .)" NAME="$(basename "$BUNDLE")" - # Generated from inside out/ so the checksum file names the bundle - # bare; deploy.sh runs `sha256sum -c` from the incoming/ directory. + # Generated from inside out/ so field 2 of the checksum file is the + # bundle's bare basename. deploy.sh does not run `sha256sum -c`: it + # parses the file, asserts field 2 equals the bundle's basename, and + # compares the digest itself (deploy.sh:163-172). A checksum written + # from the repo root would name "out/" and be refused there. (cd out && sha256sum "$NAME" > "$NAME.sha256") echo "path=$BUNDLE" >> "$GITHUB_OUTPUT" ls -la out @@ -226,6 +247,7 @@ jobs: for attempt in 1 2 3; do echo "Upload attempt $attempt..." if scp -i ~/.ssh/deploy_key \ + -o StrictHostKeyChecking=yes \ -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=2 \ "$BUNDLE" "$BUNDLE.sha256" \ "${VPS_USERNAME}@${VPS_HOST}:${REMOTE_APP_DIR}/incoming/"; then @@ -259,6 +281,7 @@ jobs: REMOTE_APP_DIR="$(printf '%q' "$APP_DIR")" REMOTE_VERSION="$(printf '%q' "$VERSION")" ssh -i ~/.ssh/deploy_key \ + -o StrictHostKeyChecking=yes \ -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=2 \ "${VPS_USERNAME}@${VPS_HOST}" \ "APP_DIR=${REMOTE_APP_DIR} bash ${REMOTE_APP_DIR}/bin/deploy.sh ${REMOTE_VERSION}" diff --git a/.github/workflows/token-expiry-watch.yml b/.github/workflows/token-expiry-watch.yml index 694f1da..6cd9992 100644 --- a/.github/workflows/token-expiry-watch.yml +++ b/.github/workflows/token-expiry-watch.yml @@ -49,8 +49,12 @@ jobs: # the false branch, and a duplicate issue is filed precisely when # the repo has enough open issues for it to matter. A here-string # has no writer to signal. + # --limit 500 because `gh issue list` defaults to 30: once the repo + # has more than 30 open issues, the existing warning can fall off + # the end of the list, the dedup match fails, and a duplicate is + # filed every week for the whole warning window. local existing - existing="$(gh issue list --repo "$REPO" --state open --json title --jq '.[].title')" + existing="$(gh issue list --repo "$REPO" --state open --limit 500 --json title --jq '.[].title')" if grep -Fxq "$title" <<<"$existing"; then echo "Issue already open: $title" return 0 diff --git a/docs/superpowers/plans/2026-08-01-continuous-deployment.md b/docs/superpowers/plans/2026-08-01-continuous-deployment.md index 5be3cb0..bbb9658 100644 --- a/docs/superpowers/plans/2026-08-01-continuous-deployment.md +++ b/docs/superpowers/plans/2026-08-01-continuous-deployment.md @@ -27,7 +27,7 @@ > Two further defects were introduced by fix rounds and caught by scoped re-review: > the SIGPIPE fix broke the name screen via `awk '{print $NF}'`, and adding > `INT TERM` to two traps made signalled deploys exit 0 with `current` left flipped. - +> > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ship `martyrology-api` to the Plesk-managed VPS automatically on every published GitHub release, bundling the private text corpus and the two public registries into one verifiable, rollback-able artifact. diff --git a/docs/superpowers/specs/2026-08-01-continuous-deployment-design.md b/docs/superpowers/specs/2026-08-01-continuous-deployment-design.md index afa9cc6..ed2dfbb 100644 --- a/docs/superpowers/specs/2026-08-01-continuous-deployment-design.md +++ b/docs/superpowers/specs/2026-08-01-continuous-deployment-design.md @@ -1,7 +1,10 @@ # Continuous Deployment Design **Date:** 2026-08-01 -**Status:** Approved, pending implementation +**Status:** Implemented and merged; the VPS is provisioned (deploy and service +users, the `martyrology` group, `/opt/martyrology`, the sudoers drop-in, the +systemd unit, the nginx reverse proxy on `api.romanmartyrology.com`, and TLS +issued). Only the first release deploy is outstanding. **Supersedes:** the three-option deployment list in `docs/architecture.md` ## Problem @@ -122,7 +125,7 @@ vendor/clbdr` succeeds for everyone, and the graceful-degradation guarantee Artifact name: `martyrology--linux-x86_64-cp312.tar.gz` -``` +```text manifest.json wheels/ martyrology_api--py3-none-any.whl + every resolved runtime dependency as a wheel @@ -239,7 +242,7 @@ Plesk may rearrange things underneath it. Everything under `/opt/martyrology` is owned `martyrology-deploy:martyrology`, and no path anywhere in it carries an "other" bit. -``` +```text /opt/martyrology/ martyrology-deploy:martyrology 0750 bin/ 0750 bin/deploy.sh 0750, installed by the setup script @@ -292,7 +295,7 @@ to do so. `/etc/sudoers.d/martyrology-deploy`: -``` +```text martyrology-deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart martyrology-api.service, \ /usr/bin/systemctl is-active martyrology-api.service ``` diff --git a/scripts/deploy/build_bundle.py b/scripts/deploy/build_bundle.py index 0c61826..1e209d7 100644 --- a/scripts/deploy/build_bundle.py +++ b/scripts/deploy/build_bundle.py @@ -82,7 +82,14 @@ def assemble(staging: Path, out_dir: Path, version: str) -> Path: tarball = out_dir / BUNDLE_NAME.format(version=version) with tarfile.open(tarball, "w:gz") as archive: for path in sorted(staging.rglob("*")): - archive.add(path, arcname=path.relative_to(staging).as_posix()) + # recursive=False is load-bearing: tarfile.add() recurses by + # default, so adding a directory would add its whole subtree and + # then rglob would yield each of those files again and add them a + # second (or third) time. rglob already walks the tree, so every + # entry — directories included, since their modes are what + # deploy.sh later normalises — is added exactly once, in sorted + # order. + archive.add(path, arcname=path.relative_to(staging).as_posix(), recursive=False) return tarball diff --git a/scripts/deploy/deploy.sh b/scripts/deploy/deploy.sh index 3c46b25..b348f1a 100755 --- a/scripts/deploy/deploy.sh +++ b/scripts/deploy/deploy.sh @@ -309,7 +309,20 @@ python3.12 -m venv "$RELEASE/venv" # complete, not per-file or in a loop. normalise_release_permissions() { chgrp -R "$SERVICE_GROUP" "$RELEASE" - chmod -R u+rwX,g+rX,o-rwx "$RELEASE" + # `a-s` clears setuid/setgid. Tar member modes come from the CI runner and + # tar restores them verbatim, so a setuid or setgid bit that got into the + # staging tree survives extraction into a tree the service group can read + # and, for anything with an execute bit, run. Nothing in a release bundle + # has any business being setuid or setgid, so strip both here rather than + # trust the runner's modes. Scope is $RELEASE only: the deliberate setgid + # on $APP_DIR/releases (2750), which is what makes each new release + # directory inherit the $SERVICE_GROUP, is set by setup-vps-deploy-user.sh + # and lives one level above this path — untouched by a chmod rooted here. + # $RELEASE and the directories tar creates inside it *do* inherit that + # setgid bit, and `a-s` clears it from them. That costs nothing: the + # chgrp -R above sets the group outright, and runs again after the smoke + # check, so nothing here depends on inheritance to get the group right. + chmod -R u+rwX,g+rX,o-rwx,a-s "$RELEASE" # The two lines above are the fix; this proves they stuck, without # impersonating the service account (this script has no sudo grant for @@ -337,10 +350,16 @@ normalise_release_permissions() { # captured into the same variable, so such a failure reports rather than # passes. UNREADABLE is deliberately not `local`: the tests splice this # block out of the script verbatim and run it at top level. + # The `-perm /6000` arm is the proof for the `a-s` above: any setuid or + # setgid bit still standing here means the chmod did not take, and a + # setgid directory in particular would keep re-applying itself to + # everything written under it afterwards. Fail loudly rather than + # activate it. UNREADABLE="$(find "$RELEASE" ! -type l \( \ \( -type d ! -perm -0050 \) -o \ \( -type f ! -perm -0040 \) -o \ -perm /0007 -o \ + -perm /6000 -o \ ! -group "$SERVICE_GROUP" \) 2>&1)" || true if [ -n "$UNREADABLE" ]; then echo "$UNREADABLE" >&2 diff --git a/src/martyrology_api/manifest.py b/src/martyrology_api/manifest.py index 2e862dd..eeb2a54 100644 --- a/src/martyrology_api/manifest.py +++ b/src/martyrology_api/manifest.py @@ -5,6 +5,16 @@ BUNDLE_FORMAT = 1 +# Every data repository a bundle must account for. This is the reader-side +# counterpart to the deploy workflow's "Verify staged data shape" step, and it +# guards the one failure this project has actually hit: a data tree silently +# vanishing from the bundle while the app still came up healthy serving the +# remaining editions, so nothing anywhere reported a problem. +# +# Membership, not equality: a fourth data repository added later must not make +# every existing release unreadable by an older reader. +REQUIRED_DATA_KEYS = frozenset({"texts", "crmedr", "clbdr"}) + class Manifest(BaseModel): """The deployment manifest written into every release bundle. @@ -26,7 +36,8 @@ def load_manifest(path: Path | None) -> Manifest | None: """Read a deployment manifest, or None when it is absent or unusable. Absence is the ordinary development case: no bundle, no manifest. A - malformed manifest, or one written by a future bundle format, is also + malformed manifest, one written by a future bundle format, or one whose + `data` mapping does not account for all of REQUIRED_DATA_KEYS, is also reported as absent rather than raised. /healthz is what the deploy script polls to decide whether to roll back, so it must keep answering even when the manifest is the thing that is broken. @@ -43,4 +54,6 @@ def load_manifest(path: Path | None) -> Manifest | None: return None if manifest.bundle_format != BUNDLE_FORMAT: return None + if not REQUIRED_DATA_KEYS.issubset(manifest.data): + return None return manifest diff --git a/tests/test_build_bundle.py b/tests/test_build_bundle.py index dacaf28..f499062 100644 --- a/tests/test_build_bundle.py +++ b/tests/test_build_bundle.py @@ -78,6 +78,42 @@ def test_assemble_writes_a_tarball_with_a_manifest_at_the_root(tmp_path: Path): assert "data/crmedr/ids.json" in names +def test_assemble_writes_each_member_exactly_once(tmp_path: Path): + """Regression test for the duplicated-member bug. + + `tarfile.add()` recurses by default, so adding `data/` pulled in its whole + subtree and the `rglob` walk then added each of those files again — on this + staging tree, 10 members for 6 unique paths, with `data/crmedr/ids.json` + appearing three times. On a real bundle (full corpus plus wheelhouse) that + multiplies the artifact size and makes extraction rewrite every file + repeatedly, with the last copy silently winning. `recursive=False` on the + add() call is what keeps this true; remove it and this test fails. + + The staging tree deliberately has nested directories (`data/crmedr/`), since + a flat tree could not reproduce the bug at all. + """ + root = _staging(tmp_path) + out = tmp_path / "out" + out.mkdir() + build_bundle.write_manifest(root, "0.1.0", "a" * 40, COMMITS) + tarball = build_bundle.assemble(root, out, "1.2.3") + with tarfile.open(tarball) as archive: + names = archive.getnames() + assert len(names) == len(set(names)), ( + f"tarball contains duplicate members: {sorted(n for n in set(names) if names.count(n) > 1)}" + ) + # And nothing was dropped in the process: directories carry the modes + # deploy.sh normalises, so they must still be present as their own members. + assert set(names) == { + "manifest.json", + "data", + "data/crmedr", + "data/crmedr/ids.json", + "wheels", + "wheels/fake.whl", + } + + def test_assemble_refuses_a_staging_tree_with_no_manifest(tmp_path: Path): """A bundle with no manifest has no provenance, yet would pass deploy.sh's manifest check and serve with an empty audit trail. Fail the build instead.""" diff --git a/tests/test_deploy_script.py b/tests/test_deploy_script.py index e378973..f706609 100644 --- a/tests/test_deploy_script.py +++ b/tests/test_deploy_script.py @@ -1,4 +1,5 @@ import contextlib +import grp import hashlib import io import signal @@ -11,6 +12,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +import pytest + SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "deploy" / "deploy.sh" @@ -169,7 +172,10 @@ def _extract_permission_selfcheck() -> str: def _extract_permission_selfcheck_block() -> tuple[str, str]: """The two pieces the permission tests below splice: the literal chmod line from deploy.sh, and the self-check block that follows it.""" - return _extract_line('chmod -R u+rwX,g+rX,o-rwx "$RELEASE"'), _extract_permission_selfcheck() + return ( + _extract_line('chmod -R u+rwX,g+rX,o-rwx,a-s "$RELEASE"'), + _extract_permission_selfcheck(), + ) def _own_group() -> str: @@ -199,6 +205,26 @@ def _extract_rollback_harness_pieces() -> tuple[str, str, str]: return function_text, exit_trap_line.strip(), signal_trap_line.strip() +HARNESS_READY = "HARNESS ARMED" + + +def _wait_for_ready(proc: "subprocess.Popen[str]") -> None: + """Block until the harness says its traps are armed. + + Replaces a fixed `time.sleep(0.3)` before the SIGTERM. That sleep was a + race: under load (a parallel test run, a busy CI box) the signal could + land before the traps existed, so bash's default disposition killed the + harness outright and the test failed for a reason that has nothing to do + with what it is testing. Worse, the same sleep sets the *upper* bound too + -- there is no arrival time that is both certainly-after-arming and + certainly-before the harness's `sleep 2` elapses. Reading the marker + removes both ends of that guess. + """ + assert proc.stdout is not None + line = proc.stdout.readline() + assert HARNESS_READY in line, f"harness never reported readiness; first stdout line: {line!r}" + + def _build_signal_harness(tmp_path: Path) -> Path: function_text, exit_trap_line, signal_trap_line = _extract_rollback_harness_pieces() harness = tmp_path / "harness.sh" @@ -215,6 +241,11 @@ def _build_signal_harness(tmp_path: Path) -> Path: f"{exit_trap_line}\n" f"{signal_trap_line}\n" "\n" + # Emitted immediately after the traps are armed, and before the sleep + # that stands in for the flip window, so the test can signal at a + # point it knows is inside that window rather than guessing at one. + f'echo "{HARNESS_READY}"\n' + "\n" "sleep 2\n" 'echo "harness: sleep completed without a signal" >&2\n' "ROLLBACK_ARMED=0\n" @@ -740,7 +771,7 @@ def test_chmod_grants_group_access_and_denies_every_other_account(tmp_path: Path while a file that already had an owner execute bit does gain the group execute bit, and directories become group-traversable. """ - chmod_line = _extract_line('chmod -R u+rwX,g+rX,o-rwx "$RELEASE"') + chmod_line = _extract_line('chmod -R u+rwX,g+rX,o-rwx,a-s "$RELEASE"') release = tmp_path / "release" (release / "sub").mkdir(parents=True) @@ -851,17 +882,145 @@ def test_permission_selfcheck_fails_loudly_on_a_world_readable_tree(tmp_path: Pa assert "REACHED END" not in result.stderr +def test_permission_selfcheck_fails_loudly_on_a_setuid_or_setgid_entry(tmp_path: Path): + """Tar restores member modes verbatim, and those modes come from the CI + runner, so a setuid or setgid bit that reached the staging tree lands + intact in a release tree the whole service group can read -- and, for + anything carrying an execute bit, run. A setgid *directory* is worse + still: it keeps re-applying itself to everything written under it after + the normalisation has already been asserted. + + The chmod's `a-s` is the fix; this is the arm that proves it stuck. The + tree here is otherwise textbook-correct (group-readable, no other bits, + right group), so `-perm /6000` is the only thing that can fire. + """ + release = tmp_path / "release" + (release / "data").mkdir(parents=True) + setuid_file = release / "data" / "helper" + setuid_file.write_text("#!/bin/sh\n", encoding="utf-8") + subprocess.run( + ["bash", "-c", f'chmod -R u+rwX,g+rX,o-rwx "{release}"'], + capture_output=True, + text=True, + check=True, + ) + setuid_file.chmod(setuid_file.stat().st_mode | 0o4000) + + result = _run_selfcheck(release, _own_group()) + + assert result.returncode != 0, result.stderr + assert str(setuid_file) in result.stderr + assert "REACHED END" not in result.stderr + + +def test_chmod_clears_setuid_and_setgid_from_the_release_tree(tmp_path: Path): + """The positive half of the arm above: the real chmod line, run against a + tree carrying a setuid file, a setgid file and a setgid directory, must + leave none of the three -- while still granting the group access the + service account needs. Splices deploy.sh's literal chmod line, so dropping + `a-s` from it fails here. + """ + chmod_line = _extract_line('chmod -R u+rwX,g+rX,o-rwx,a-s "$RELEASE"') + + release = tmp_path / "release" + setgid_dir = release / "data" + setgid_dir.mkdir(parents=True) + setuid_file = setgid_dir / "helper" + setgid_file = setgid_dir / "other" + setuid_file.write_text("#!/bin/sh\n", encoding="utf-8") + setgid_file.write_text("{}", encoding="utf-8") + setuid_file.chmod(0o4755) + setgid_file.chmod(0o2644) + setgid_dir.chmod(0o2755) + + result = subprocess.run( + ["bash", "-c", f"RELEASE={release}\n{chmod_line}\n"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + + for path in (setgid_dir, setuid_file, setgid_file): + mode = path.stat().st_mode + assert mode & 0o6000 == 0, f"{path} kept a setuid/setgid bit: {mode & 0o7777:04o}" + assert mode & 0o040 == 0o040, f"{path} lost group read: {mode & 0o7777:04o}" + + +def test_permission_selfcheck_passes_on_a_release_created_under_a_setgid_parent( + tmp_path: Path, +): + """The production shape, and the way the `a-s`/`-perm /6000` pair could + have turned into a check that fires on every deploy. + + `$APP_DIR/releases` is deliberately 2750 (setgid) so each release + directory deploy.sh mkdir's under it inherits the `martyrology` group -- + and inherits the setgid bit along with it, as does every directory tar + creates inside. So the very first real deploy arrives at the self-check + with a tree full of setgid directories. The chmod's `a-s` is what clears + them before the check looks; if it were dropped while the `-perm /6000` + arm stayed, every deploy would fail here. + + Group ownership does not depend on the inherited bit: the chgrp -R + immediately above the chmod sets it outright, and runs again after the + smoke check, so stripping setgid costs nothing. + """ + chmod_line, selfcheck = _extract_permission_selfcheck_block() + die_fn = _extract_die_function() + + releases = tmp_path / "releases" + releases.mkdir() + releases.chmod(0o2750) + assert releases.stat().st_mode & 0o2000, "setgid did not stick; test cannot prove anything" + + release = releases / "1.0.0" + (release / "data").mkdir(parents=True) + (release / "data" / "01.json").write_text("{}", encoding="utf-8") + assert (release / "data").stat().st_mode & 0o2000, ( + "the release subtree did not inherit setgid from its parent; " + "this test is not exercising the production shape" + ) + + script = ( + f"RELEASE={release}\nSERVICE_GROUP={_own_group()}\n" + f'{die_fn}\n{chmod_line}\n{selfcheck}\necho "REACHED END" >&2\n' + ) + result = subprocess.run(["bash", "-c", script], capture_output=True, text=True) + + assert result.returncode == 0, result.stderr + assert "REACHED END" in result.stderr + assert releases.stat().st_mode & 0o2000, ( + "the chmod is rooted at $RELEASE and must not have touched releases/'s setgid bit, " + "which is what makes new release directories inherit the service group" + ) + + +def _a_group_the_tree_is_not_in(path: Path) -> str: + """A group name that is definitely NOT the group owning `path`. + + Derived at runtime rather than hard-coded to `root`: the previous version + of this test used `root` as its stand-in non-service group, which quietly + inverts into a false pass the moment the suite runs somewhere root is the + process's primary group (a container, a CI image running as uid 0) -- the + tree would then genuinely be in `root` and the `! -group` arm it exists to + exercise would never fire, while the test still went green for the wrong + reason. Skips instead of guessing if the host has only one group defined. + """ + tree_gid = path.stat().st_gid + for entry in grp.getgrall(): + if entry.gr_gid != tree_gid: + return entry.gr_name + pytest.skip("host defines no group other than the one owning the test tree") + + def test_permission_selfcheck_fails_loudly_when_the_tree_is_not_in_the_service_group( tmp_path: Path, ): """Group bits are only worth anything if the group is the one the - service account is in. `root` stands in for "some group that is not - $SERVICE_GROUP": it exists on every Linux host and the tree is - certainly not in it, so the ! -group arm must fire. Without this arm a - tree left in the deploy user's own primary group -- what happens if - releases/'s setgid bit is lost and the chgrp is dropped -- would sail - through with textbook-correct 0750/0640 modes and be unreadable to the - service account at runtime. + service account is in, so the `! -group` arm must fire whenever the tree + is in some other group. Without it a tree left in the deploy user's own + primary group -- what happens if releases/'s setgid bit is lost and the + chgrp is dropped -- would sail through with textbook-correct 0750/0640 + modes and be unreadable to the service account at runtime. """ release = tmp_path / "release" (release / "data").mkdir(parents=True) @@ -872,8 +1031,7 @@ def test_permission_selfcheck_fails_loudly_when_the_tree_is_not_in_the_service_g text=True, check=True, ) - - result = _run_selfcheck(release, "root") + result = _run_selfcheck(release, _a_group_the_tree_is_not_in(release)) assert result.returncode != 0, result.stderr assert "not group-readable" in result.stderr @@ -955,7 +1113,7 @@ def test_signal_during_flip_window_rolls_back_instead_of_exiting_zero(tmp_path: stderr=subprocess.PIPE, text=True, ) - time.sleep(0.3) + _wait_for_ready(proc) proc.send_signal(signal.SIGTERM) stdout, stderr = proc.communicate(timeout=5) assert proc.returncode != 0, ( diff --git a/tests/test_manifest.py b/tests/test_manifest.py index de10b43..4d51efb 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -3,11 +3,13 @@ from martyrology_api.manifest import load_manifest +GOOD_DATA = {"texts": "t" * 40, "crmedr": "c" * 40, "clbdr": "l" * 40} + GOOD = { "bundle_format": 1, "api_version": "0.1.0", "api_commit": "a" * 40, - "data": {"texts": "t" * 40, "crmedr": "c" * 40, "clbdr": "l" * 40}, + "data": GOOD_DATA, "python_requires": ">=3.12", "files": {"data/crmedr/x.json": "0" * 64}, } @@ -42,6 +44,30 @@ def test_unknown_bundle_format_yields_none(tmp_path: Path): assert load_manifest(_write(tmp_path, {**GOOD, "bundle_format": 99})) is None +def test_manifest_missing_a_data_repository_yields_none(tmp_path: Path): + """A bundle whose `data` map has lost a repository is not a usable + bundle, and this is the failure this project has actually hit: the + private corpus disappeared from the staged tree while the app still came + up healthy on the remaining editions, so nothing reported it. deploy.sh + runs load_manifest against the extracted bundle before activating it, so + rejecting here is what stops such a bundle going live. + """ + for missing in ("texts", "crmedr", "clbdr"): + data = {k: v for k, v in GOOD_DATA.items() if k != missing} + assert load_manifest(_write(tmp_path, {**GOOD, "data": data})) is None, ( + f"a manifest with no {missing!r} data commit must be rejected" + ) + + +def test_manifest_with_an_extra_data_repository_still_parses(tmp_path: Path): + """Membership, not equality: adding a fourth data repository later must + not make every bundle unreadable to a reader that predates it.""" + data = {**GOOD_DATA, "future": "f" * 40} + manifest = load_manifest(_write(tmp_path, {**GOOD, "data": data})) + assert manifest is not None + assert manifest.data["future"] == "f" * 40 + + def test_good_manifest_parses(tmp_path: Path): manifest = load_manifest(_write(tmp_path, GOOD)) assert manifest is not None From 9935b8cc767a0c3cd1a2f3a7d0983fe2720507f1 Mon Sep 17 00:00:00 2001 From: "John R. D'Orazio" Date: Sun, 2 Aug 2026 14:02:12 +0200 Subject: [PATCH 25/25] Bound the signal harness readiness wait _wait_for_ready blocked on an unbounded proc.stdout.readline(). A harness that started but never reached its marker would hang the whole suite -- this repo configures no pytest timeout, so that means the CI job runs to GitHub's limit rather than one test failing. The assertion path also left the process running. Reads through a joinable thread with a 10s backstop, and always terminates and collects the harness before raising. stderr is read directly rather than via communicate(), which would race the reader thread for stdout. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_deploy_script.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/tests/test_deploy_script.py b/tests/test_deploy_script.py index f706609..b814e1b 100644 --- a/tests/test_deploy_script.py +++ b/tests/test_deploy_script.py @@ -206,6 +206,7 @@ def _extract_rollback_harness_pieces() -> tuple[str, str, str]: HARNESS_READY = "HARNESS ARMED" +READY_TIMEOUT = 10.0 def _wait_for_ready(proc: "subprocess.Popen[str]") -> None: @@ -219,10 +220,37 @@ def _wait_for_ready(proc: "subprocess.Popen[str]") -> None: -- there is no arrival time that is both certainly-after-arming and certainly-before the harness's `sleep 2` elapses. Reading the marker removes both ends of that guess. + + The read is bounded. A bare `proc.stdout.readline()` blocks forever if the + harness starts but never reaches its marker, and this repo configures no + pytest timeout, so that would hang the whole suite rather than fail one + test. The harness prints the marker before its `sleep 2`, so the real wait + is well under a second; READY_TIMEOUT is only a backstop. """ assert proc.stdout is not None - line = proc.stdout.readline() - assert HARNESS_READY in line, f"harness never reported readiness; first stdout line: {line!r}" + stdout = proc.stdout + captured: list[str] = [] + reader = threading.Thread(target=lambda: captured.append(stdout.readline()), daemon=True) + reader.start() + reader.join(READY_TIMEOUT) + + line = captured[0] if captured else "" + if reader.is_alive() or HARNESS_READY not in line: + # Always collect the harness before failing: an assertion alone leaves + # it running, and on the timeout path it is still blocked mid-run. + # stderr is read directly rather than via communicate(), which would + # race the reader thread for stdout. + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + stderr = proc.stderr.read() if proc.stderr is not None else "" + raise AssertionError( + f"harness never reported readiness within {READY_TIMEOUT}s; " + f"first stdout line: {line!r}; stderr: {stderr!r}" + ) def _build_signal_harness(tmp_path: Path) -> Path: