|
| 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}" |
0 commit comments