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
24 changes: 24 additions & 0 deletions .github/scripts/publish-simulation-api-tag.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/bin/bash

set -euo pipefail

PROJECT_DIR="${1:-projects/policyengine-api-simulation}"
PYPROJECT="${PROJECT_DIR}/pyproject.toml"

VERSION=$(python - "$PYPROJECT" <<'PY'
import re
import sys
from pathlib import Path

text = Path(sys.argv[1]).read_text(encoding="utf-8")
match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
if not match:
raise SystemExit(f"Could not find version in {sys.argv[1]}")
print(match.group(1))
PY
)

TAG="policyengine-api-simulation-v${VERSION}"

git tag "$TAG" 2>/dev/null || true
git push origin "refs/tags/${TAG}" || true
10 changes: 8 additions & 2 deletions .github/scripts/update-country-package.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ PROJECT_PATH="${ROOT_DIR}/${PROJECT_DIR}"
PYPROJECT="${PROJECT_PATH}/pyproject.toml"
LOCKFILE="${PROJECT_PATH}/uv.lock"
MODAL_APP="${PROJECT_PATH}/src/modal/app.py"
CHANGELOG_DIR="${PROJECT_PATH}/changelog.d"

create_pr_body_file() {
local changelog
Expand Down Expand Up @@ -115,6 +116,7 @@ if [[ "$CURRENT" == "$LATEST" ]]; then
fi

BRANCH="auto/update-${PACKAGE}-${LATEST}"
CHANGELOG_FRAGMENT="${CHANGELOG_DIR}/update-${PACKAGE}-${LATEST}.changed.md"
echo "Update available: ${CURRENT} -> ${LATEST}"

if [[ "$DRY_RUN" == "1" ]]; then
Expand All @@ -126,6 +128,7 @@ if [[ "$DRY_RUN" == "1" ]]; then
echo " ${PROJECT_DIR}/pyproject.toml"
echo " ${PROJECT_DIR}/uv.lock"
echo " ${PROJECT_DIR}/src/modal/app.py"
echo " ${PROJECT_DIR}/changelog.d/$(basename "$CHANGELOG_FRAGMENT")"
exit 0
fi

Expand Down Expand Up @@ -185,14 +188,17 @@ PY
uv lock --upgrade-package "$PACKAGE"
)

if git diff --quiet -- "$PYPROJECT" "$LOCKFILE" "$MODAL_APP"; then
mkdir -p "$CHANGELOG_DIR"
echo "Update ${DISPLAY_NAME} to ${LATEST}." > "$CHANGELOG_FRAGMENT"

if git diff --quiet -- "$PYPROJECT" "$LOCKFILE" "$MODAL_APP" "$CHANGELOG_FRAGMENT"; then
echo "No changes after update. Nothing to do."
exit 0
fi

PR_BODY_FILE="$(create_pr_body_file)"

git add "$PYPROJECT" "$LOCKFILE" "$MODAL_APP"
git add "$PYPROJECT" "$LOCKFILE" "$MODAL_APP" "$CHANGELOG_FRAGMENT"
git commit -m "chore(deps): update ${PACKAGE} to ${LATEST}"
git push -u origin "$BRANCH"

Expand Down
73 changes: 70 additions & 3 deletions .github/workflows/modal-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,57 @@ concurrency:
group: modal-deploy-main

jobs:
versioning:
name: Update versioning
if: |
(github.repository == 'PolicyEngine/policyengine-api-v2')
&& (github.event_name == 'push')
&& !(github.event.head_commit.message == 'Update Simulation API')
runs-on: ubuntu-latest
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}

- name: Checkout repo
uses: actions/checkout@v6
with:
token: ${{ steps.app-token.outputs.token }}

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.13"

- name: Install uv
uses: astral-sh/setup-uv@v8.1.0

- name: Build changelog
working-directory: projects/policyengine-api-simulation
run: |
python -m pip install towncrier
python scripts/bump_version.py
uv lock
version="$(python -c 'import re; print(re.search(r"version = \"(.+?)\"", open("pyproject.toml").read()).group(1))')"
towncrier build --yes --version "$version"

- name: Update changelog
uses: EndBug/add-and-commit@v9
with:
add: "projects/policyengine-api-simulation"
committer_name: Github Actions[bot]
author_name: Github Actions[bot]
message: Update Simulation API

setup_environments:
name: Setup Modal environments
runs-on: ubuntu-latest
if: |
(github.event_name == 'workflow_dispatch')
|| (github.event.head_commit.message == 'Update Simulation API')
steps:
- name: Checkout repo
uses: actions/checkout@v6
Expand Down Expand Up @@ -48,17 +96,36 @@ jobs:
deploy_beta:
name: Deploy to beta
needs: [setup_environments]
if: ${{ !inputs.skip_beta }}
if: ${{ always() && (github.event_name == 'workflow_dispatch' || github.event.head_commit.message == 'Update Simulation API') && needs.setup_environments.result == 'success' && !inputs.skip_beta }}
uses: ./.github/workflows/modal-deploy.reusable.yml
with:
environment: beta
modal_environment: staging
secrets: inherit

publish_tag:
name: Publish simulation API tag
runs-on: ubuntu-latest
needs: [setup_environments]
if: |
(github.repository == 'PolicyEngine/policyengine-api-v2')
&& (github.event_name == 'push')
&& (github.event.head_commit.message == 'Update Simulation API')
&& (needs.setup_environments.result == 'success')
steps:
- name: Checkout repo
uses: actions/checkout@v6

- name: Make scripts executable
run: chmod +x .github/scripts/*.sh

- name: Publish tag
run: .github/scripts/publish-simulation-api-tag.sh

deploy_prod:
name: Deploy to production
needs: [deploy_beta]
if: ${{ always() && (needs.deploy_beta.result == 'success' || inputs.skip_beta) }}
if: ${{ always() && (github.event_name == 'workflow_dispatch' || github.event.head_commit.message == 'Update Simulation API') && (needs.deploy_beta.result == 'success' || inputs.skip_beta) }}
uses: ./.github/workflows/modal-deploy.reusable.yml
with:
environment: prod
Expand All @@ -68,7 +135,7 @@ jobs:
summary:
name: Deployment summary
needs: [deploy_beta, deploy_prod]
if: always()
if: ${{ always() && (github.event_name == 'workflow_dispatch' || github.event.head_commit.message == 'Update Simulation API') }}
runs-on: ubuntu-latest
steps:
- name: Checkout repo
Expand Down
1 change: 1 addition & 0 deletions projects/policyengine-api-simulation/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Changelog
1 change: 1 addition & 0 deletions projects/policyengine-api-simulation/changelog.d/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ def fake_repo(tmp_path: Path) -> Path:
),
encoding="utf-8",
)
(project / "changelog.d").mkdir()
(modal_dir / "app.py").write_text(
"\n".join(
[
Expand Down
34 changes: 34 additions & 0 deletions projects/policyengine-api-simulation/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,40 @@ policyengine-fastapi = { path = "../../libs/policyengine-fastapi", editable = tr
[project.optional-dependencies]
test = [ "pytest>=8.3.4", "pytest-asyncio>=0.25.3", "pytest-cov>=6.1.1",]
build = [ "pyright>=1.1.401", "black>=25.1.0", "openapi-python-client>=0.21.6",]
release = [ "towncrier>=24.8.0",]

[tool.towncrier]
package = "policyengine_api_simulation"
directory = "changelog.d"
filename = "CHANGELOG.md"
title_format = "## [{version}] - {project_date}"
issue_format = ""
underlines = ["", "", ""]

[[tool.towncrier.type]]
directory = "breaking"
name = "Breaking changes"
showcontent = true

[[tool.towncrier.type]]
directory = "added"
name = "Added"
showcontent = true

[[tool.towncrier.type]]
directory = "changed"
name = "Changed"
showcontent = true

[[tool.towncrier.type]]
directory = "fixed"
name = "Fixed"
showcontent = true

[[tool.towncrier.type]]
directory = "removed"
name = "Removed"
showcontent = true

[tool.pytest.ini_options]
pythonpath = [
Expand Down
89 changes: 89 additions & 0 deletions projects/policyengine-api-simulation/scripts/bump_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Infer a semver bump from Towncrier fragments and update project version."""

from __future__ import annotations

import re
import sys
from pathlib import Path


def get_current_version(pyproject_path: Path) -> str:
text = pyproject_path.read_text(encoding="utf-8")
match = re.search(
r'^version\s*=\s*"(\d+\.\d+\.\d+)"',
text,
re.MULTILINE,
)
if not match:
print("Could not find version in pyproject.toml", file=sys.stderr)
sys.exit(1)
return match.group(1)


def infer_bump(changelog_dir: Path) -> str:
fragments = [
path
for path in changelog_dir.iterdir()
if path.is_file() and path.name != ".gitkeep"
]
if not fragments:
print("No changelog fragments found", file=sys.stderr)
sys.exit(1)

categories = {path.suffix.lstrip(".") for path in fragments}
for path in fragments:
parts = path.stem.split(".")
if len(parts) >= 2:
categories.add(parts[-1])

if "breaking" in categories:
return "major"
if "added" in categories or "removed" in categories:
return "minor"
return "patch"


def bump_version(version: str, bump: str) -> str:
major, minor, patch = (int(part) for part in version.split("."))
if bump == "major":
return f"{major + 1}.0.0"
if bump == "minor":
return f"{major}.{minor + 1}.0"
return f"{major}.{minor}.{patch + 1}"


def update_version(pyproject_path: Path, old_version: str, new_version: str) -> None:
text = pyproject_path.read_text(encoding="utf-8")
updated = text.replace(
f'version = "{old_version}"',
f'version = "{new_version}"',
1,
)
if updated == text:
print(
f"Could not update version in {pyproject_path}",
file=sys.stderr,
)
sys.exit(1)
pyproject_path.write_text(updated, encoding="utf-8")
print(f" Updated {pyproject_path}")


def main(argv: list[str] | None = None) -> None:
args = sys.argv[1:] if argv is None else argv
project_dir = (
Path(args[0]).resolve() if args else Path(__file__).resolve().parent.parent
)
pyproject = project_dir / "pyproject.toml"
changelog_dir = project_dir / "changelog.d"

current = get_current_version(pyproject)
bump = infer_bump(changelog_dir)
new = bump_version(current, bump)

print(f"Version: {current} -> {new} ({bump})")
update_version(pyproject, current, new)


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ def test_update_country_package_dry_run_reports_planned_changes_without_editing(
assert "simulation/pyproject.toml" in result.stdout
assert "simulation/uv.lock" in result.stdout
assert "simulation/src/modal/app.py" in result.stdout
assert "simulation/changelog.d/update-policyengine-us-1.1.0.changed.md" in (
result.stdout
)
assert pyproject.read_text(encoding="utf-8") == original_pyproject


Expand Down Expand Up @@ -170,12 +173,45 @@ def test_update_country_package_updates_files_and_opens_pr(
assert "lock --upgrade-package policyengine-us" in uv_log.read_text(
encoding="utf-8"
)
assert (
fake_repo
/ "simulation"
/ "changelog.d"
/ "update-policyengine-us-1.1.0.changed.md"
).read_text(encoding="utf-8") == "Update PolicyEngine US to 1.1.0.\n"
assert "checkout -b auto/update-policyengine-us-1.1.0" in git_log.read_text(
encoding="utf-8"
)
assert "pr create" in gh_log.read_text(encoding="utf-8")


def test_update_country_package_creates_uk_changelog_fragment(
fake_bin: Path, fake_repo: Path, tmp_path: Path
) -> None:
git_log = tmp_path / "git.log"
gh_log = tmp_path / "gh.log"
uv_log = tmp_path / "uv.log"
install_fake_git(fake_bin, root=fake_repo, log=git_log, diff_has_changes=True)
install_fake_gh(fake_bin, log=gh_log)
install_fake_uv(fake_bin, log=uv_log)

result = run_updater(
"policyengine-uk",
env=updater_env(fake_bin, fake_repo, LATEST_OVERRIDE="2.1.0"),
)

assert result.returncode == 0, result.stderr
fragment = (
fake_repo
/ "simulation"
/ "changelog.d"
/ "update-policyengine-uk-2.1.0.changed.md"
)
assert fragment.read_text(encoding="utf-8") == (
"Update PolicyEngine UK to 2.1.0.\n"
)


def test_parse_changelog_collects_versioned_category_items(
changelog_module: ModuleType,
) -> None:
Expand Down
Loading