diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 6dffa5e2b..6b817ab14 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -89,11 +89,18 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3.12" + - name: Setup uv + uses: astral-sh/setup-uv@v6 + with: + version: "0.12.1" + - name: Install changelog tool + run: pip install towncrier + - name: Bump project version + run: python .github/bump_version.py + - name: Refresh uv lock + run: uv lock - name: Build changelog - run: | - pip install towncrier - python .github/bump_version.py - towncrier build --yes --version "$(python -c "import re; print(re.search(r'version = \"(.+?)\"', open('pyproject.toml').read()).group(1))")" + run: towncrier build --yes --version "$(python -c "import re; print(re.search(r'version = \"(.+?)\"', open('pyproject.toml').read()).group(1))")" - name: Preview changelog update run: ".github/get-changelog-diff.sh" - name: Update changelog diff --git a/Makefile b/Makefile index d95670b8c..1e9c1a80c 100644 --- a/Makefile +++ b/Makefile @@ -33,4 +33,5 @@ format: changelog: python .github/bump_version.py + uv lock towncrier build --yes --version $$(python -c "import re; print(re.search(r'version = \"(.+?)\"', open('pyproject.toml').read()).group(1))") diff --git a/changelog.d/3858.fixed.md b/changelog.d/3858.fixed.md new file mode 100644 index 000000000..7eb9be8f3 --- /dev/null +++ b/changelog.d/3858.fixed.md @@ -0,0 +1 @@ +Install the Cloud Run API environment from the checked-in uv lock so image rebuilds retain the reviewed dependency versions. diff --git a/gcp/README.md b/gcp/README.md index 72366554c..14f15c29f 100644 --- a/gcp/README.md +++ b/gcp/README.md @@ -5,6 +5,10 @@ image to the staging and production Cloud Run services. The image runs Gunicorn through `gcp/cloud_run/start.sh`; it does not install or launch Redis and has no localhost cache fallback. +The image installs its Python environment with +`uv sync --frozen --no-dev --no-editable` from the checked-in `uv.lock`. +Dependency versions can therefore change only when that lock file changes. + Cloud Run injects the database password, external-service credentials, Memorystore URL, and Memorystore CA through revision-specific Secret Manager bindings. Required cache settings are `RUNTIME_CACHE_MODE`, `RUNTIME_CACHE_URL`, diff --git a/gcp/cloud_run/Dockerfile b/gcp/cloud_run/Dockerfile index 1f2a09ace..06780a69c 100644 --- a/gcp/cloud_run/Dockerfile +++ b/gcp/cloud_run/Dockerfile @@ -1,18 +1,24 @@ FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.12.1 /uv /uvx /bin/ + RUN apt-get update \ && apt-get install -y --no-install-recommends build-essential \ && rm -rf /var/lib/apt/lists/* +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_PROJECT_ENVIRONMENT=/opt/venv \ + PATH="/opt/venv/bin:${PATH}" + WORKDIR /app -COPY pyproject.toml README.md ./ +COPY pyproject.toml uv.lock README.md ./ COPY policyengine_api ./policyengine_api COPY gcp/cloud_run/start.sh ./start.sh RUN chmod +x ./start.sh \ - && pip install --no-cache-dir --upgrade pip \ - && pip install --no-cache-dir -e . + && uv sync --frozen --no-dev --no-editable ENV GATEWAY_AUTH_REQUIRED=1 diff --git a/gcp/cloud_run/Dockerfile.dockerignore b/gcp/cloud_run/Dockerfile.dockerignore index b14b5958e..fa7e83d3b 100644 --- a/gcp/cloud_run/Dockerfile.dockerignore +++ b/gcp/cloud_run/Dockerfile.dockerignore @@ -4,6 +4,7 @@ !README.md !pyproject.toml +!uv.lock !policyengine_api/ !policyengine_api/** !gcp/ diff --git a/pyproject.toml b/pyproject.toml index 15d452a6b..fd69fa382 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,8 +44,8 @@ dependencies = [ "policyengine-ng==0.5.1", "policyengine-il==0.1.0", "policyengine[models]==5.2.0", - # Cloud Run installs with pip, which does not read uv.lock. Keep the - # calculator compatible with the country model in this bundle. + # Keep the calculator compatible with the country model in this bundle. + # The Cloud Run image installs this exact selection from uv.lock. "spm-calculator==0.3.1", "pydantic", "pymysql", diff --git a/tests/unit/test_alembic_workflows.py b/tests/unit/test_alembic_workflows.py index f57516988..ffc82dc57 100644 --- a/tests/unit/test_alembic_workflows.py +++ b/tests/unit/test_alembic_workflows.py @@ -1,12 +1,11 @@ from __future__ import annotations import os -from pathlib import Path import subprocess +from pathlib import Path import pytest - REPO = Path(__file__).resolve().parents[2] @@ -105,6 +104,25 @@ def test_push_requires_schema_and_v2_integration_checks_before_versioning(): assert "github.repository == 'PolicyEngine/policyengine-uk'" not in workflow +def test_versioning_refreshes_the_lock_after_bumping_the_project_version(): + workflow = _workflow("push.yml") + versioning_job = workflow[workflow.index(" versioning:") :] + versioning_job = versioning_job[: versioning_job.index("\n publish-git-tag:")] + makefile = (REPO / "Makefile").read_text(encoding="utf-8") + changelog_target = makefile[makefile.index("changelog:") :] + + assert "uses: astral-sh/setup-uv@v6" in versioning_job + assert 'version: "0.12.1"' in versioning_job + assert versioning_job.index("python .github/bump_version.py") < ( + versioning_job.index("uv lock") + ) + assert versioning_job.index("uv lock") < versioning_job.index("towncrier build") + assert changelog_target.index("python .github/bump_version.py") < ( + changelog_target.index("uv lock") + ) + assert changelog_target.index("uv lock") < changelog_target.index("towncrier build") + + def test_release_migration_uses_the_installed_python_environment(): workflow = _workflow("migrate-v1-cloud-sql.yml") orchestration_script = ( diff --git a/tests/unit/test_cloud_run_deploy_scripts.py b/tests/unit/test_cloud_run_deploy_scripts.py index 24cbe8c89..653e4ea98 100644 --- a/tests/unit/test_cloud_run_deploy_scripts.py +++ b/tests/unit/test_cloud_run_deploy_scripts.py @@ -526,8 +526,23 @@ def test_cloud_run_dockerfile_runs_startup_with_bash(): assert 'CMD ["/bin/sh", "/app/start.sh"]' not in dockerfile +def test_cloud_run_dockerfile_installs_the_frozen_uv_environment(): + dockerfile = (REPO / "gcp/cloud_run/Dockerfile").read_text(encoding="utf-8") + dockerignore = (REPO / "gcp/cloud_run/Dockerfile.dockerignore").read_text( + encoding="utf-8" + ) + + assert "COPY --from=ghcr.io/astral-sh/uv:" in dockerfile + assert "UV_PROJECT_ENVIRONMENT=/opt/venv" in dockerfile + assert 'PATH="/opt/venv/bin:${PATH}"' in dockerfile + assert "COPY pyproject.toml uv.lock README.md ./" in dockerfile + assert "uv sync --frozen --no-dev --no-editable" in dockerfile + assert "pip install" not in dockerfile + assert "!uv.lock" in dockerignore + + def test_active_images_pin_spm_calculator_for_the_legacy_country_bundle(): - """Both pip build paths must protect the current bundle's SPM behavior.""" + """Both deployed images must protect the current bundle's SPM behavior.""" import tomllib project = tomllib.loads((REPO / "pyproject.toml").read_text()) diff --git a/uv.lock b/uv.lock index 9d035a443..2a3ae4985 100644 --- a/uv.lock +++ b/uv.lock @@ -2769,7 +2769,7 @@ models = [ [[package]] name = "policyengine-api" -version = "4.2.2" +version = "4.2.4" source = { editable = "." } dependencies = [ { name = "a2wsgi" },