Skip to content

Commit 577bf65

Browse files
authored
Merge pull request #3330 from PolicyEngine/add-country-package-update-cron
Add cron-based country package update workflow
2 parents d8caa1a + 55a193a commit 577bf65

File tree

7 files changed

+346
-84
lines changed

7 files changed

+346
-84
lines changed

.github/scripts/check_updates.py

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Check for PolicyEngine country package updates and generate PR summary.
4+
5+
Checks PyPI for newer versions of country packages pinned in
6+
pyproject.toml. If updates are found, edits pyproject.toml, creates a
7+
changelog fragment, and writes a PR summary file.
8+
"""
9+
10+
import os
11+
import re
12+
import sys
13+
14+
import requests
15+
16+
# Packages to track — must match the exact names used in pyproject.toml
17+
PACKAGES = ["policyengine_us", "policyengine_uk"]
18+
19+
# Map package names to GitHub repos (for fetching changelogs)
20+
REPO_MAP = {
21+
"policyengine_us": "PolicyEngine/policyengine-us",
22+
"policyengine_uk": "PolicyEngine/policyengine-uk",
23+
}
24+
25+
26+
def get_current_versions(pyproject_content):
27+
"""Extract current pinned versions from pyproject.toml."""
28+
versions = {}
29+
for pkg in PACKAGES:
30+
pattern = rf"{pkg.replace('_', '[-_]')}==([0-9]+\.[0-9]+\.[0-9]+)"
31+
match = re.search(pattern, pyproject_content)
32+
if match:
33+
versions[pkg] = match.group(1)
34+
return versions
35+
36+
37+
def get_latest_versions():
38+
"""Fetch latest versions from PyPI."""
39+
versions = {}
40+
for pkg in PACKAGES:
41+
pypi_name = pkg.replace("_", "-")
42+
resp = requests.get(f"https://pypi.org/pypi/{pypi_name}/json")
43+
if resp.status_code == 200:
44+
versions[pkg] = resp.json()["info"]["version"]
45+
return versions
46+
47+
48+
def find_updates(current, latest):
49+
"""Return dict of packages that have newer versions on PyPI."""
50+
updates = {}
51+
for pkg in PACKAGES:
52+
if pkg in current and pkg in latest and current[pkg] != latest[pkg]:
53+
updates[pkg] = {"old": current[pkg], "new": latest[pkg]}
54+
return updates
55+
56+
57+
def update_pyproject(content, updates):
58+
"""Replace pinned versions in pyproject.toml content."""
59+
for pkg, versions in updates.items():
60+
pattern = rf"({pkg.replace('_', '[-_]')}==)[0-9]+\.[0-9]+\.[0-9]+"
61+
content = re.sub(pattern, rf"\g<1>{versions['new']}", content)
62+
return content
63+
64+
65+
def fetch_changelog(pkg):
66+
"""Fetch CHANGELOG.md from the package's GitHub repo."""
67+
repo = REPO_MAP.get(pkg)
68+
if not repo:
69+
return None
70+
# Try main first, then master
71+
for branch in ("main", "master"):
72+
url = f"https://raw.githubusercontent.com/{repo}/{branch}/CHANGELOG.md"
73+
resp = requests.get(url)
74+
if resp.status_code == 200:
75+
return resp.text
76+
return None
77+
78+
79+
def parse_version(version_str):
80+
"""Parse a version string into a comparable tuple."""
81+
return tuple(map(int, version_str.split(".")))
82+
83+
84+
def parse_changelog(text):
85+
"""Parse Keep-a-Changelog markdown into structured entries."""
86+
if not text:
87+
return []
88+
89+
entries = []
90+
current_entry = None
91+
current_category = None
92+
93+
for line in text.splitlines():
94+
version_match = re.match(r"^##\s+\[(\d+\.\d+\.\d+)\]", line)
95+
if version_match:
96+
current_entry = {
97+
"version": version_match.group(1),
98+
"changes": {},
99+
}
100+
entries.append(current_entry)
101+
current_category = None
102+
continue
103+
104+
if current_entry is None:
105+
continue
106+
107+
category_match = re.match(r"^###\s+(\w+)", line)
108+
if category_match:
109+
current_category = category_match.group(1).lower()
110+
continue
111+
112+
item_match = re.match(r"^-\s+(.+)", line)
113+
if item_match and current_category:
114+
current_entry["changes"].setdefault(current_category, [])
115+
current_entry["changes"][current_category].append(item_match.group(1))
116+
117+
return entries
118+
119+
120+
def get_changes_between(changelog, old_version, new_version):
121+
"""Extract changelog entries between two versions."""
122+
old_v = parse_version(old_version)
123+
new_v = parse_version(new_version)
124+
return [
125+
e
126+
for e in changelog
127+
if "version" in e and old_v < parse_version(e["version"]) <= new_v
128+
]
129+
130+
131+
def format_changes(entries):
132+
"""Format changelog entries as markdown sections."""
133+
buckets = {"added": [], "changed": [], "fixed": [], "removed": []}
134+
for entry in entries:
135+
for cat, items in entry.get("changes", {}).items():
136+
if cat in buckets:
137+
buckets[cat].extend(items)
138+
139+
sections = []
140+
for cat, items in buckets.items():
141+
if items:
142+
sections.append(
143+
f"### {cat.capitalize()}\n" + "\n".join(f"- {item}" for item in items)
144+
)
145+
return "\n\n".join(sections) if sections else "No detailed changes available."
146+
147+
148+
def generate_summary(updates):
149+
"""Build a PR summary with version table and changelogs."""
150+
parts = []
151+
152+
table = "| Package | Old Version | New Version |\n|---------|-------------|-------------|\n"
153+
for pkg, v in updates.items():
154+
table += f"| {pkg} | {v['old']} | {v['new']} |\n"
155+
parts.append(table)
156+
157+
for pkg, v in updates.items():
158+
changelog_text = fetch_changelog(pkg)
159+
changelog = parse_changelog(changelog_text) if changelog_text else None
160+
if changelog:
161+
entries = get_changes_between(changelog, v["old"], v["new"])
162+
if entries:
163+
formatted = format_changes(entries)
164+
parts.append(
165+
f"## What Changed ({pkg} {v['old']}{v['new']})\n\n{formatted}"
166+
)
167+
else:
168+
parts.append(
169+
f"## What Changed ({pkg} {v['old']}{v['new']})\n\n"
170+
"No changelog entries found between these versions."
171+
)
172+
173+
return "\n\n".join(parts)
174+
175+
176+
def write_github_output(key, value):
177+
"""Write a key=value pair to the GitHub Actions output file."""
178+
path = os.environ.get("GITHUB_OUTPUT")
179+
if path:
180+
with open(path, "a") as f:
181+
f.write(f"{key}={value}\n")
182+
183+
184+
def main():
185+
with open("pyproject.toml", "r") as f:
186+
content = f.read()
187+
188+
current = get_current_versions(content)
189+
print(f"Current versions: {current}")
190+
191+
latest = get_latest_versions()
192+
print(f"Latest versions: {latest}")
193+
194+
updates = find_updates(current, latest)
195+
if not updates:
196+
print("No updates available.")
197+
write_github_output("has_updates", "false")
198+
return 0
199+
200+
print(f"Updates available: {updates}")
201+
202+
# Update pyproject.toml
203+
new_content = update_pyproject(content, updates)
204+
with open("pyproject.toml", "w") as f:
205+
f.write(new_content)
206+
207+
# Generate PR summary
208+
summary = generate_summary(updates)
209+
with open("pr_summary.md", "w") as f:
210+
f.write(summary)
211+
212+
# Create changelog fragment(s) in changelog.d/
213+
for pkg, v in updates.items():
214+
pretty = pkg.replace("_", " ").replace("policyengine", "PolicyEngine")
215+
fragment = f"changelog.d/update-{pkg.replace('_', '-')}-{v['new']}.changed.md"
216+
with open(fragment, "w") as f:
217+
f.write(f"Update {pretty} to {v['new']}.\n")
218+
219+
# Set outputs
220+
write_github_output("has_updates", "true")
221+
updates_str = ", ".join(f"{pkg} to {v['new']}" for pkg, v in updates.items())
222+
write_github_output("updates_summary", updates_str)
223+
224+
print("Updates prepared successfully!")
225+
return 0
226+
227+
228+
if __name__ == "__main__":
229+
sys.exit(main())

.github/scripts/create_pr.sh

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Create or update a PR for country package updates.
4+
#
5+
# Reads pr_summary.md and creates a new PR (or updates an existing one)
6+
# on the given branch.
7+
#
8+
# Usage: ./create_pr.sh <branch-name>
9+
#
10+
# Environment: GH_TOKEN must be set for the gh CLI.
11+
set -euo pipefail
12+
13+
BRANCH="${1:?Usage: create_pr.sh <branch-name>}"
14+
15+
if [[ ! -f pr_summary.md ]]; then
16+
echo "Error: pr_summary.md not found"
17+
exit 1
18+
fi
19+
20+
PR_SUMMARY=$(cat pr_summary.md)
21+
22+
PR_BODY="## Summary
23+
24+
Automated country-package version bump.
25+
26+
## Version Updates
27+
28+
${PR_SUMMARY}
29+
30+
---
31+
Generated automatically by GitHub Actions"
32+
33+
# Re-use an existing PR on this branch if one is open
34+
EXISTING_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number' 2>/dev/null || true)
35+
36+
if [[ -n "$EXISTING_PR" ]]; then
37+
echo "Updating existing PR #${EXISTING_PR}"
38+
gh api --method PATCH "/repos/{owner}/{repo}/pulls/${EXISTING_PR}" \
39+
-f body="$PR_BODY"
40+
else
41+
echo "Creating new PR"
42+
gh pr create \
43+
--title "Update country packages" \
44+
--body "$PR_BODY" \
45+
--base master
46+
fi

.github/workflows/pr.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,18 @@ jobs:
3535
test_container_builds:
3636
name: Docker
3737
runs-on: ubuntu-latest
38+
permissions:
39+
contents: read
40+
packages: write
3841
steps:
3942
- name: Checkout repo
4043
uses: actions/checkout@v4
4144
- name: Log in to the Container registry
42-
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
45+
uses: docker/login-action@v3
4346
with:
4447
registry: ghcr.io
4548
username: ${{ github.actor }}
46-
password: ${{ secrets.POLICYENGINE_DOCKER }}
49+
password: ${{ secrets.GITHUB_TOKEN }}
4750
- name: Build container
4851
run: docker build -t ghcr.io/policyengine/policyengine docker
4952
test_env_vars:

.github/workflows/push.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,15 +111,18 @@ jobs:
111111
name: Docker
112112
runs-on: ubuntu-latest
113113
needs: ensure-model-version-aligns-with-sim-api
114+
permissions:
115+
contents: read
116+
packages: write
114117
steps:
115118
- name: Checkout repo
116119
uses: actions/checkout@v4
117120
- name: Log in to the Container registry
118-
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
121+
uses: docker/login-action@v3
119122
with:
120123
registry: ghcr.io
121124
username: ${{ github.actor }}
122-
password: ${{ secrets.POLICYENGINE_DOCKER }}
125+
password: ${{ secrets.GITHUB_TOKEN }}
123126
- name: Build container
124127
run: docker build -t ghcr.io/policyengine/policyengine docker
125128
- name: Push container
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
name: Update country packages
2+
3+
on:
4+
schedule:
5+
- cron: "*/30 * * * *" # Every 30 minutes
6+
workflow_dispatch:
7+
8+
jobs:
9+
update:
10+
name: Check for country package updates
11+
runs-on: ubuntu-latest
12+
if: github.repository == 'PolicyEngine/policyengine-api'
13+
steps:
14+
- name: Generate GitHub App token
15+
id: app-token
16+
uses: actions/create-github-app-token@v1
17+
with:
18+
app-id: ${{ secrets.APP_ID }}
19+
private-key: ${{ secrets.APP_PRIVATE_KEY }}
20+
21+
- name: Checkout repo
22+
uses: actions/checkout@v4
23+
with:
24+
ref: master
25+
token: ${{ steps.app-token.outputs.token }}
26+
27+
- name: Set up Python
28+
uses: actions/setup-python@v5
29+
with:
30+
python-version: "3.12"
31+
32+
- name: Install dependencies
33+
run: pip install requests
34+
35+
- name: Check for updates
36+
id: check
37+
run: python .github/scripts/check_updates.py
38+
39+
- name: No updates available
40+
if: steps.check.outputs.has_updates != 'true'
41+
run: echo "::notice::All country packages are up to date."
42+
43+
- name: Commit and push
44+
if: steps.check.outputs.has_updates == 'true'
45+
run: |
46+
git config user.name "github-actions[bot]"
47+
git config user.email "github-actions[bot]@users.noreply.github.com"
48+
BRANCH="bot/update-country-packages"
49+
git checkout -b "$BRANCH"
50+
git add pyproject.toml changelog.d/
51+
git commit -m "Update country packages (${{ steps.check.outputs.updates_summary }})"
52+
git push -f origin "$BRANCH"
53+
54+
- name: Create or update PR
55+
if: steps.check.outputs.has_updates == 'true'
56+
env:
57+
GH_TOKEN: ${{ steps.app-token.outputs.token }}
58+
run: |
59+
chmod +x .github/scripts/create_pr.sh
60+
.github/scripts/create_pr.sh bot/update-country-packages
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Replace push-based country package bump with cron-based workflow that polls PyPI every 30 minutes. Fix Docker login to use GITHUB_TOKEN instead of expired PAT.

0 commit comments

Comments
 (0)