diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a55a4b..d3ad917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,36 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.2.3 — 2026-05-26 + +**Patch — no operator action required.** Rebuild and restart per the +routine deploy steps; no `.env` changes, no schema changes, no +behavior changes a deployment would notice in steady state beyond +the new endpoint below. + +### Added + +- **`GET /api/health`** — an unauthenticated probe returning JSON + `{version, status}` for ops tooling. `version` is the running + framework version (the contents of `VERSION` per §20.1, read at + process start and cached as a module-level constant in + `backend/app/health.py`); `status` is `"ok"` with HTTP 200 in + v1. The `"degraded"` / HTTP 503 path stays reserved in the + response shape so a later release can wire real degradation + conditions without breaking the contract. The version-match + check is the structural value — a deploy-control-panel polling + the endpoint after `systemctl restart` catches the failure mode + where a restart did not pick up the new code. See `SPEC.md` §17 + and the §19.2 settlement. + +### Upgrade steps (from 0.2.2) + +1. The deployment **MAY** configure its monitoring (Pingdom, + Healthchecks.io, the flotilla deploy control panel, etc.) to + probe `/api/health` and compare the returned `version` against + the tag last deployed. The endpoint is unauthenticated by + design — no PII in the payload, no session required. + ## 0.2.2 — 2026-05-26 **Patch — no operator action required.** Rebuild the frontend and diff --git a/SPEC.md b/SPEC.md index 30077ec..d69022e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2416,6 +2416,17 @@ specified* and what is intentionally out of scope for v1. The follow-up session will refine this. A minimal starting set: +- `GET /api/health` — unauthenticated. Returns JSON + `{version, status}` where `version` is the running framework + version (the contents of `VERSION` per §20.1, read at process + start) and `status` is `"ok"` (HTTP 200) or `"degraded"` + (HTTP 503). v1 always reports `"ok"`; the 503 / `"degraded"` + path is reserved scaffold for future degradation conditions. + Used by ops tooling (e.g. the flotilla deploy control panel) + as a post-flight probe — the structural check is that the + returned `version` matches the tag the operator just deployed, + catching the failure mode where a restart did not pick up the + new code. - `GET /api/rfcs` — list entries with state, id, title, slug, repo, owners, last_active_at, has_open_prs, starred-by-me. Supports search, sort, filter chips, and the `unclaimed` predicate. @@ -3208,6 +3219,51 @@ binding. read-only relationship counts as active). A future session may settle a tighter definition (e.g., has any `actions` row on the RFC) if the generous proxy refuses too many legitimate mutes. +- **Health-check endpoint for ops tooling.** *Settled in the + post-v1 session that picked it. The flotilla deploy control panel + (a sibling operator-side tool spec'd in flotilla/SPEC.md) needs a + small framework-side endpoint to verify a deploy landed correctly. + Specifically: an unauthenticated `GET /api/health` returning JSON + `{version, status}` where `version` is the running framework + version recorded at startup and `status` is `"ok"` (HTTP 200) or + `"degraded"` (HTTP 503). flotilla uses the endpoint as a + post-flight probe — after `systemctl restart` reports active, + flotilla polls the endpoint and verifies the returned `version` + matches the tag just deployed. The version-match check is the + structural catch for the failure mode where a restart did not + actually pick up the new code. Scope intentionally small: one + endpoint, version + status payload, no auth (no PII), ships in + a patch release as a §17 addition. The endpoint becomes part of + §20.3's versioned surface and is available to any deployment + without operator action — flotilla is one consumer of many + possible. Earns its session next: flotilla v1 is blocked on this + dependency.* + + *Settled in this session as follows. The running process reads + the `VERSION` file at the repo root at import time and caches the + string as a module-level constant (`backend/app/health.py`), + mirroring `backend/app/philosophy.py`'s disk-first shape. The + §20.1 invariant guarantees `VERSION` and + `frontend/package.json#version` are equal, so the choice is + cleanliness rather than correctness: `VERSION` is the canonical + source per §20.1, lives at the repo root, is one text line. A + missing `VERSION` file fails loudly per §20.6 — the module raises + at import time rather than serving a placeholder, because the + whole point of the endpoint is the version-match check and a + silent default would defeat it. `"degraded"` is reserved scaffold + for v1: a healthy startup always reports `"ok"` with HTTP 200, + and the `"degraded"` / 503 path stays in the response shape so a + later release can wire real degradation conditions (reconciler + stuck, migrations pending, provider universe empty) without + breaking flotilla's parser. Probing SQLite at request time is + out of scope — per §4.2 the DB is colocated, so a process that + can respond can reach it; the check would add code for no + signal. The §20.4 CHANGELOG treatment is the patch shape — no + operator action required — with an `### Added` section naming + the endpoint so operators discover it and one MAY-language step + ("operators MAY configure their monitoring to probe `/api/health`; + the endpoint is unauthenticated by design"). §17 now lists the + endpoint in its illustrative table.* - **Deployment-supplied subject framing.** The framework was built with one deployment in mind (OHM, standardizing natural-language vocabulary), but the substrate generalizes to any domain that diff --git a/VERSION b/VERSION index ee1372d..7179039 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.2 +0.2.3 diff --git a/backend/app/api.py b/backend/app/api.py index 6bb607c..e0ab320 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -28,6 +28,7 @@ from . import ( entry as entry_mod, cache, funder, + health, philosophy, providers as providers_mod, ) @@ -81,6 +82,16 @@ def make_router( # (role, write-mute, audit-log, graduation-readiness queue). router.include_router(api_admin.make_router(config)) + # --------------------------------------------------------------- + # §17: /api/health — unauthenticated post-flight probe. + # Used by ops tooling (flotilla) to verify a deploy landed via + # version-match. v1 always reports ok; see backend/app/health.py. + # --------------------------------------------------------------- + + @router.get("/api/health") + async def get_health() -> dict[str, Any]: + return {"version": health.VERSION, "status": "ok"} + # --------------------------------------------------------------- # §14.2: /api/philosophy — PHILOSOPHY.md served verbatim. # No auth gate; anonymous visitors reach `/philosophy` per §14.1. diff --git a/backend/app/health.py b/backend/app/health.py new file mode 100644 index 0000000..817c1df --- /dev/null +++ b/backend/app/health.py @@ -0,0 +1,37 @@ +"""§17 health-check endpoint source. + +A small unauthenticated probe used by ops tooling (e.g. the flotilla +deploy control panel) to verify a deploy landed correctly. The +structural value is the version-match check — after `systemctl +restart` reports active, the operator polls `/api/health` and +verifies the returned `version` equals the tag just deployed, +catching the failure mode where a restart did not pick up the new +code. + +The version is read from the `VERSION` file at the repo root at +import time (§20.1's canonical source) and cached as a module-level +constant. A missing `VERSION` fails loudly per §20.6 rather than +serving a placeholder — a silent default would defeat the +version-match check that is the entire point of the endpoint. + +`status` is always `"ok"` (HTTP 200) in v1. The `"degraded"` / 503 +path stays in the response shape so a later release can wire real +degradation conditions (reconciler stuck, migrations pending, +provider universe empty) without breaking the contract. +""" +from __future__ import annotations + +from pathlib import Path + + +_VERSION_PATH = Path(__file__).resolve().parents[2] / "VERSION" + + +def _read_version() -> str: + text = _VERSION_PATH.read_text(encoding="utf-8").strip() + if not text: + raise RuntimeError(f"VERSION file at {_VERSION_PATH} is empty") + return text + + +VERSION: str = _read_version() diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..4e1b4a4 --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,68 @@ +"""Integration tests for the §17 `/api/health` endpoint. + +The endpoint is the structural framework-side dependency for the +flotilla deploy control panel — see SPEC.md §17 and the §19.2 +candidate-topic settlement. The tests cover the contract: HTTP 200, +JSON `{version, status}`, `version` matches `VERSION` at the repo +root, `status` is `"ok"` in v1, and no auth gate is in front of it. +""" +from __future__ import annotations + +from pathlib import Path + +from test_propose_vertical import ( # noqa: F401 + app_with_fake_gitea, + tmp_env, +) + + +_VERSION_PATH = Path(__file__).resolve().parents[2] / "VERSION" + + +def test_health_returns_version_and_ok_status(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + expected_version = _VERSION_PATH.read_text(encoding="utf-8").strip() + with TestClient(app) as client: + r = client.get("/api/health") + assert r.status_code == 200, r.text + payload = r.json() + assert payload == {"version": expected_version, "status": "ok"} + + +def test_health_is_unauthenticated(app_with_fake_gitea): + """flotilla polls /api/health without credentials; the endpoint + must not require a session cookie. The §17 entry names it + unauthenticated by design — no PII in the payload, version-match + is the whole structural value, and gating it would force ops + tooling to hold a long-lived token for a check that needs none. + """ + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + # No sign-in step. A fresh client has no session cookie. + r = client.get("/api/health") + assert r.status_code == 200, r.text + assert "version" in r.json() + + +def test_health_version_matches_VERSION_file(app_with_fake_gitea): + """The §20.1 invariant: VERSION at the repo root is the canonical + source. The endpoint reports the same string verbatim, so flotilla's + post-flight version-match check can compare against the tag + operators just deployed without a transform step. + """ + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + raw = _VERSION_PATH.read_text(encoding="utf-8") + expected = raw.strip() + # The file itself carries no leading "v" per §20.1, and the endpoint + # mirrors that — a flotilla check comparing against `v0.2.3` would + # need to strip the prefix on its side, not ours. + assert not expected.startswith("v"), "VERSION must not carry a leading v per §20.1" + with TestClient(app) as client: + r = client.get("/api/health") + assert r.json()["version"] == expected diff --git a/frontend/package.json b/frontend/package.json index 6358a52..f46db0d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.2.2", + "version": "0.2.3", "type": "module", "scripts": { "dev": "vite",