Skip to content
Draft
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: 8 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Rule machinery: changes need review from a core developer other than the
# author. Deliberately narrow: pyproject.toml and workflow files are not owned,
# so dependency bots and everyday CI edits are not blocked.
/rules/ @PolicyEngine/core-developers
/scripts/rules/ @PolicyEngine/core-developers
/scripts/guards/ @PolicyEngine/core-developers
/scripts/run_quality_guards.py @PolicyEngine/core-developers
/.github/CODEOWNERS @PolicyEngine/core-developers
9 changes: 9 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Fixes #

## Summary

-

## Testing

-
39 changes: 39 additions & 0 deletions .github/scripts/check_ci_complete.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env bash

# Gate behind the "CI complete" check in pr.yml.
#
# In a stack of pull requests, only the lowest unmerged one runs the full
# workflow; the jobs of the others are skipped to save runners. A skipped job
# counts as passing for a required check, so this gate fails on those pull
# requests instead. On every other pull request it passes only when every job
# it needs passed.
#
# Environment:
# JOB_RESULTS toJSON(needs) for the gate job
# PULL_REQUEST_BASE_REF github.event.pull_request.base.ref
# STACK_BASE_REF github.event.pull_request.stack.base.ref, empty when
# the pull request isn't in a stack

set -euo pipefail

job_results="${JOB_RESULTS:?JOB_RESULTS is required}"
pull_request_base_ref="${PULL_REQUEST_BASE_REF:?PULL_REQUEST_BASE_REF is required}"
stack_base_ref="${STACK_BASE_REF:-}"

if [[ -n "${stack_base_ref}" && "${stack_base_ref}" != "${pull_request_base_ref}" ]]; then
echo "::error::Full CI runs only on the lowest unmerged pull request in a stack. This one targets ${pull_request_base_ref}; it runs once the pull request below it merges and this one is rebased onto ${stack_base_ref}."
exit 1
fi

unsuccessful_jobs="$(
jq -r 'to_entries[] | select(.value.result != "success") | "\(.key): \(.value.result)"' <<<"${job_results}"
)"

if [[ -n "${unsuccessful_jobs}" ]]; then
while IFS= read -r unsuccessful_job; do
echo "::error::Job did not pass: ${unsuccessful_job}"
done <<<"${unsuccessful_jobs}"
exit 1
fi

echo "Every job passed."
30 changes: 30 additions & 0 deletions .github/scripts/install_locked_ruff.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env bash

# Install the ruff version pinned in uv.lock, so CI formats and lints with the
# same ruff as `uv sync --extra dev`.
#
# Usage: install_locked_ruff.sh [path/to/uv.lock]
# PYTHON selects the interpreter that reads the lock (Python 3.11+ for tomllib).

set -euo pipefail

lock_file="${1:-uv.lock}"
python_bin="${PYTHON:-python3}"

version="$(
"${python_bin}" - "${lock_file}" <<'PY'
import sys
import tomllib

lock_path = sys.argv[1]
with open(lock_path, "rb") as lock:
packages = tomllib.load(lock).get("package", [])
versions = [package["version"] for package in packages if package.get("name") == "ruff"]
if not versions:
sys.exit(f"ruff is not pinned in {lock_path}; add it to the dev extra and run uv lock")
print(versions[0])
PY
)"

pip install "ruff==${version}"
ruff --version
58 changes: 56 additions & 2 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,46 @@ env:
OLD_SIMULATION_GATEWAY_URL: ${{ vars.OLD_SIMULATION_GATEWAY_URL }}

jobs:
ci-complete:
# The required check. Needs every other job: on a stacked pull request
# above the lowest unmerged one it fails, because those jobs were skipped
# and a skipped job would count as passing; otherwise it passes only when
# every job passed. See docs/engineering/skills/github-prs.md.
name: CI complete
if: ${{ always() }}
needs:
- ensure-policyengine-bundle-supported-by-simulation-api
- lint
- typecheck-v2
- quality-guards
- alembic-v1-check
- alembic-v2-check
- v2-integration-check
- check-changelog
- test_container_builds
- test_cloud_run_container_builds
- test_env_vars
- test
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout the gate script
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts/check_ci_complete.sh
sparse-checkout-cone-mode: false
- name: Require full CI, with every job passing
env:
JOB_RESULTS: ${{ toJSON(needs) }}
PULL_REQUEST_BASE_REF: ${{ github.event.pull_request.base.ref }}
STACK_BASE_REF: ${{ github.event.pull_request.stack.base.ref }}
run: bash .github/scripts/check_ci_complete.sh

ensure-policyengine-bundle-supported-by-simulation-api:
name: Ensure PolicyEngine bundle is supported by simulation API
# Full CI runs only on the lowest unmerged pull request in a stack.
if: ${{ !github.event.pull_request.stack || github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref }}
runs-on: ubuntu-latest
environment: staging
env:
Expand All @@ -34,18 +72,24 @@ jobs:

lint:
name: Lint
# Full CI runs only on the lowest unmerged pull request in a stack.
if: ${{ !github.event.pull_request.stack || github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref }}
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
- name: Install ruff
run: pip install ruff>=0.9.0
with:
python-version: "3.12"
- name: Install ruff (version pinned in uv.lock)
run: bash .github/scripts/install_locked_ruff.sh
- name: Format check with ruff
run: ruff format --check .
typecheck-v2:
name: Type-check API v2
# Full CI runs only on the lowest unmerged pull request in a stack.
if: ${{ !github.event.pull_request.stack || github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref }}
runs-on: ubuntu-latest
steps:
- name: Checkout repo
Expand All @@ -62,6 +106,8 @@ jobs:
run: uv run --frozen --extra dev mypy
quality-guards:
name: Quality guards
# Full CI runs only on the lowest unmerged pull request in a stack.
if: ${{ !github.event.pull_request.stack || github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref }}
runs-on: ubuntu-latest
steps:
- name: Checkout repo
Expand All @@ -75,17 +121,25 @@ jobs:

alembic-v1-check:
name: Alembic v1 qualification
# Full CI runs only on the lowest unmerged pull request in a stack.
if: ${{ !github.event.pull_request.stack || github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref }}
uses: ./.github/workflows/alembic-v1-check.yml
alembic-v2-check:
name: Alembic v2 qualification
# Full CI runs only on the lowest unmerged pull request in a stack.
if: ${{ !github.event.pull_request.stack || github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref }}
uses: ./.github/workflows/alembic-v2-check.yml
v2-integration-check:
name: Cross-database integration tests
# Full CI runs only on the lowest unmerged pull request in a stack.
if: ${{ !github.event.pull_request.stack || github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref }}
uses: ./.github/workflows/v2-integration-check.yml
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
check-changelog:
name: Check changelog fragment
# Full CI runs only on the lowest unmerged pull request in a stack.
if: ${{ !github.event.pull_request.stack || github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ jobs:
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
- name: Install ruff
run: pip install ruff>=0.9.0
with:
python-version: "3.12"
- name: Install ruff (version pinned in uv.lock)
run: bash .github/scripts/install_locked_ruff.sh
- name: Format check with ruff
run: ruff format --check .

Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ When modifying the `Makefile`, read
## GitHub PRs

Read `docs/engineering/skills/github-prs.md` before opening, replacing, or
sharing any pull request.
sharing any pull request. Every PR needs a tracking issue and
`Fixes #ISSUE_NUMBER` as the first line of its description. Agents must never
bypass branch rules (for example with `gh pr merge --admin`).

Before creating or sharing a migration PR, run the focused migration guards:

Expand Down
1 change: 1 addition & 0 deletions changelog.d/phase-0-enforcement.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added CODEOWNERS for the rule machinery, pinned CI's ruff to the version in uv.lock with an explicit rule selection, made the v2 CRUD boundary tests discover modules so households and user_households are checked, and documented the tracking-issue, Fixes line and no-bypass PR rules. In a stack of pull requests, only the lowest unmerged one now runs the full PR workflow, behind a new "CI complete" gate job that fails on the others.
36 changes: 36 additions & 0 deletions docs/engineering/skills/github-prs.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,42 @@

Use this skill before opening, replacing, or sharing a pull request.

## Tracking Issues

Before opening a PR, all developers and agents must:

1. Open a GitHub issue for the work in `PolicyEngine/policyengine-api`, or
verify that an appropriate issue already exists.
2. Put `Fixes #ISSUE_NUMBER` as the first line of the PR description, using the
issue number from the previous step. The PR template starts with this line.

## Branch Rules

Agents must never bypass branch rules, even when their credentials have admin
rights: do not merge with `gh pr merge --admin`, and do not use GitHub's option
to bypass rules. If a required check or review blocks a merge, report it to a
human instead.

## Stacked PRs and CI

In a stack of pull requests, only the lowest unmerged one runs the full PR
workflow (`.github/workflows/pr.yml`). The ones above it run only the
`CI complete` job, which fails and says so. A stack therefore uses one set of
runners instead of one per layer.

- **`CI complete` is the required check.** It needs every other job. On a
pull request that isn't stacked, or is the lowest unmerged one, it passes
only when every job passed. Skipped jobs would otherwise count as passing, so
a stack can only merge bottom-up, and nothing merges untested.
- **When the pull request below merges,** GitHub rebases the next one onto
the trunk, and that push starts its full run. If no run starts, push again
(`gh stack rebase`, then `gh stack push`), or close and reopen the pull
request. Re-running the old workflow doesn't help: it reuses the old event,
which still describes the pull request as higher in the stack.
- **When you add a job to `pr.yml`,** add it to `CI complete`'s `needs`. If
the job has no `needs` of its own, also give it the same stack condition as
the other jobs. `tests/unit/test_ci_complete_gate.py` checks both.

## Migration PRs

Migration PRs should keep the user experience stable unless the PR explicitly
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ packages = ["policyengine_api"]
[tool.hatch.metadata]
allow-direct-references = true

[tool.ruff.lint]
# Ruff's defaults as of 0.15, made explicit so a ruff upgrade cannot change
# which rules apply. Widen this deliberately, one rule family per PR.
select = ["E4", "E7", "E9", "F"]

[tool.ruff.lint.per-file-ignores]
"migrations/*/versions/*.py" = ["F401"]

Expand Down
22 changes: 20 additions & 2 deletions tests/unit/test_alembic_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,24 @@
REPO = Path(__file__).resolve().parents[2]


STACKED_PR_CONDITION = (
"${{ !github.event.pull_request.stack || "
"github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref }}"
)


def _workflow(name: str) -> str:
return (REPO / ".github" / "workflows" / name).read_text(encoding="utf-8")


def _job_conditions(job_text: str) -> list[str]:
return [
line.strip().removeprefix("if:").strip()
for line in job_text.splitlines()
if line.strip().startswith("if:")
]


def _long_inline_run_blocks() -> list[str]:
offenders = []
block_markers = {"|", "|-", "|+", ">", ">-", ">+"}
Expand Down Expand Up @@ -74,9 +88,13 @@ def test_pr_always_runs_reusable_alembic_and_v2_integration_checks():
assert "uses: ./.github/workflows/v2-integration-check.yml" in integration_job
assert "CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}" in integration_job
assert "needs:" not in alembic_job
assert "if:" not in alembic_job
assert "needs:" not in integration_job
assert "if:" not in integration_job
# The only condition allowed is the stacked-PR one: these checks run on
# every PR that isn't stacked and on the lowest unmerged PR of a stack, and
# CI complete, which needs them, fails on the others
# (tests/unit/test_ci_complete_gate.py).
assert _job_conditions(alembic_job) == [STACKED_PR_CONDITION]
assert _job_conditions(integration_job) == [STACKED_PR_CONDITION]


def test_push_requires_schema_and_v2_integration_checks_before_versioning():
Expand Down
Loading
Loading