From a300b2426f889fb069c5c1744e1cfbe85d957e30 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 27 Oct 2026 15:53:04 -0400 Subject: [PATCH 1/3] Report when the deployed policyengine.py version went live The candidate deploy now reads the .py version from uv.lock and the version and date recorded on the revision serving 100% of traffic. A deploy that keeps the version keeps the date; a new version, a revision without a date or a failed lookup gets the deploy time. Both values go to Cloud Run as DEPLOYED_POLICYENGINE_VERSION and POLICYENGINE_VERSION_LIVE_SINCE, and GET /deployment reports them with the Cloud Run revision. Fixes #4083 Co-Authored-By: Claude Opus 5.5 --- .github/scripts/deploy_cloud_run_candidate.sh | 7 + .../resolve_policyengine_live_since.sh | 60 ++++++ .github/workflows/push.yml | 2 + changelog.d/added/4083.md | 1 + policyengine_api/fastapi_routes/health.py | 25 +++ tests/unit/test_policyengine_live_since.py | 186 ++++++++++++++++++ 6 files changed, 281 insertions(+) create mode 100644 .github/scripts/resolve_policyengine_live_since.sh create mode 100644 changelog.d/added/4083.md create mode 100644 tests/unit/test_policyengine_live_since.py diff --git a/.github/scripts/deploy_cloud_run_candidate.sh b/.github/scripts/deploy_cloud_run_candidate.sh index fee77af4f..ad9bb4a11 100755 --- a/.github/scripts/deploy_cloud_run_candidate.sh +++ b/.github/scripts/deploy_cloud_run_candidate.sh @@ -7,6 +7,10 @@ cloud_run_set_defaults bash .github/scripts/validate_cloud_run_deploy_env.sh +# DEPLOYED_POLICYENGINE_VERSION and POLICYENGINE_VERSION_LIVE_SINCE, reported +# by GET /deployment. +live_since_vars="$(bash .github/scripts/resolve_policyengine_live_since.sh)" + env_vars=( "APP_ENVIRONMENT=${DEPLOYMENT_ENVIRONMENT}" "POLICYENGINE_DB_INSTANCE_CONNECTION_NAME=${POLICYENGINE_DB_INSTANCE_CONNECTION_NAME}" @@ -48,6 +52,9 @@ env_vars=( "PUBLIC_API_AUTH_ISSUER=${PUBLIC_API_AUTH_ISSUER:-}" "PUBLIC_API_AUTH_AUDIENCE=${PUBLIC_API_AUTH_AUDIENCE:-}" ) +while IFS= read -r live_since_var; do + env_vars+=("${live_since_var}") +done <<<"${live_since_vars}" # Optional: account deletion removes the Auth0 user only when the Management # API client is configured. Validation requires all three values or none. diff --git a/.github/scripts/resolve_policyengine_live_since.sh b/.github/scripts/resolve_policyengine_live_since.sh new file mode 100644 index 000000000..6e3ea88b7 --- /dev/null +++ b/.github/scripts/resolve_policyengine_live_since.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash + +# Decide when the policyengine.py (.py) version this deploy installs went live. +# A deploy that keeps the serving revision's .py version keeps that revision's +# date; a new .py version goes live with this deploy. The API redeploys several +# times a day without changing .py, so a plain deploy time would not say how +# long the version has been served. +# +# Inputs: PREVIOUS_CLOUD_RUN_REVISION (the revision serving 100% of traffic, +# from capture_cloud_run_service_state.sh; empty on a first deploy) and the +# usual CLOUD_RUN_* settings. POLICYENGINE_LOCK_FILE and +# POLICYENGINE_LIVE_SINCE_NOW override the lock path and the clock for tests. +# +# Prints DEPLOYED_POLICYENGINE_VERSION=... and POLICYENGINE_VERSION_LIVE_SINCE=... +# A failed lookup falls back to the deploy time; it never fails the deploy. + +set -euo pipefail + +source .github/scripts/cloud_run_env.sh +cloud_run_set_defaults + +lock_file="${POLICYENGINE_LOCK_FILE:-uv.lock}" +version="$(awk ' + /^name = "policyengine"$/ { found = 1; next } + found && /^version = / { gsub(/"/, "", $3); print $3; exit } + /^\[\[package\]\]$/ { found = 0 } +' "${lock_file}")" +if [[ -z "${version}" ]]; then + echo "policyengine is not locked in ${lock_file}" >&2 + exit 1 +fi + +now="${POLICYENGINE_LIVE_SINCE_NOW:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" +live_since="${now}" +previous="${PREVIOUS_CLOUD_RUN_REVISION:-}" + +if [[ -n "${previous}" && "${CLOUD_RUN_DRY_RUN:-0}" != "1" ]]; then + gcloud_bin="${GCLOUD_BIN:-gcloud}" + if revision_json="$("${gcloud_bin}" run revisions describe "${previous}" \ + --project "${CLOUD_RUN_PROJECT}" \ + --region "${CLOUD_RUN_REGION}" \ + --platform managed \ + --format=json)"; then + revision_env() { + jq -r --arg name "$1" ' + [.spec.containers[0].env[]? | select(.name == $name) | .value][0] // "" + ' <<<"${revision_json}" 2>/dev/null || true + } + previous_version="$(revision_env DEPLOYED_POLICYENGINE_VERSION)" + previous_since="$(revision_env POLICYENGINE_VERSION_LIVE_SINCE)" + if [[ "${previous_version}" == "${version}" && -n "${previous_since}" ]]; then + live_since="${previous_since}" + fi + else + echo "Could not read ${previous}; .py ${version} is live since this deploy" >&2 + fi +fi + +printf 'DEPLOYED_POLICYENGINE_VERSION=%s\nPOLICYENGINE_VERSION_LIVE_SINCE=%s\n' \ + "${version}" "${live_since}" diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 255c11e3d..7c525a61f 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -308,6 +308,7 @@ jobs: - name: Deploy tagged Cloud Run staging candidate run: bash .github/scripts/deploy_cloud_run_candidate.sh env: + PREVIOUS_CLOUD_RUN_REVISION: ${{ steps.previous.outputs.revision }} CLOUD_RUN_IMAGE_TAG: ${{ steps.cloud_run.outputs.image_tag }} CLOUD_RUN_TAG: ${{ steps.cloud_run.outputs.revision_tag }} CLOUD_RUN_RUNTIME_SERVICE_ACCOUNT: policyengine-api-cr-staging@policyengine-api.iam.gserviceaccount.com @@ -577,6 +578,7 @@ jobs: - name: Deploy tagged Cloud Run candidate run: bash .github/scripts/deploy_cloud_run_candidate.sh env: + PREVIOUS_CLOUD_RUN_REVISION: ${{ steps.previous.outputs.revision }} CLOUD_RUN_IMAGE_TAG: ${{ steps.cloud_run.outputs.image_tag }} CLOUD_RUN_TAG: ${{ steps.cloud_run.outputs.revision_tag }} CLOUD_RUN_RUNTIME_SERVICE_ACCOUNT: ${{ secrets.GCP_CLOUD_RUN_RUNTIME_SERVICE_ACCOUNT }} diff --git a/changelog.d/added/4083.md b/changelog.d/added/4083.md new file mode 100644 index 000000000..18289a059 --- /dev/null +++ b/changelog.d/added/4083.md @@ -0,0 +1 @@ +`GET /deployment` reports the policyengine.py version the API serves and when it went live; the candidate deploy keeps that date across redeploys of the same version. diff --git a/policyengine_api/fastapi_routes/health.py b/policyengine_api/fastapi_routes/health.py index 6e0da7ec1..be2590d86 100644 --- a/policyengine_api/fastapi_routes/health.py +++ b/policyengine_api/fastapi_routes/health.py @@ -2,6 +2,8 @@ from __future__ import annotations +import os +from importlib import metadata as importlib_metadata from typing import TYPE_CHECKING, Literal from fastapi import APIRouter, HTTPException @@ -25,6 +27,19 @@ class SimulationGatewayHealthResponse(BaseModel): simulation_gateway: Literal["healthy"] +class DeploymentResponse(BaseModel): + """The policyengine.py version this revision serves, and since when. + + ``policyengine_version_live_since`` is when this .py version first went + live: redeploys that keep the version keep the date. It is null on a + revision deployed before the deploy recorded it, or outside Cloud Run. + """ + + policyengine_version: str + policyengine_version_live_since: str | None + revision: str | None + + def build_core_health_router( dependencies: NativeRouteDependencies, ) -> APIRouter: @@ -35,6 +50,16 @@ def build_core_health_router( def health() -> HealthResponse: return HealthResponse(status="healthy") + @router.get("/deployment", response_model=DeploymentResponse) + def deployment() -> DeploymentResponse: + return DeploymentResponse( + policyengine_version=importlib_metadata.version("policyengine"), + policyengine_version_live_since=( + os.environ.get("POLICYENGINE_VERSION_LIVE_SINCE") or None + ), + revision=os.environ.get("K_REVISION") or None, + ) + @router.get( "/simulation-gateway-check", response_model=SimulationGatewayHealthResponse, diff --git a/tests/unit/test_policyengine_live_since.py b/tests/unit/test_policyengine_live_since.py new file mode 100644 index 000000000..7e46f9b91 --- /dev/null +++ b/tests/unit/test_policyengine_live_since.py @@ -0,0 +1,186 @@ +"""When the deployed policyengine.py version went live (deploy script and route).""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest +from fastapi.testclient import TestClient + +from policyengine_api.asgi_factory import create_asgi_app +from tests.unit.cloud_run_deploy_support import ( + PRODUCTION_CLOUD_RUN_SERVICE, + required_runtime_env, + run_script, + script_env, +) +from tests.unit.test_asgi_factory import create_test_wsgi_app + +if TYPE_CHECKING: + from pathlib import Path + +SCRIPT = ".github/scripts/resolve_policyengine_live_since.sh" +NOW = "2026-10-28T09:00:00Z" +EARLIER = "2026-10-20T12:00:00Z" + + +def _lock(tmp_path: Path, version: str) -> Path: + lock = tmp_path / "uv.lock" + lock.write_text( + "version = 1\n\n" + '[[package]]\nname = "policyengine"\n' + f'version = "{version}"\n\n' + '[[package]]\nname = "policyengine-us"\nversion = "2.2.1"\n', + encoding="utf-8", + ) + return lock + + +def _fake_gcloud(tmp_path: Path, revision_env: dict[str, str] | None) -> Path: + """Write a gcloud that describes one revision with revision_env, or fails.""" + calls = tmp_path / "gcloud-calls.json" + gcloud = tmp_path / "gcloud" + body = ( + "sys.exit('describe failed')" + if revision_env is None + else "print(json.dumps({'spec': {'containers': [{'env': ENV}]}}))" + ) + env_items = [ + {"name": name, "value": value} for name, value in (revision_env or {}).items() + ] + gcloud.write_text( + "#!/usr/bin/env python3\n" + "import json, pathlib, sys\n" + f"ENV = {env_items!r}\n" + f"pathlib.Path({str(calls)!r}).write_text(json.dumps(sys.argv[1:]))\n" + f"{body}\n", + encoding="utf-8", + ) + gcloud.chmod(0o755) + return gcloud + + +def _resolve( + tmp_path: Path, + *, + version: str = "6.11.1", + previous: str = "policyengine-api-00001-old", + revision_env: dict[str, str] | None = None, + dry_run: bool = False, +): + gcloud = _fake_gcloud(tmp_path, revision_env) + return run_script( + SCRIPT, + script_env( + CLOUD_RUN_DRY_RUN="1" if dry_run else "0", + CLOUD_RUN_SERVICE=PRODUCTION_CLOUD_RUN_SERVICE, + GCLOUD_BIN=str(gcloud), + POLICYENGINE_LOCK_FILE=str(_lock(tmp_path, version)), + POLICYENGINE_LIVE_SINCE_NOW=NOW, + PREVIOUS_CLOUD_RUN_REVISION=previous, + ), + ) + + +def _outputs(result) -> dict[str, str]: + assert result.returncode == 0, result.stderr + return dict(line.split("=", 1) for line in result.stdout.splitlines()) + + +def test_same_version_keeps_the_serving_revisions_date(tmp_path): + result = _resolve( + tmp_path, + revision_env={ + "DEPLOYED_POLICYENGINE_VERSION": "6.11.1", + "POLICYENGINE_VERSION_LIVE_SINCE": EARLIER, + }, + ) + + assert _outputs(result) == { + "DEPLOYED_POLICYENGINE_VERSION": "6.11.1", + "POLICYENGINE_VERSION_LIVE_SINCE": EARLIER, + } + args = json.loads((tmp_path / "gcloud-calls.json").read_text()) + assert args[:4] == ["run", "revisions", "describe", "policyengine-api-00001-old"] + + +def test_new_version_goes_live_with_this_deploy(tmp_path): + result = _resolve( + tmp_path, + revision_env={ + "DEPLOYED_POLICYENGINE_VERSION": "6.10.0", + "POLICYENGINE_VERSION_LIVE_SINCE": EARLIER, + }, + ) + + assert _outputs(result)["POLICYENGINE_VERSION_LIVE_SINCE"] == NOW + + +@pytest.mark.parametrize( + "case", + ["revision without a recorded date", "first deploy", "describe fails"], +) +def test_unknown_history_goes_live_with_this_deploy(tmp_path, case): + kwargs = { + "revision without a recorded date": {"revision_env": {}}, + "first deploy": {"previous": "", "revision_env": {}}, + "describe fails": {"revision_env": None}, + }[case] + + result = _resolve(tmp_path, **kwargs) + + assert _outputs(result) == { + "DEPLOYED_POLICYENGINE_VERSION": "6.11.1", + "POLICYENGINE_VERSION_LIVE_SINCE": NOW, + } + + +def test_a_lock_without_policyengine_fails(tmp_path): + lock = tmp_path / "uv.lock" + lock.write_text('[[package]]\nname = "numpy"\nversion = "2.1.0"\n') + + result = run_script( + SCRIPT, + script_env(POLICYENGINE_LOCK_FILE=str(lock)), + ) + + assert result.returncode != 0 + assert "policyengine is not locked" in result.stderr + + +def test_the_candidate_deploy_sets_both_variables(): + result = run_script( + ".github/scripts/deploy_cloud_run_candidate.sh", + script_env( + **required_runtime_env(), + CLOUD_RUN_IMAGE_URI="us-central1-docker.pkg.dev/project/repo/api:sha", + CLOUD_RUN_TAG="stage3-test", + POLICYENGINE_LIVE_SINCE_NOW=NOW, + ), + ) + + assert result.returncode == 0, result.stderr + assert "DEPLOYED_POLICYENGINE_VERSION=" in result.stdout + assert f"POLICYENGINE_VERSION_LIVE_SINCE={NOW}" in result.stdout + + +def test_the_deployment_route_reports_version_date_and_revision(monkeypatch): + monkeypatch.setenv("POLICYENGINE_VERSION_LIVE_SINCE", EARLIER) + monkeypatch.setenv("K_REVISION", "policyengine-api-00042-abc") + client = TestClient(create_asgi_app(create_test_wsgi_app())) + + body = client.get("/deployment").json() + + assert body["policyengine_version_live_since"] == EARLIER + assert body["revision"] == "policyengine-api-00042-abc" + assert body["policyengine_version"] + + +def test_the_deployment_route_has_no_date_before_the_deploy_records_one( + monkeypatch, +): + monkeypatch.delenv("POLICYENGINE_VERSION_LIVE_SINCE", raising=False) + client = TestClient(create_asgi_app(create_test_wsgi_app())) + + assert client.get("/deployment").json()["policyengine_version_live_since"] is None From 95f6ccd5071f7114de41993c46e9fb7929ff1b9e Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 27 Oct 2026 15:58:47 -0400 Subject: [PATCH 2/3] Leave the live-since date unknown rather than wrong A serving revision with the same .py version but no recorded date was deployed before dates were recorded, so its version may have been live for weeks; a failed lookup tells nothing either. Both now leave the date empty (GET /deployment reports null) instead of the deploy time. Only a new .py version or a first deploy records the deploy time. Co-Authored-By: Claude Opus 5.5 --- .../resolve_policyengine_live_since.sh | 15 ++++++++-- tests/unit/test_policyengine_live_since.py | 29 ++++++++++++------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/.github/scripts/resolve_policyengine_live_since.sh b/.github/scripts/resolve_policyengine_live_since.sh index 6e3ea88b7..a7ed95867 100644 --- a/.github/scripts/resolve_policyengine_live_since.sh +++ b/.github/scripts/resolve_policyengine_live_since.sh @@ -12,7 +12,11 @@ # POLICYENGINE_LIVE_SINCE_NOW override the lock path and the clock for tests. # # Prints DEPLOYED_POLICYENGINE_VERSION=... and POLICYENGINE_VERSION_LIVE_SINCE=... -# A failed lookup falls back to the deploy time; it never fails the deploy. +# The date is recorded only when this deploy is the event (a new .py version +# or a first deploy). When the serving revision has the same version but no +# date (deployed before dates were recorded), or cannot be read, the date is +# left empty (unknown) rather than set to a later, wrong time. A failed lookup +# never fails the deploy. set -euo pipefail @@ -48,11 +52,16 @@ if [[ -n "${previous}" && "${CLOUD_RUN_DRY_RUN:-0}" != "1" ]]; then } previous_version="$(revision_env DEPLOYED_POLICYENGINE_VERSION)" previous_since="$(revision_env POLICYENGINE_VERSION_LIVE_SINCE)" - if [[ "${previous_version}" == "${version}" && -n "${previous_since}" ]]; then + if [[ "${previous_version}" == "${version}" ]]; then live_since="${previous_since}" + elif [[ -z "${previous_version}" ]]; then + # A revision from before versions were recorded may already serve this + # version, so its start is unknown. + live_since="" fi else - echo "Could not read ${previous}; .py ${version} is live since this deploy" >&2 + echo "Could not read ${previous}; when .py ${version} went live is unknown" >&2 + live_since="" fi fi diff --git a/tests/unit/test_policyengine_live_since.py b/tests/unit/test_policyengine_live_since.py index 7e46f9b91..e4fe6e626 100644 --- a/tests/unit/test_policyengine_live_since.py +++ b/tests/unit/test_policyengine_live_since.py @@ -117,22 +117,29 @@ def test_new_version_goes_live_with_this_deploy(tmp_path): assert _outputs(result)["POLICYENGINE_VERSION_LIVE_SINCE"] == NOW +def test_a_first_deploy_goes_live_with_this_deploy(tmp_path): + result = _resolve(tmp_path, previous="", revision_env={}) + + assert _outputs(result)["POLICYENGINE_VERSION_LIVE_SINCE"] == NOW + + @pytest.mark.parametrize( - "case", - ["revision without a recorded date", "first deploy", "describe fails"], + "revision_env", + [ + pytest.param({}, id="revision from before versions were recorded"), + pytest.param( + {"DEPLOYED_POLICYENGINE_VERSION": "6.11.1"}, + id="same version without a recorded date", + ), + pytest.param(None, id="describe fails"), + ], ) -def test_unknown_history_goes_live_with_this_deploy(tmp_path, case): - kwargs = { - "revision without a recorded date": {"revision_env": {}}, - "first deploy": {"previous": "", "revision_env": {}}, - "describe fails": {"revision_env": None}, - }[case] - - result = _resolve(tmp_path, **kwargs) +def test_an_unknown_start_stays_unknown_rather_than_wrong(tmp_path, revision_env): + result = _resolve(tmp_path, revision_env=revision_env) assert _outputs(result) == { "DEPLOYED_POLICYENGINE_VERSION": "6.11.1", - "POLICYENGINE_VERSION_LIVE_SINCE": NOW, + "POLICYENGINE_VERSION_LIVE_SINCE": "", } From 7cd3e9e52628ff868911356f04de4e24f7a40a87 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 27 Oct 2026 18:09:06 -0400 Subject: [PATCH 3/3] Address review: one uv.lock reader, health group, version check, tests - resolve_policyengine_live_since.sh reads the .py version with the new `locked_packages.py bundle-version` command (the relock's tomllib reader), which fails unless policyengine is locked at exactly one version. - /deployment is in the health route group (migration registry and ROUTE_GROUP_BY_PATH), so its request logs no longer show `unknown`; migration contract docs regenerated. - /deployment reports the date only when DEPLOYED_POLICYENGINE_VERSION matches the installed version, so a date is never paired with another version. - Tests: both candidate deploy steps receive PREVIOUS_CLOUD_RUN_REVISION; bundle-version reads one version and refuses two; the route drops a date for another version and logs under health. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/locked_packages.py | 23 ++++++ .../resolve_policyengine_live_since.sh | 8 +- docs/engineering/migration-contracts.md | 2 +- docs/generated/migration_contracts.json | 3 +- policyengine_api/fastapi_routes/health.py | 12 ++- policyengine_api/migration_flags.py | 1 + policyengine_api/migration_registry.py | 1 + tests/unit/test_policyengine_live_since.py | 76 +++++++++++++++++++ 8 files changed, 116 insertions(+), 10 deletions(-) diff --git a/.github/scripts/locked_packages.py b/.github/scripts/locked_packages.py index 2391e2b24..55b22f11d 100644 --- a/.github/scripts/locked_packages.py +++ b/.github/scripts/locked_packages.py @@ -11,6 +11,7 @@ locked_packages.py upgradeable LOCK names it may upgrade, one per line locked_packages.py held LOCK "name version" of each package it holds locked_packages.py summary OLD NEW Markdown summary of the changes + locked_packages.py bundle-version LOCK the locked .py bundle version """ from __future__ import annotations @@ -78,6 +79,25 @@ def held_versions(lock_path: Path) -> list[str]: ] +def bundle_version(lock_path: Path) -> str: + """Return the one version of the .py bundle package that ``lock_path`` locks. + + Raises: + SystemExit: when the bundle is locked at more than one version, since + the deploy could not say which one it installs. + + """ + versions, _ = _locked_versions(lock_path) + found = versions[BUNDLE_PACKAGE] + if len(found) != 1: + message = ( + f"{BUNDLE_PACKAGE} is locked at {', '.join(found)} in {lock_path}; " + "expected exactly one version." + ) + raise SystemExit(message) + return found[0] + + def summary(old_lock: Path, new_lock: Path) -> str: """Return a Markdown summary of the version changes from old_lock to new_lock.""" old_versions, _ = _locked_versions(old_lock) @@ -128,6 +148,7 @@ def main() -> None: commands = parser.add_subparsers(dest="command", required=True) commands.add_parser("upgradeable").add_argument("lock", type=Path) commands.add_parser("held").add_argument("lock", type=Path) + commands.add_parser("bundle-version").add_argument("lock", type=Path) summary_parser = commands.add_parser("summary") summary_parser.add_argument("old_lock", type=Path) summary_parser.add_argument("new_lock", type=Path) @@ -137,6 +158,8 @@ def main() -> None: print("\n".join(upgradeable(arguments.lock))) elif arguments.command == "held": print("\n".join(held_versions(arguments.lock))) + elif arguments.command == "bundle-version": + print(bundle_version(arguments.lock)) else: sys.stdout.write(summary(arguments.old_lock, arguments.new_lock)) diff --git a/.github/scripts/resolve_policyengine_live_since.sh b/.github/scripts/resolve_policyengine_live_since.sh index a7ed95867..72204d2f3 100644 --- a/.github/scripts/resolve_policyengine_live_since.sh +++ b/.github/scripts/resolve_policyengine_live_since.sh @@ -24,11 +24,9 @@ source .github/scripts/cloud_run_env.sh cloud_run_set_defaults lock_file="${POLICYENGINE_LOCK_FILE:-uv.lock}" -version="$(awk ' - /^name = "policyengine"$/ { found = 1; next } - found && /^version = / { gsub(/"/, "", $3); print $3; exit } - /^\[\[package\]\]$/ { found = 0 } -' "${lock_file}")" +# The same uv.lock reader the daily relock uses; it fails unless policyengine +# is locked at exactly one version. +version="$(python3 .github/scripts/locked_packages.py bundle-version "${lock_file}")" if [[ -z "${version}" ]]; then echo "policyengine is not locked in ${lock_file}" >&2 exit 1 diff --git a/docs/engineering/migration-contracts.md b/docs/engineering/migration-contracts.md index f39a5e369..75bf588f5 100644 --- a/docs/engineering/migration-contracts.md +++ b/docs/engineering/migration-contracts.md @@ -16,7 +16,7 @@ Generated from `policyengine_api/migration_registry.py` and `tests/contract/regi | Route group | Path segments | DB entity | Simulation flow | | --- | --- | --- | --- | -| `health` | `health`, `simulation-gateway-check`, `liveness-check`, `readiness-check` | `none` | `none` | +| `health` | `health`, `simulation-gateway-check`, `liveness-check`, `readiness-check`, `deployment` | `none` | `none` | | `specification` | `specification` | `none` | `none` | | `metadata` | `metadata`, `datasets`, `economy-options`, `parameter-values`, `parameters`, `regions`, `tax-benefit-model-versions`, `tax-benefit-models`, `variables` | `metadata` | `none` | | `policy` | `policy`, `policies`, `user-policy`, `user-policies` | `policy` | `none` | diff --git a/docs/generated/migration_contracts.json b/docs/generated/migration_contracts.json index 7a0f1b67d..0938a6f98 100644 --- a/docs/generated/migration_contracts.json +++ b/docs/generated/migration_contracts.json @@ -14,7 +14,8 @@ "health", "simulation-gateway-check", "liveness-check", - "readiness-check" + "readiness-check", + "deployment" ], "sim_flow": null }, diff --git a/policyengine_api/fastapi_routes/health.py b/policyengine_api/fastapi_routes/health.py index be2590d86..edff74500 100644 --- a/policyengine_api/fastapi_routes/health.py +++ b/policyengine_api/fastapi_routes/health.py @@ -32,7 +32,8 @@ class DeploymentResponse(BaseModel): ``policyengine_version_live_since`` is when this .py version first went live: redeploys that keep the version keep the date. It is null on a - revision deployed before the deploy recorded it, or outside Cloud Run. + revision deployed before the deploy recorded it, outside Cloud Run, or + when the version the deploy dated is not the installed one. """ policyengine_version: str @@ -52,10 +53,15 @@ def health() -> HealthResponse: @router.get("/deployment", response_model=DeploymentResponse) def deployment() -> DeploymentResponse: + installed = importlib_metadata.version("policyengine") + # The deploy computed the date for the version it read from uv.lock; + # it describes the installed version only when the two agree. + deployed = os.environ.get("DEPLOYED_POLICYENGINE_VERSION") + live_since = os.environ.get("POLICYENGINE_VERSION_LIVE_SINCE") or None return DeploymentResponse( - policyengine_version=importlib_metadata.version("policyengine"), + policyengine_version=installed, policyengine_version_live_since=( - os.environ.get("POLICYENGINE_VERSION_LIVE_SINCE") or None + live_since if deployed == installed else None ), revision=os.environ.get("K_REVISION") or None, ) diff --git a/policyengine_api/migration_flags.py b/policyengine_api/migration_flags.py index 2b966808b..e5d9a555f 100644 --- a/policyengine_api/migration_flags.py +++ b/policyengine_api/migration_flags.py @@ -51,6 +51,7 @@ class RouteImplementation(StrEnum): "/simulation-gateway-check": "health", "/liveness-check": "health", "/readiness-check": "health", + "/deployment": "health", "/specification": "specification", "/v2/openapi.json": "specification", } diff --git a/policyengine_api/migration_registry.py b/policyengine_api/migration_registry.py index e49de6986..dd41885ab 100644 --- a/policyengine_api/migration_registry.py +++ b/policyengine_api/migration_registry.py @@ -27,6 +27,7 @@ class RouteGroupConfig: "simulation-gateway-check", "liveness-check", "readiness-check", + "deployment", ), ), RouteGroupConfig( diff --git a/tests/unit/test_policyengine_live_since.py b/tests/unit/test_policyengine_live_since.py index e4fe6e626..7b25fea3a 100644 --- a/tests/unit/test_policyengine_live_since.py +++ b/tests/unit/test_policyengine_live_since.py @@ -3,17 +3,24 @@ from __future__ import annotations import json +import subprocess +import sys +from importlib import metadata as importlib_metadata from typing import TYPE_CHECKING import pytest from fastapi.testclient import TestClient from policyengine_api.asgi_factory import create_asgi_app +from policyengine_api.migration_flags import infer_route_group from tests.unit.cloud_run_deploy_support import ( PRODUCTION_CLOUD_RUN_SERVICE, + REPO, + push_workflow, required_runtime_env, run_script, script_env, + workflow_job_block, ) from tests.unit.test_asgi_factory import create_test_wsgi_app @@ -173,6 +180,9 @@ def test_the_candidate_deploy_sets_both_variables(): def test_the_deployment_route_reports_version_date_and_revision(monkeypatch): + monkeypatch.setenv( + "DEPLOYED_POLICYENGINE_VERSION", importlib_metadata.version("policyengine") + ) monkeypatch.setenv("POLICYENGINE_VERSION_LIVE_SINCE", EARLIER) monkeypatch.setenv("K_REVISION", "policyengine-api-00042-abc") client = TestClient(create_asgi_app(create_test_wsgi_app())) @@ -191,3 +201,69 @@ def test_the_deployment_route_has_no_date_before_the_deploy_records_one( client = TestClient(create_asgi_app(create_test_wsgi_app())) assert client.get("/deployment").json()["policyengine_version_live_since"] is None + + +def test_the_deployment_route_drops_a_date_for_another_version(monkeypatch): + monkeypatch.setenv("DEPLOYED_POLICYENGINE_VERSION", "0.0.0") + monkeypatch.setenv("POLICYENGINE_VERSION_LIVE_SINCE", EARLIER) + client = TestClient(create_asgi_app(create_test_wsgi_app())) + + assert client.get("/deployment").json()["policyengine_version_live_since"] is None + + +def test_the_deployment_route_logs_under_the_health_group(): + assert infer_route_group("/deployment") == "health" + + +@pytest.mark.parametrize( + ("job", "step"), + [ + ("deploy-cloud-run-staging", "Deploy tagged Cloud Run staging candidate"), + ("deploy-cloud-run-candidate", "Deploy tagged Cloud Run candidate"), + ], +) +def test_both_candidate_deploys_receive_the_serving_revision(job, step): + """Without it every deploy would look like a first deploy and get a new date.""" + block = workflow_job_block(push_workflow(), job) + step_block = block.split(f"- name: {step}\n", 1)[1].split("\n - name:", 1)[0] + + assert ( + "PREVIOUS_CLOUD_RUN_REVISION: ${{ steps.previous.outputs.revision }}" + in step_block + ) + assert "id: previous" in block + + +def _bundle_version(lock: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(REPO / ".github/scripts/locked_packages.py"), + "bundle-version", + str(lock), + ], + text=True, + capture_output=True, + check=False, + ) + + +def test_bundle_version_reads_the_one_locked_policyengine(tmp_path): + result = _bundle_version(_lock(tmp_path, "6.11.1")) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "6.11.1" + + +def test_bundle_version_refuses_more_than_one_locked_version(tmp_path): + lock = tmp_path / "uv.lock" + lock.write_text( + '[[package]]\nname = "policyengine"\nversion = "6.10.0"\n\n' + '[[package]]\nname = "policyengine"\nversion = "6.11.1"\n', + encoding="utf-8", + ) + + result = _bundle_version(lock) + + assert result.returncode != 0 + assert "expected exactly one version" in result.stderr