Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/scripts/migrate_v1_cloud_sql.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,17 @@ export POLICYENGINE_DB_MIGRATION_PASSWORD="${migration_password}"
# Never allow a job-level URL to bypass the credentials fetched for this release.
unset STAGE7_EXISTING_DATABASE_URL ALEMBIC_DATABASE_URL

database_state="$(python scripts/v1_database_migration.py --mode state)"
# Every Python command runs in the environment uv.lock defines; the workflow
# installs it with uv sync --frozen.
database_state="$(uv run --frozen python scripts/v1_database_migration.py --mode state)"
echo "Detected v1 database state: ${database_state}"

case "${database_state}" in
head)
;;
pending)
backup_id="$(bash .github/scripts/create_cloud_sql_backup.sh)"
python scripts/v1_database_migration.py \
uv run --frozen python scripts/v1_database_migration.py \
--mode upgrade \
--backup-id "${backup_id}"
;;
Expand All @@ -54,4 +56,4 @@ case "${database_state}" in
;;
esac

python scripts/v1_database_migration.py --mode verify-head
uv run --frozen python scripts/v1_database_migration.py --mode verify-head
4 changes: 3 additions & 1 deletion .github/scripts/run_household_mirror_event_operator.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then
printf '::add-mask::%s\n' "${migration_password}"
fi

# The command runs in the environment uv.lock defines; the workflow installs
# it with uv sync --frozen.
POLICYENGINE_DB_PROXY_PORT="3307" \
POLICYENGINE_DB_PASSWORD="${migration_password}" \
python scripts/process_v1_household_mirror_event.py "$@"
uv run --frozen python scripts/process_v1_household_mirror_event.py "$@"
17 changes: 12 additions & 5 deletions .github/workflows/migrate-v1-cloud-sql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,25 +46,32 @@ jobs:
POLICYENGINE_DB_MIGRATION_PASSWORD_SECRET: ${{ vars.POLICYENGINE_DB_MIGRATION_PASSWORD_SECRET }}
PRODUCTION_POLICYENGINE_DB_READONLY_PASSWORD_SECRET: ${{ vars.PRODUCTION_POLICYENGINE_DB_READONLY_PASSWORD_SECRET }}
PRODUCTION_POLICYENGINE_DB_MIGRATION_PASSWORD_SECRET: ${{ vars.PRODUCTION_POLICYENGINE_DB_MIGRATION_PASSWORD_SECRET }}
# uv installs uv.lock on the Cloud Run image's Python, not the 3.14
# that .python-version selects.
UV_PYTHON: "3.12"
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
# The same uv version as the Cloud Run image and the release job.
- name: Set up uv
uses: astral-sh/setup-uv@v6
with:
version: "0.13.4"
- name: Authenticate to GCP
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ vars.GCP_DB_MIGRATION_SERVICE_ACCOUNT }}
- name: Set up GCloud
uses: google-github-actions/setup-gcloud@v2
# make install syncs .venv from uv.lock, which the bare python this
# job's scripts run does not read. Moving this job to uv.lock is a
# separate, verified change; until then it installs unlocked.
- name: Install dependencies (unlocked)
run: pip install -e ".[dev]"
# The runtime dependencies only, as the Cloud Run image installs them;
# the migration script runs them through uv run --frozen.
- name: Install locked dependencies
run: uv sync --frozen
- name: Start Cloud SQL Auth Proxy
run: bash .github/scripts/start_cloud_sql_proxy.sh
- name: Upgrade and verify v1 database
Expand Down
17 changes: 12 additions & 5 deletions .github/workflows/process-v1-household-mirror-event.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,25 @@ jobs:
V2_RUNTIME_DATABASE_URL: ${{ secrets.V2_DATA_WRITE_DATABASE_URL }}
HOUSEHOLD_MIRROR_OPERATOR_IDENTITY: ${{ vars.GCP_DB_MIGRATION_SERVICE_ACCOUNT }}
ALLOWED_HOUSEHOLD_MIRROR_OPERATOR_IDENTITY: ${{ vars.HOUSEHOLD_MIRROR_OPERATOR_IDENTITY }}
# uv installs uv.lock on the Cloud Run image's Python, not the 3.14
# that .python-version selects.
UV_PYTHON: "3.12"
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
# make install syncs .venv from uv.lock, which the bare python this
# job's scripts run does not read. Moving this job to uv.lock is a
# separate, verified change; until then it installs unlocked.
- name: Install dependencies (unlocked)
run: pip install -e ".[dev]"
# The same uv version as the Cloud Run image and the release job.
- name: Set up uv
uses: astral-sh/setup-uv@v6
with:
version: "0.13.4"
# The runtime dependencies only, as the Cloud Run image installs them;
# the operator script runs them through uv run --frozen.
- name: Install locked dependencies
run: uv sync --frozen
- name: Authenticate authorized database operator
uses: google-github-actions/auth@v2
with:
Expand Down
1 change: 1 addition & 0 deletions changelog.d/operational-workflows-from-lock.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The Cloud SQL migration and household mirror-event workflows install their dependencies from uv.lock and run their scripts through uv run --frozen, instead of an unlocked pip install.
2 changes: 0 additions & 2 deletions docs/generated/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,7 @@ Every rule that governs `policyengine-api` is listed here with how it is enforce

- `api.dependencies.install-from-lock`: exception for `.github/scripts/install_locked_ruff.sh` (`pip install "ruff==`) allowed as a pinned-tool (Installs the ruff version uv.lock pins, read from uv.lock, without installing the whole project for the lint job.)
- `api.dependencies.install-from-lock`: exception for `.github/scripts/verify_observability_collector.sh` (`uv run --no-project --with grpcio --with opentelemetry-proto`) until the deploy job syncs the project with uv sync --frozen and the check runs through uv run --frozen (The collector check runs in the deploy job without installing the project; grpcio and opentelemetry-proto are locked only as transitive dependencies of the project.)
- `api.dependencies.install-from-lock`: exception for `.github/workflows/migrate-v1-cloud-sql.yml` (`pip install -e ".[dev]"`) until the job installs with uv sync --frozen and runs its scripts through uv run --frozen, after a run against the staging database succeeds (The migration scripts run with the bare python setup-python provides, against the production and staging Cloud SQL databases; moving them to uv run --frozen has not been tested against those databases.)
- `api.dependencies.install-from-lock`: exception for `.github/workflows/pr.yml` (`pip install "shellcheck-py==`) allowed as a pinned-tool (shellcheck is a command-line tool the ci-scripts guard runs; it is not a dependency of the project.)
- `api.dependencies.install-from-lock`: exception for `.github/workflows/process-v1-household-mirror-event.yml` (`pip install -e ".[dev]"`) until the job installs with uv sync --frozen and runs its script through uv run --frozen, after a run against the staging database succeeds (The operator script runs with the bare python setup-python provides, against the production and staging databases; moving it to uv run --frozen has not been tested against those databases.)
- `api.dependencies.install-from-lock`: exception for `.github/workflows/push.yml` (`pip install "shellcheck-py==`) allowed as a pinned-tool (shellcheck is a command-line tool the ci-scripts guard runs; it is not a dependency of the project.)
- `api.dependencies.install-from-lock`: exception for `.github/workflows/update-policyengine-bundle.yaml` (`pip install requests uv`) until the workflow installs uv with astral-sh/setup-uv at a pinned version and runs its Python through uv run --frozen (The bundle updater changes uv.lock, so it runs before a matching lock exists; it installs uv itself and requests for its inline Python with setup-python's interpreter.)
- `api.dependencies.install-from-lock`: exception for `docker/Dockerfile` (`pip install "policyengine[models]==`) until the image installs from uv.lock (uv sync --frozen, or uv export --frozen piped to pip install --no-deps), or is removed (The image packages the PolicyEngine .py bundle for interactive use, not this project; uv.lock locks this project. The bundle updater keeps the pin equal to pyproject.toml's, but ipython and the bundle's own dependencies are not pinned.)
Expand Down
9 changes: 6 additions & 3 deletions docs/migration/stage-11-v2-households.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,12 @@ gh workflow run process-v1-household-mirror-event.yml \
```

The workflow starts an environment-specific Cloud SQL Auth Proxy and the
command connects through it as `policyengine_schema_migrator`. The
corresponding local command is intended only for an already authenticated
operator with the same explicit settings and an active staging-only proxy:
command connects through it as `policyengine_schema_migrator`. The workflow
installs the runtime dependencies from `uv.lock` (`uv sync --frozen`), and the
script runs the command through `uv run --frozen`, so a local run needs uv and
uses the same locked versions. The corresponding local command is intended only
for an already authenticated operator with the same explicit settings and an
active staging-only proxy:

```bash
bash .github/scripts/run_household_mirror_event_operator.sh \
Expand Down
12 changes: 0 additions & 12 deletions rules/registry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1274,18 +1274,6 @@ match = 'pip install "ruff=='
category = "pinned-tool"
reason = "Installs the ruff version uv.lock pins, read from uv.lock, without installing the whole project for the lint job."

[[rule.exception]]
path = ".github/workflows/migrate-v1-cloud-sql.yml"
match = 'pip install -e ".[dev]"'
reason = "The migration scripts run with the bare python setup-python provides, against the production and staging Cloud SQL databases; moving them to uv run --frozen has not been tested against those databases."
removal = "the job installs with uv sync --frozen and runs its scripts through uv run --frozen, after a run against the staging database succeeds"

[[rule.exception]]
path = ".github/workflows/process-v1-household-mirror-event.yml"
match = 'pip install -e ".[dev]"'
reason = "The operator script runs with the bare python setup-python provides, against the production and staging databases; moving it to uv run --frozen has not been tested against those databases."
removal = "the job installs with uv sync --frozen and runs its script through uv run --frozen, after a run against the staging database succeeds"

[[rule.exception]]
path = ".github/workflows/update-policyengine-bundle.yaml"
match = "pip install requests uv"
Expand Down
68 changes: 42 additions & 26 deletions tests/unit/test_alembic_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import pytest

REPO = Path(__file__).resolve().parents[2]
# The arguments the migration script passes to uv for each database command.
LOCKED_MIGRATION_COMMAND = "run --frozen python scripts/v1_database_migration.py"


STACKED_PR_CONDITION = (
Expand Down Expand Up @@ -147,15 +149,23 @@ def test_versioning_refreshes_the_lock_after_bumping_the_project_version():
assert changelog_target.index("uv lock") < changelog_target.index("towncrier build")


def test_release_migration_uses_the_installed_python_environment():
def test_release_migration_runs_in_the_locked_python_environment():
workflow = _workflow("migrate-v1-cloud-sql.yml")
orchestration_script = (
REPO / ".github" / "scripts" / "migrate_v1_cloud_sql.sh"
).read_text(encoding="utf-8")
python_commands = re.findall(
r"^.*\bpython scripts/.*$", orchestration_script, flags=re.MULTILINE
)

assert "run: uv sync --frozen" in workflow
assert "pip install" not in workflow
assert "bash .github/scripts/migrate_v1_cloud_sql.sh" in workflow
assert "python scripts/v1_database_migration.py" in orchestration_script
assert "uv run" not in orchestration_script
assert len(python_commands) == 3
assert all(
"uv run --frozen python scripts/v1_database_migration.py" in python_command
for python_command in python_commands
), python_commands


def test_reusable_alembic_check_uses_only_disposable_mysql():
Expand Down Expand Up @@ -351,24 +361,29 @@ def _write_fake_migration_commands(tmp_path: Path) -> tuple[Path, Path]:
)
gcloud_path.chmod(0o755)

python_path = bin_path / "python"
python_path.write_text(
# The script runs every Python command through uv run --frozen. The fake
# uv fails unless it receives the fetched passwords and none of the
# job-level database URLs the script unsets.
uv_path = bin_path / "uv"
uv_path.write_text(
"#!/usr/bin/env bash\n"
': "${POLICYENGINE_DB_READONLY_PASSWORD:?}"\n'
': "${POLICYENGINE_DB_MIGRATION_PASSWORD:?}"\n'
'printf "%s\\n" "$*" >> "${PYTHON_CALLS}"\n'
'[[ "${POLICYENGINE_DB_READONLY_PASSWORD:-}" == reader-p@ss ]] || exit 91\n'
'[[ "${POLICYENGINE_DB_MIGRATION_PASSWORD:-}" == migrator-p@ss ]] || exit 92\n'
'[[ -z "${STAGE7_EXISTING_DATABASE_URL+set}" ]] || exit 93\n'
'[[ -z "${ALEMBIC_DATABASE_URL+set}" ]] || exit 94\n'
'printf "%s\\n" "$*" >> "${UV_CALLS}"\n'
'if [[ "$*" == *"--mode state"* ]]; then\n'
' printf "%s\\n" "${DATABASE_STATE}"\n'
"fi\n",
encoding="utf-8",
)
python_path.chmod(0o755)
uv_path.chmod(0o755)
return bin_path, gcloud_path


def _run_migration_orchestrator(tmp_path: Path, database_state: str):
bin_path, _ = _write_fake_migration_commands(tmp_path)
python_calls = tmp_path / "python-calls"
uv_calls = tmp_path / "uv-calls"
gcloud_calls = tmp_path / "gcloud-calls"
result = subprocess.run(
["bash", ".github/scripts/migrate_v1_cloud_sql.sh"],
Expand All @@ -377,7 +392,10 @@ def _run_migration_orchestrator(tmp_path: Path, database_state: str):
**os.environ,
"DATABASE_STATE": database_state,
"GCLOUD_CALLS": str(gcloud_calls),
"PYTHON_CALLS": str(python_calls),
"UV_CALLS": str(uv_calls),
# A job-level URL must never reach the migration commands.
"STAGE7_EXISTING_DATABASE_URL": "mysql+pymysql://job:url@db/state",
"ALEMBIC_DATABASE_URL": "mysql+pymysql://job:url@db/upgrade",
"POLICYENGINE_DB_INSTANCE_CONNECTION_NAME": "project:region:instance",
"PRODUCTION_POLICYENGINE_DB_INSTANCE_CONNECTION_NAME": (
"project:region:instance"
Expand All @@ -401,37 +419,35 @@ def _run_migration_orchestrator(tmp_path: Path, database_state: str):
text=True,
check=False,
)
return result, python_calls, gcloud_calls
return result, uv_calls, gcloud_calls


def test_migration_orchestrator_keeps_credentials_local_and_upgrades_pending_schema(
tmp_path,
):
result, python_calls, gcloud_calls = _run_migration_orchestrator(
tmp_path, "pending"
)
result, uv_calls, gcloud_calls = _run_migration_orchestrator(tmp_path, "pending")

assert result.returncode == 0, result.stderr
assert "::add-mask::reader-p@ss" in result.stdout
assert "::add-mask::migrator-p@ss" in result.stdout
assert "STAGE7_EXISTING_DATABASE_URL" not in result.stdout
assert "ALEMBIC_DATABASE_URL" not in result.stdout
calls = python_calls.read_text(encoding="utf-8").splitlines()
calls = uv_calls.read_text(encoding="utf-8").splitlines()
assert calls == [
"scripts/v1_database_migration.py --mode state",
"scripts/v1_database_migration.py --mode upgrade --backup-id backup-123",
"scripts/v1_database_migration.py --mode verify-head",
f"{LOCKED_MIGRATION_COMMAND} --mode state",
f"{LOCKED_MIGRATION_COMMAND} --mode upgrade --backup-id backup-123",
f"{LOCKED_MIGRATION_COMMAND} --mode verify-head",
]
assert "sql backups create" in gcloud_calls.read_text(encoding="utf-8")


def test_migration_orchestrator_skips_backup_and_upgrade_at_head(tmp_path):
result, python_calls, gcloud_calls = _run_migration_orchestrator(tmp_path, "head")
result, uv_calls, gcloud_calls = _run_migration_orchestrator(tmp_path, "head")

assert result.returncode == 0, result.stderr
assert python_calls.read_text(encoding="utf-8").splitlines() == [
"scripts/v1_database_migration.py --mode state",
"scripts/v1_database_migration.py --mode verify-head",
assert uv_calls.read_text(encoding="utf-8").splitlines() == [
f"{LOCKED_MIGRATION_COMMAND} --mode state",
f"{LOCKED_MIGRATION_COMMAND} --mode verify-head",
]
assert "sql backups create" not in gcloud_calls.read_text(encoding="utf-8")

Expand All @@ -441,14 +457,14 @@ def test_migration_orchestrator_refuses_unsafe_database_states(
tmp_path,
database_state,
):
result, python_calls, gcloud_calls = _run_migration_orchestrator(
result, uv_calls, gcloud_calls = _run_migration_orchestrator(
tmp_path, database_state
)

assert result.returncode != 0
assert "automatic baseline stamping is disabled" in result.stderr
assert python_calls.read_text(encoding="utf-8").splitlines() == [
"scripts/v1_database_migration.py --mode state"
assert uv_calls.read_text(encoding="utf-8").splitlines() == [
f"{LOCKED_MIGRATION_COMMAND} --mode state"
]
assert "sql backups create" not in gcloud_calls.read_text(encoding="utf-8")

Expand Down
Loading
Loading