From 61d8ba0dda31e6c66510002120582f7975831d33 Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Thu, 21 May 2026 22:25:43 +0200 Subject: [PATCH] Add simulation API release versioning --- .github/scripts/publish-simulation-api-tag.sh | 24 +++++ .github/scripts/update-country-package.sh | 10 ++- .github/workflows/modal-deploy.yml | 73 ++++++++++++++- .../policyengine-api-simulation/CHANGELOG.md | 1 + .../changelog.d/.gitkeep | 1 + .../test_country_package_update_scripts.py | 1 + .../pyproject.toml | 34 +++++++ .../scripts/bump_version.py | 89 +++++++++++++++++++ .../test_country_package_update_scripts.py | 36 ++++++++ .../tests/test_modal_deploy_workflow.py | 32 +++++++ .../tests/test_modal_scripts.py | 21 ++++- .../tests/test_simulation_versioning.py | 84 +++++++++++++++++ projects/policyengine-api-simulation/uv.lock | 19 +++- 13 files changed, 418 insertions(+), 7 deletions(-) create mode 100755 .github/scripts/publish-simulation-api-tag.sh create mode 100644 projects/policyengine-api-simulation/CHANGELOG.md create mode 100644 projects/policyengine-api-simulation/changelog.d/.gitkeep create mode 100644 projects/policyengine-api-simulation/scripts/bump_version.py create mode 100644 projects/policyengine-api-simulation/tests/test_modal_deploy_workflow.py create mode 100644 projects/policyengine-api-simulation/tests/test_simulation_versioning.py diff --git a/.github/scripts/publish-simulation-api-tag.sh b/.github/scripts/publish-simulation-api-tag.sh new file mode 100755 index 000000000..ee8a6e507 --- /dev/null +++ b/.github/scripts/publish-simulation-api-tag.sh @@ -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 diff --git a/.github/scripts/update-country-package.sh b/.github/scripts/update-country-package.sh index 780cd229f..0eb93a5ca 100644 --- a/.github/scripts/update-country-package.sh +++ b/.github/scripts/update-country-package.sh @@ -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 @@ -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 @@ -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 @@ -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" diff --git a/.github/workflows/modal-deploy.yml b/.github/workflows/modal-deploy.yml index 91e781f2f..4c8bf9d44 100644 --- a/.github/workflows/modal-deploy.yml +++ b/.github/workflows/modal-deploy.yml @@ -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 @@ -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 @@ -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 diff --git a/projects/policyengine-api-simulation/CHANGELOG.md b/projects/policyengine-api-simulation/CHANGELOG.md new file mode 100644 index 000000000..825c32f0d --- /dev/null +++ b/projects/policyengine-api-simulation/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/projects/policyengine-api-simulation/changelog.d/.gitkeep b/projects/policyengine-api-simulation/changelog.d/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/projects/policyengine-api-simulation/changelog.d/.gitkeep @@ -0,0 +1 @@ + diff --git a/projects/policyengine-api-simulation/fixtures/test_country_package_update_scripts.py b/projects/policyengine-api-simulation/fixtures/test_country_package_update_scripts.py index 08a68a7e7..630df47a0 100644 --- a/projects/policyengine-api-simulation/fixtures/test_country_package_update_scripts.py +++ b/projects/policyengine-api-simulation/fixtures/test_country_package_update_scripts.py @@ -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( [ diff --git a/projects/policyengine-api-simulation/pyproject.toml b/projects/policyengine-api-simulation/pyproject.toml index 84a91564a..3bd84f42e 100644 --- a/projects/policyengine-api-simulation/pyproject.toml +++ b/projects/policyengine-api-simulation/pyproject.toml @@ -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 = [ diff --git a/projects/policyengine-api-simulation/scripts/bump_version.py b/projects/policyengine-api-simulation/scripts/bump_version.py new file mode 100644 index 000000000..1fbb67322 --- /dev/null +++ b/projects/policyengine-api-simulation/scripts/bump_version.py @@ -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() diff --git a/projects/policyengine-api-simulation/tests/test_country_package_update_scripts.py b/projects/policyengine-api-simulation/tests/test_country_package_update_scripts.py index c035d791f..7f555e849 100644 --- a/projects/policyengine-api-simulation/tests/test_country_package_update_scripts.py +++ b/projects/policyengine-api-simulation/tests/test_country_package_update_scripts.py @@ -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 @@ -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: diff --git a/projects/policyengine-api-simulation/tests/test_modal_deploy_workflow.py b/projects/policyengine-api-simulation/tests/test_modal_deploy_workflow.py new file mode 100644 index 000000000..c7c74bdb2 --- /dev/null +++ b/projects/policyengine-api-simulation/tests/test_modal_deploy_workflow.py @@ -0,0 +1,32 @@ +"""Static checks for the Modal deployment workflow release gates.""" + +from __future__ import annotations + +from fixtures.test_modal_scripts import REPO_ROOT + + +WORKFLOW = REPO_ROOT / ".github" / "workflows" / "modal-deploy.yml" + + +def test_modal_deploy_workflow_uses_two_stage_release_commit() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + + assert "name: Update versioning" in workflow + assert "github.event.head_commit.message == 'Update Simulation API'" in workflow + assert "!(github.event.head_commit.message == 'Update Simulation API')" in workflow + assert "message: Update Simulation API" in workflow + + +def test_modal_deploy_workflow_keeps_manual_dispatch_direct_deploy() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + + assert "workflow_dispatch:" in workflow + assert "skip_beta:" in workflow + assert "github.event_name == 'workflow_dispatch'" in workflow + + +def test_modal_deploy_workflow_publishes_simulation_api_tag() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + + assert "name: Publish simulation API tag" in workflow + assert ".github/scripts/publish-simulation-api-tag.sh" in workflow diff --git a/projects/policyengine-api-simulation/tests/test_modal_scripts.py b/projects/policyengine-api-simulation/tests/test_modal_scripts.py index 648273285..9f48cee84 100644 --- a/projects/policyengine-api-simulation/tests/test_modal_scripts.py +++ b/projects/policyengine-api-simulation/tests/test_modal_scripts.py @@ -363,6 +363,25 @@ def test_script_syntax(self): assert result.returncode == 0, f"Syntax error: {result.stderr}" +class TestPublishSimulationApiTag: + """Tests for publish-simulation-api-tag.sh""" + + script = SCRIPTS_DIR / "publish-simulation-api-tag.sh" + + def test_script_exists(self): + """Script file should exist.""" + assert self.script.exists(), f"Script not found at {self.script}" + + def test_script_syntax(self): + """Script should have valid bash syntax.""" + result = subprocess.run( + ["bash", "-n", str(self.script)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"Syntax error: {result.stderr}" + + class TestModalRunIntegTests: """Tests for modal-run-integ-tests.sh""" @@ -446,7 +465,7 @@ def test_exports_us_and_uk_model_versions_to_integration_tests(self, tmp_path): fake_bin.mkdir() fake_uv = fake_bin / "uv" fake_uv.write_text( - '#!/bin/bash\n' + "#!/bin/bash\n" 'printf "%s|base=%s|us=%s|uk=%s\\n" "$*" ' '"${simulation_integ_test_base_url:-}" ' '"${simulation_integ_test_us_model_version:-}" ' diff --git a/projects/policyengine-api-simulation/tests/test_simulation_versioning.py b/projects/policyengine-api-simulation/tests/test_simulation_versioning.py new file mode 100644 index 000000000..d4d9ee633 --- /dev/null +++ b/projects/policyengine-api-simulation/tests/test_simulation_versioning.py @@ -0,0 +1,84 @@ +"""Tests for simulation API release versioning helpers.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from fixtures.test_modal_scripts import REPO_ROOT + + +SCRIPT = ( + REPO_ROOT + / "projects" + / "policyengine-api-simulation" + / "scripts" + / "bump_version.py" +) + + +def make_project(tmp_path: Path, *fragments: str) -> Path: + project = tmp_path / "simulation" + changelog = project / "changelog.d" + changelog.mkdir(parents=True) + (project / "pyproject.toml").write_text( + "\n".join( + [ + "[project]", + 'name = "policyengine-simulation-api-project"', + 'version = "1.2.3"', + ] + ), + encoding="utf-8", + ) + for fragment in fragments: + (changelog / fragment).write_text("Example change.\n", encoding="utf-8") + return project + + +def run_bump(project: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["python", str(SCRIPT), str(project)], + capture_output=True, + text=True, + ) + + +def read_version(project: Path) -> str: + return (project / "pyproject.toml").read_text(encoding="utf-8") + + +def test_bump_version_uses_patch_for_changed_fragments(tmp_path: Path) -> None: + project = make_project(tmp_path, "country-package.changed.md") + + result = run_bump(project) + + assert result.returncode == 0, result.stderr + assert 'version = "1.2.4"' in read_version(project) + + +def test_bump_version_uses_minor_for_added_fragments(tmp_path: Path) -> None: + project = make_project(tmp_path, "feature.added.md") + + result = run_bump(project) + + assert result.returncode == 0, result.stderr + assert 'version = "1.3.0"' in read_version(project) + + +def test_bump_version_uses_major_for_breaking_fragments(tmp_path: Path) -> None: + project = make_project(tmp_path, "api-breaking.breaking.md") + + result = run_bump(project) + + assert result.returncode == 0, result.stderr + assert 'version = "2.0.0"' in read_version(project) + + +def test_bump_version_fails_without_fragments(tmp_path: Path) -> None: + project = make_project(tmp_path) + + result = run_bump(project) + + assert result.returncode != 0 + assert "No changelog fragments found" in result.stderr diff --git a/projects/policyengine-api-simulation/uv.lock b/projects/policyengine-api-simulation/uv.lock index 0f679e01b..a5e653f79 100644 --- a/projects/policyengine-api-simulation/uv.lock +++ b/projects/policyengine-api-simulation/uv.lock @@ -1785,6 +1785,9 @@ build = [ { name = "openapi-python-client" }, { name = "pyright" }, ] +release = [ + { name = "towncrier" }, +] test = [ { name = "pytest" }, { name = "pytest-asyncio" }, @@ -1810,8 +1813,9 @@ requires-dist = [ { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.25.3" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.1.1" }, { name = "tables", specifier = ">=3.10.2" }, + { name = "towncrier", marker = "extra == 'release'", specifier = ">=24.8.0" }, ] -provides-extras = ["test", "build"] +provides-extras = ["test", "build", "release"] [[package]] name = "policyengine-uk" @@ -2501,6 +2505,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, ] +[[package]] +name = "towncrier" +version = "25.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "jinja2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/eb/5bf25a34123698d3bbab39c5bc5375f8f8bcbcc5a136964ade66935b8b9d/towncrier-25.8.0.tar.gz", hash = "sha256:eef16d29f831ad57abb3ae32a0565739866219f1ebfbdd297d32894eb9940eb1", size = 76322, upload-time = "2025-08-30T11:41:55.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/06/8ba22ec32c74ac1be3baa26116e3c28bc0e76a5387476921d20b6fdade11/towncrier-25.8.0-py3-none-any.whl", hash = "sha256:b953d133d98f9aeae9084b56a3563fd2519dfc6ec33f61c9cd2c61ff243fb513", size = 65101, upload-time = "2025-08-30T11:41:53.644Z" }, +] + [[package]] name = "tqdm" version = "4.67.3"