Skip to content

Commit 0a5b699

Browse files
authored
Merge pull request #491 from PolicyEngine/codex/add-country-package-updater
[codex] Add country package update automation
2 parents 240af3d + 0b547ba commit 0a5b699

6 files changed

Lines changed: 795 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: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
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+
create_pr_body_file() {
48+
local changelog
49+
local pr_body_file
50+
51+
changelog=$(python3 "${ROOT_DIR}/.github/scripts/check-country-package-updates.py" \
52+
--package "$PACKAGE" \
53+
--old-version "$CURRENT" \
54+
--new-version "$LATEST" 2>/dev/null || true)
55+
56+
pr_body_file="$(mktemp)"
57+
{
58+
echo "## Summary"
59+
echo
60+
echo "Update ${DISPLAY_NAME} from ${CURRENT} to ${LATEST} in the simulation API runtime."
61+
if [[ -n "$changelog" ]]; then
62+
echo
63+
echo "## What changed (${CURRENT} -> ${LATEST})"
64+
echo
65+
echo "$changelog"
66+
fi
67+
echo
68+
echo "---"
69+
echo "Generated automatically by GitHub Actions."
70+
} > "$pr_body_file"
71+
72+
echo "$pr_body_file"
73+
}
74+
75+
if [[ ! -f "$PYPROJECT" || ! -f "$LOCKFILE" || ! -f "$MODAL_APP" ]]; then
76+
echo "ERROR: Expected simulation project files were not found under ${PROJECT_DIR}." >&2
77+
exit 1
78+
fi
79+
80+
CURRENT=$(python3 - "$LOCKFILE" "$PACKAGE" <<'PY'
81+
import re
82+
import sys
83+
84+
lockfile, package = sys.argv[1:]
85+
text = open(lockfile, encoding="utf-8").read()
86+
pattern = rf'\[\[package\]\]\s+name = "{re.escape(package)}"\s+version = "([^"]+)"'
87+
match = re.search(pattern, text)
88+
if not match:
89+
raise SystemExit(f"Package {package!r} not found in {lockfile}")
90+
print(match.group(1))
91+
PY
92+
)
93+
94+
if [[ -n "${LATEST_OVERRIDE:-}" ]]; then
95+
LATEST="$LATEST_OVERRIDE"
96+
else
97+
LATEST=$(curl -fsSL "https://pypi.org/pypi/${PACKAGE}/json" | python3 -c 'import json, sys; print(json.load(sys.stdin)["info"]["version"])')
98+
if [[ -z "$LATEST" ]]; then
99+
echo "ERROR: Could not fetch latest version for ${PACKAGE} from PyPI." >&2
100+
exit 1
101+
fi
102+
fi
103+
104+
if [[ -z "$LATEST" ]]; then
105+
echo "ERROR: Latest version for ${PACKAGE} is empty." >&2
106+
exit 1
107+
fi
108+
109+
echo "Current locked version: ${PACKAGE}==${CURRENT}"
110+
echo "Latest PyPI version: ${PACKAGE}==${LATEST}"
111+
112+
if [[ "$CURRENT" == "$LATEST" ]]; then
113+
echo "Already up to date. Nothing to do."
114+
exit 0
115+
fi
116+
117+
BRANCH="auto/update-${PACKAGE}-${LATEST}"
118+
echo "Update available: ${CURRENT} -> ${LATEST}"
119+
120+
if [[ "$DRY_RUN" == "1" ]]; then
121+
if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
122+
echo "Dry run: remote branch '${BRANCH}' already exists; would ensure a PR exists for it."
123+
exit 0
124+
fi
125+
echo "Dry run: would create ${BRANCH} and update:"
126+
echo " ${PROJECT_DIR}/pyproject.toml"
127+
echo " ${PROJECT_DIR}/uv.lock"
128+
echo " ${PROJECT_DIR}/src/modal/app.py"
129+
exit 0
130+
fi
131+
132+
EXISTING_PR=$(gh pr list \
133+
--head "$BRANCH" \
134+
--state open \
135+
--json number \
136+
--jq '.[0].number' 2>/dev/null || true)
137+
if [[ -n "$EXISTING_PR" ]]; then
138+
echo "PR #${EXISTING_PR} already exists for ${BRANCH}. Skipping."
139+
exit 0
140+
fi
141+
142+
if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
143+
echo "Remote branch '${BRANCH}' already exists without an open PR. Creating PR."
144+
PR_BODY_FILE="$(create_pr_body_file)"
145+
gh pr create \
146+
--base main \
147+
--head "$BRANCH" \
148+
--title "chore(deps): update ${PACKAGE} to ${LATEST}" \
149+
--body-file "$PR_BODY_FILE"
150+
echo "PR created for existing branch ${BRANCH}"
151+
exit 0
152+
fi
153+
154+
git config user.name "github-actions[bot]"
155+
git config user.email "github-actions[bot]@users.noreply.github.com"
156+
git checkout -b "$BRANCH"
157+
158+
python3 - "$PYPROJECT" "$MODAL_APP" "$PACKAGE" "$CURRENT" "$LATEST" "$CONSTANT_NAME" "$ENV_NAME" <<'PY'
159+
import re
160+
import sys
161+
from pathlib import Path
162+
163+
pyproject_path, modal_app_path, package, current, latest, constant, env_name = sys.argv[1:]
164+
165+
pyproject = Path(pyproject_path)
166+
pyproject_text = pyproject.read_text(encoding="utf-8")
167+
old_pin = f'"{package}=={current}"'
168+
new_pin = f'"{package}=={latest}"'
169+
if old_pin not in pyproject_text:
170+
raise SystemExit(f"Could not find {old_pin} in {pyproject}")
171+
pyproject.write_text(pyproject_text.replace(old_pin, new_pin), encoding="utf-8")
172+
173+
modal_app = Path(modal_app_path)
174+
modal_text = modal_app.read_text(encoding="utf-8")
175+
pattern = rf'{constant} = os\.environ\.get\("{env_name}", "[^"]+"\)'
176+
replacement = f'{constant} = os.environ.get("{env_name}", "{latest}")'
177+
updated, count = re.subn(pattern, replacement, modal_text, count=1)
178+
if count != 1:
179+
raise SystemExit(f"Could not update {constant} in {modal_app}")
180+
modal_app.write_text(updated, encoding="utf-8")
181+
PY
182+
183+
(
184+
cd "$PROJECT_PATH"
185+
uv lock --upgrade-package "$PACKAGE"
186+
)
187+
188+
if git diff --quiet -- "$PYPROJECT" "$LOCKFILE" "$MODAL_APP"; then
189+
echo "No changes after update. Nothing to do."
190+
exit 0
191+
fi
192+
193+
PR_BODY_FILE="$(create_pr_body_file)"
194+
195+
git add "$PYPROJECT" "$LOCKFILE" "$MODAL_APP"
196+
git commit -m "chore(deps): update ${PACKAGE} to ${LATEST}"
197+
git push -u origin "$BRANCH"
198+
199+
gh pr create \
200+
--base main \
201+
--title "chore(deps): update ${PACKAGE} to ${LATEST}" \
202+
--body-file "$PR_BODY_FILE"
203+
204+
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 }}

0 commit comments

Comments
 (0)