Skip to content

Commit fe79f16

Browse files
committed
Add country package update automation
1 parent 240af3d commit fe79f16

4 files changed

Lines changed: 360 additions & 1 deletion

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
#!/usr/bin/env python3
2+
"""Format country package changelog entries between two versions."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import re
8+
import sys
9+
import urllib.error
10+
import urllib.request
11+
12+
REPO_MAP = {
13+
"policyengine-us": "PolicyEngine/policyengine-us",
14+
"policyengine-uk": "PolicyEngine/policyengine-uk",
15+
}
16+
17+
18+
def fetch_changelog(package: str) -> str | None:
19+
repo = REPO_MAP.get(package)
20+
if repo is None:
21+
return None
22+
23+
for branch in ("main", "master"):
24+
url = f"https://raw.githubusercontent.com/{repo}/{branch}/CHANGELOG.md"
25+
try:
26+
with urllib.request.urlopen(url, timeout=30) as response:
27+
if response.status == 200:
28+
return response.read().decode("utf-8")
29+
except (TimeoutError, urllib.error.URLError):
30+
continue
31+
32+
return None
33+
34+
35+
def parse_version(version: str) -> tuple[int, int, int]:
36+
parts = tuple(int(part) for part in version.split("."))
37+
if len(parts) != 3:
38+
raise ValueError(f"Expected a semantic version, got {version!r}")
39+
return parts
40+
41+
42+
def parse_changelog(text: str) -> list[dict[str, object]]:
43+
entries: list[dict[str, object]] = []
44+
current_entry: dict[str, object] | None = None
45+
current_category: str | None = None
46+
47+
for line in text.splitlines():
48+
version_match = re.match(r"^##\s+\[?(\d+\.\d+\.\d+)\]?", line)
49+
if version_match:
50+
current_entry = {"version": version_match.group(1), "changes": {}}
51+
entries.append(current_entry)
52+
current_category = None
53+
continue
54+
55+
if current_entry is None:
56+
continue
57+
58+
category_match = re.match(r"^###\s+(.+)", line)
59+
if category_match:
60+
current_category = category_match.group(1).strip().lower()
61+
continue
62+
63+
item_match = re.match(r"^-\s+(.+)", line)
64+
if item_match and current_category:
65+
changes = current_entry["changes"]
66+
assert isinstance(changes, dict)
67+
changes.setdefault(current_category, [])
68+
changes[current_category].append(item_match.group(1))
69+
70+
return entries
71+
72+
73+
def get_changes_between(
74+
changelog: list[dict[str, object]], old_version: str, new_version: str
75+
) -> list[dict[str, object]]:
76+
old_v = parse_version(old_version)
77+
new_v = parse_version(new_version)
78+
entries = []
79+
for entry in changelog:
80+
version = entry.get("version")
81+
if isinstance(version, str) and old_v < parse_version(version) <= new_v:
82+
entries.append(entry)
83+
return entries
84+
85+
86+
def format_changes(entries: list[dict[str, object]]) -> str:
87+
preferred_order = ("added", "changed", "fixed", "removed", "deprecated")
88+
buckets: dict[str, list[str]] = {category: [] for category in preferred_order}
89+
extra_buckets: dict[str, list[str]] = {}
90+
91+
for entry in entries:
92+
changes = entry.get("changes", {})
93+
if not isinstance(changes, dict):
94+
continue
95+
for category, items in changes.items():
96+
if not isinstance(category, str) or not isinstance(items, list):
97+
continue
98+
target = buckets if category in buckets else extra_buckets
99+
target.setdefault(category, [])
100+
target[category].extend(str(item) for item in items)
101+
102+
sections = []
103+
for category in (*preferred_order, *sorted(extra_buckets)):
104+
items = buckets.get(category) or extra_buckets.get(category) or []
105+
if items:
106+
body = "\n".join(f"- {item}" for item in items)
107+
sections.append(f"### {category.capitalize()}\n{body}")
108+
109+
return "\n\n".join(sections)
110+
111+
112+
def main() -> int:
113+
parser = argparse.ArgumentParser()
114+
parser.add_argument("--package", required=True)
115+
parser.add_argument("--old-version", required=True)
116+
parser.add_argument("--new-version", required=True)
117+
args = parser.parse_args()
118+
119+
changelog_text = fetch_changelog(args.package)
120+
if changelog_text is None:
121+
print(f"Could not fetch changelog for {args.package}.", file=sys.stderr)
122+
return 0
123+
124+
changes = get_changes_between(
125+
parse_changelog(changelog_text), args.old_version, args.new_version
126+
)
127+
if not changes:
128+
print("No changelog entries found between these versions.")
129+
return 0
130+
131+
print(format_changes(changes))
132+
return 0
133+
134+
135+
if __name__ == "__main__":
136+
raise SystemExit(main())
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Check PyPI for a newer country package, update the simulation project pins,
4+
# and open a version-specific PR.
5+
#
6+
# Usage:
7+
# .github/scripts/update-country-package.sh policyengine-us [--dry-run]
8+
# .github/scripts/update-country-package.sh policyengine-uk [--dry-run]
9+
#
10+
# Optional environment:
11+
# PROJECT_DIR Project containing pyproject.toml and uv.lock.
12+
# LATEST_OVERRIDE Version to use instead of querying PyPI, for local checks.
13+
# DRY_RUN=1 Report planned changes without editing files or opening a PR.
14+
15+
set -euo pipefail
16+
17+
PACKAGE="${1:?Usage: update-country-package.sh <policyengine-us|policyengine-uk> [--dry-run]}"
18+
DRY_RUN="${DRY_RUN:-0}"
19+
if [[ "${2:-}" == "--dry-run" ]]; then
20+
DRY_RUN=1
21+
fi
22+
23+
case "$PACKAGE" in
24+
policyengine-us)
25+
DISPLAY_NAME="PolicyEngine US"
26+
CONSTANT_NAME="US_VERSION"
27+
ENV_NAME="POLICYENGINE_US_VERSION"
28+
;;
29+
policyengine-uk)
30+
DISPLAY_NAME="PolicyEngine UK"
31+
CONSTANT_NAME="UK_VERSION"
32+
ENV_NAME="POLICYENGINE_UK_VERSION"
33+
;;
34+
*)
35+
echo "ERROR: Unsupported package '${PACKAGE}'." >&2
36+
exit 1
37+
;;
38+
esac
39+
40+
ROOT_DIR="$(git rev-parse --show-toplevel)"
41+
PROJECT_DIR="${PROJECT_DIR:-projects/policyengine-api-simulation}"
42+
PROJECT_PATH="${ROOT_DIR}/${PROJECT_DIR}"
43+
PYPROJECT="${PROJECT_PATH}/pyproject.toml"
44+
LOCKFILE="${PROJECT_PATH}/uv.lock"
45+
MODAL_APP="${PROJECT_PATH}/src/modal/app.py"
46+
47+
if [[ ! -f "$PYPROJECT" || ! -f "$LOCKFILE" || ! -f "$MODAL_APP" ]]; then
48+
echo "ERROR: Expected simulation project files were not found under ${PROJECT_DIR}." >&2
49+
exit 1
50+
fi
51+
52+
CURRENT=$(python3 - "$LOCKFILE" "$PACKAGE" <<'PY'
53+
import re
54+
import sys
55+
56+
lockfile, package = sys.argv[1:]
57+
text = open(lockfile, encoding="utf-8").read()
58+
pattern = rf'\[\[package\]\]\s+name = "{re.escape(package)}"\s+version = "([^"]+)"'
59+
match = re.search(pattern, text)
60+
if not match:
61+
raise SystemExit(f"Package {package!r} not found in {lockfile}")
62+
print(match.group(1))
63+
PY
64+
)
65+
66+
if [[ -n "${LATEST_OVERRIDE:-}" ]]; then
67+
LATEST="$LATEST_OVERRIDE"
68+
else
69+
LATEST=$(curl -fsSL "https://pypi.org/pypi/${PACKAGE}/json" | python3 -c 'import json, sys; print(json.load(sys.stdin)["info"]["version"])')
70+
if [[ -z "$LATEST" ]]; then
71+
echo "ERROR: Could not fetch latest version for ${PACKAGE} from PyPI." >&2
72+
exit 1
73+
fi
74+
fi
75+
76+
if [[ -z "$LATEST" ]]; then
77+
echo "ERROR: Latest version for ${PACKAGE} is empty." >&2
78+
exit 1
79+
fi
80+
81+
echo "Current locked version: ${PACKAGE}==${CURRENT}"
82+
echo "Latest PyPI version: ${PACKAGE}==${LATEST}"
83+
84+
if [[ "$CURRENT" == "$LATEST" ]]; then
85+
echo "Already up to date. Nothing to do."
86+
exit 0
87+
fi
88+
89+
BRANCH="auto/update-${PACKAGE}-${LATEST}"
90+
echo "Update available: ${CURRENT} -> ${LATEST}"
91+
92+
if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
93+
echo "Branch '${BRANCH}' already exists on remote. Skipping."
94+
exit 0
95+
fi
96+
97+
if [[ "$DRY_RUN" == "1" ]]; then
98+
echo "Dry run: would create ${BRANCH} and update:"
99+
echo " ${PROJECT_DIR}/pyproject.toml"
100+
echo " ${PROJECT_DIR}/uv.lock"
101+
echo " ${PROJECT_DIR}/src/modal/app.py"
102+
exit 0
103+
fi
104+
105+
git config user.name "github-actions[bot]"
106+
git config user.email "github-actions[bot]@users.noreply.github.com"
107+
git checkout -b "$BRANCH"
108+
109+
python3 - "$PYPROJECT" "$MODAL_APP" "$PACKAGE" "$CURRENT" "$LATEST" "$CONSTANT_NAME" "$ENV_NAME" <<'PY'
110+
import re
111+
import sys
112+
from pathlib import Path
113+
114+
pyproject_path, modal_app_path, package, current, latest, constant, env_name = sys.argv[1:]
115+
116+
pyproject = Path(pyproject_path)
117+
pyproject_text = pyproject.read_text(encoding="utf-8")
118+
old_pin = f'"{package}=={current}"'
119+
new_pin = f'"{package}=={latest}"'
120+
if old_pin not in pyproject_text:
121+
raise SystemExit(f"Could not find {old_pin} in {pyproject}")
122+
pyproject.write_text(pyproject_text.replace(old_pin, new_pin), encoding="utf-8")
123+
124+
modal_app = Path(modal_app_path)
125+
modal_text = modal_app.read_text(encoding="utf-8")
126+
pattern = rf'{constant} = os\.environ\.get\("{env_name}", "[^"]+"\)'
127+
replacement = f'{constant} = os.environ.get("{env_name}", "{latest}")'
128+
updated, count = re.subn(pattern, replacement, modal_text, count=1)
129+
if count != 1:
130+
raise SystemExit(f"Could not update {constant} in {modal_app}")
131+
modal_app.write_text(updated, encoding="utf-8")
132+
PY
133+
134+
(
135+
cd "$PROJECT_PATH"
136+
uv lock --upgrade-package "$PACKAGE"
137+
)
138+
139+
if git diff --quiet -- "$PYPROJECT" "$LOCKFILE" "$MODAL_APP"; then
140+
echo "No changes after update. Nothing to do."
141+
exit 0
142+
fi
143+
144+
CHANGELOG=$(python3 "${ROOT_DIR}/.github/scripts/check-country-package-updates.py" \
145+
--package "$PACKAGE" \
146+
--old-version "$CURRENT" \
147+
--new-version "$LATEST" 2>/dev/null || true)
148+
149+
PR_BODY_FILE="$(mktemp)"
150+
{
151+
echo "## Summary"
152+
echo
153+
echo "Update ${DISPLAY_NAME} from ${CURRENT} to ${LATEST} in the simulation API runtime."
154+
if [[ -n "$CHANGELOG" ]]; then
155+
echo
156+
echo "## What changed (${CURRENT} -> ${LATEST})"
157+
echo
158+
echo "$CHANGELOG"
159+
fi
160+
echo
161+
echo "---"
162+
echo "Generated automatically by GitHub Actions."
163+
} > "$PR_BODY_FILE"
164+
165+
git add "$PYPROJECT" "$LOCKFILE" "$MODAL_APP"
166+
git commit -m "chore(deps): update ${PACKAGE} to ${LATEST}"
167+
git push -u origin "$BRANCH"
168+
169+
gh pr create \
170+
--base main \
171+
--title "chore(deps): update ${PACKAGE} to ${LATEST}" \
172+
--body-file "$PR_BODY_FILE"
173+
174+
echo "PR created for ${PACKAGE} ${CURRENT} -> ${LATEST}"
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: Update country packages
2+
3+
on:
4+
schedule:
5+
- cron: "*/30 * * * *"
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: write
10+
pull-requests: write
11+
12+
jobs:
13+
update:
14+
name: Update ${{ matrix.package }}
15+
runs-on: ubuntu-latest
16+
if: github.repository == 'PolicyEngine/policyengine-api-v2'
17+
strategy:
18+
matrix:
19+
package: [policyengine-us, policyengine-uk]
20+
fail-fast: false
21+
22+
steps:
23+
- name: Generate GitHub App token
24+
id: app-token
25+
uses: actions/create-github-app-token@v3
26+
with:
27+
app-id: ${{ secrets.APP_ID }}
28+
private-key: ${{ secrets.APP_PRIVATE_KEY }}
29+
30+
- name: Checkout code
31+
uses: actions/checkout@v6
32+
with:
33+
ref: main
34+
token: ${{ steps.app-token.outputs.token }}
35+
36+
- name: Install uv
37+
uses: astral-sh/setup-uv@v8.1.0
38+
with:
39+
save-cache: false
40+
41+
- name: Set up Python
42+
uses: actions/setup-python@v6
43+
with:
44+
python-version: "3.13"
45+
46+
- name: Check for update and open PR
47+
env:
48+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
49+
run: bash .github/scripts/update-country-package.sh ${{ matrix.package }}

projects/policyengine-api-simulation/src/modal/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from src.modal.logging_redaction import redact_params_for_logging
1515

1616
# Get versions from environment or use defaults
17-
US_VERSION = os.environ.get("POLICYENGINE_US_VERSION", "1.690.7")
17+
US_VERSION = os.environ.get("POLICYENGINE_US_VERSION", "1.691.3")
1818
UK_VERSION = os.environ.get("POLICYENGINE_UK_VERSION", "2.88.14")
1919

2020

0 commit comments

Comments
 (0)