From b58238e365601b48c1f246b63e84fa7ae7ef2d98 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:40:28 +0400 Subject: [PATCH 01/11] Test the changelog fragment check before it exists Each test builds a throwaway repository with this repository's towncrier.toml, commits a base and a pull request, and runs .github/scripts/check-changelog-fragment.sh there. The check must accept only what the release reads: at least one non-empty file directly in changelog.d/ named ..md with a configured type, added by the pull request itself, and no other file in changelog.d/. The cases include policyengine-api #4084's changelog.d/added/4083.md, a pull request that adds no fragment while main has pending ones, an unknown type and an empty file. The proof fixtures hold #4084's fragment as it was and moved into place. These tests fail: towncrier.toml and the check do not exist yet. Co-Authored-By: Claude Opus 5.5 --- .../tests/changelog_release_support.py | 93 +++++ .../tests/test_changelog_fragment_check.py | 359 ++++++++++++++++++ .../bad/changelog.d/added/4083.md | 1 + .../good/changelog.d/4083.added.md | 1 + 4 files changed, 454 insertions(+) create mode 100644 projects/policyengine-simulation-entry/tests/changelog_release_support.py create mode 100644 projects/policyengine-simulation-entry/tests/test_changelog_fragment_check.py create mode 100644 rules/fixtures/sim.process.changelog-fragment/bad/changelog.d/added/4083.md create mode 100644 rules/fixtures/sim.process.changelog-fragment/good/changelog.d/4083.added.md diff --git a/projects/policyengine-simulation-entry/tests/changelog_release_support.py b/projects/policyengine-simulation-entry/tests/changelog_release_support.py new file mode 100644 index 000000000..a416f4384 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/changelog_release_support.py @@ -0,0 +1,93 @@ +"""Build throwaway git repositories to run the changelog scripts in. + +The changelog scripts under ``.github/scripts/`` read ``towncrier.toml``, +``VERSION`` and ``changelog.d/`` from the directory they run in, and ask git +what a pull request adds. Each test builds a repository in ``tmp_path`` with +this repository's ``towncrier.toml``, commits the files it needs, and runs the +real scripts there. Git runs without the user's or the system's configuration, +so local settings such as commit signing can't change a result. +""" + +from __future__ import annotations + +import importlib.util +import os +import shutil +import subprocess +from typing import TYPE_CHECKING + +from workflow_script_support import REPOSITORY_ROOT, SCRIPTS_DIR + +if TYPE_CHECKING: + from pathlib import Path + from types import ModuleType + +FRAGMENTS_MODULE = SCRIPTS_DIR / "changelog_fragments.py" +TOWNCRIER_CONFIG = REPOSITORY_ROOT / "towncrier.toml" +FRAGMENT_TYPES = ("breaking", "added", "changed", "fixed", "removed") + +GIT_ENVIRONMENT = { + **os.environ, + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "Changelog Test", + "GIT_AUTHOR_EMAIL": "changelog-test@example.com", + "GIT_COMMITTER_NAME": "Changelog Test", + "GIT_COMMITTER_EMAIL": "changelog-test@example.com", +} + + +def git(root: Path, *arguments: str) -> str: + """Run git in ``root`` and return what it printed, without the final newline.""" + completed = subprocess.run( + ["git", *arguments], + cwd=root, + env=GIT_ENVIRONMENT, + capture_output=True, + text=True, + check=True, + ) + return completed.stdout.rstrip("\n") + + +def write_files(root: Path, files: dict[str, str]) -> None: + """Write each ``path: text`` pair under ``root``, creating folders as needed.""" + for relative_path, text in files.items(): + path = root / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def commit_all(root: Path, message: str) -> str: + """Commit every change in ``root`` and return the new commit's hash.""" + git(root, "add", "--all") + git(root, "commit", "--quiet", "--allow-empty", "--message", message) + return git(root, "rev-parse", "HEAD") + + +def make_repository(tmp_path: Path, files: dict[str, str] | None = None) -> Path: + """Return a repository on ``main`` with this repository's towncrier.toml. + + Its first commit also holds ``changelog.d/.gitkeep``, a ``README.md`` and + ``files``. + """ + root = tmp_path / "repository" + root.mkdir(parents=True) + git(root, "init", "--quiet", "--initial-branch", "main") + shutil.copyfile(TOWNCRIER_CONFIG, root / "towncrier.toml") + write_files( + root, + {"changelog.d/.gitkeep": "", "README.md": "Base.\n", **(files or {})}, + ) + commit_all(root, "Base") + return root + + +def load_fragments_module() -> ModuleType: + """Load ``.github/scripts/changelog_fragments.py`` as a module in this process.""" + spec = importlib.util.spec_from_file_location( + "changelog_fragments", FRAGMENTS_MODULE + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module diff --git a/projects/policyengine-simulation-entry/tests/test_changelog_fragment_check.py b/projects/policyengine-simulation-entry/tests/test_changelog_fragment_check.py new file mode 100644 index 000000000..cdf4cee97 --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_changelog_fragment_check.py @@ -0,0 +1,359 @@ +"""Tests for the changelog fragment check that every pull request runs. + +Rule sim.process.changelog-fragment: a pull request adds at least one +non-empty file directly in changelog.d/, named ``..md`` with a type +from towncrier.toml, and every file in changelog.d/ is such a fragment, because +those are the only files the release reads. Each test builds a repository with +a base commit and a pull request commit, and runs +.github/scripts/check-changelog-fragment.sh there, as the workflow does. +""" + +from __future__ import annotations + +import shutil +from typing import TYPE_CHECKING + +import pytest +from changelog_release_support import ( + FRAGMENT_TYPES, + GIT_ENVIRONMENT, + commit_all, + git, + load_fragments_module, + make_repository, + write_files, +) +from workflow_script_support import REPOSITORY_ROOT, run_script + +if TYPE_CHECKING: + import subprocess + from pathlib import Path + +CHECK_SCRIPT = "check-changelog-fragment.sh" +PROOF_FIXTURES = ( + REPOSITORY_ROOT / "rules" / "fixtures" / "sim.process.changelog-fragment" +) +FRAGMENT_TEXT = "Required a changelog fragment on every pull request.\n" +NO_FRAGMENT_ADDED = "::error::This pull request adds no changelog fragment." + + +def _open_pull_request( + root: Path, files: dict[str, str], removed: tuple[str, ...] = () +) -> str: + """Commit ``files`` and the removal of ``removed`` on a pull request branch.""" + git(root, "checkout", "--quiet", "-b", "pull-request") + write_files(root, files) + for relative_path in removed: + (root / relative_path).unlink() + return commit_all(root, "Pull request") + + +def _check(root: Path, base: str = "main") -> subprocess.CompletedProcess[str]: + return run_script( + CHECK_SCRIPT, env={**GIT_ENVIRONMENT, "BASE_SHA": base}, cwd=root + ) + + +def _errors(result: subprocess.CompletedProcess[str]) -> list[str]: + return [line for line in result.stdout.splitlines() if line.startswith("::error")] + + +# --- What the check reads from towncrier.toml ------------------------------ + + +def test_the_definition_reads_the_directory_and_types_from_towncrier_toml(): + module = load_fragments_module() + + definition = module.FragmentDefinition.load(REPOSITORY_ROOT) + + assert definition.directory == "changelog.d" + assert definition.types == FRAGMENT_TYPES + + +# --- Pull requests the check accepts ----------------------------------------- + + +@pytest.mark.parametrize( + "fragment", + [ + "changelog.d/982.added.md", + "changelog.d/982.breaking.md", + "changelog.d/982.changed.md", + "changelog.d/982.fixed.md", + "changelog.d/982.removed.md", + "changelog.d/update-policyengine-6.11.1.changed.md", + "changelog.d/towncrier_changelog.added.md", + ], +) +def test_accepts_a_pull_request_that_adds_a_fragment(tmp_path, fragment): + root = make_repository(tmp_path) + _open_pull_request(root, {fragment: FRAGMENT_TEXT, "src/app.py": "pass\n"}) + + result = _check(root) + + assert result.returncode == 0, result.stdout + result.stderr + assert _errors(result) == [] + assert f"{fragment} (" in result.stdout + + +def test_accepts_several_fragments_of_different_types(tmp_path): + root = make_repository(tmp_path) + _open_pull_request( + root, + { + "changelog.d/982.added.md": FRAGMENT_TEXT, + "changelog.d/982.fixed.md": "Fixed the check.\n", + }, + ) + + result = _check(root) + + assert result.returncode == 0, result.stdout + assert "adds 2 changelog fragments" in result.stdout + + +def test_counts_a_misplaced_file_renamed_into_place(tmp_path): + """A rename is a removal and an addition, so moving a file into place counts.""" + root = make_repository(tmp_path, {"changelog.d/added/4083.md": FRAGMENT_TEXT}) + git(root, "checkout", "--quiet", "-b", "pull-request") + git(root, "mv", "changelog.d/added/4083.md", "changelog.d/4083.added.md") + commit_all(root, "Move the fragment into place") + + result = _check(root) + + assert result.returncode == 0, result.stdout + + +# --- Pull requests the check rejects --------------------------------------- + + +def test_rejects_the_subfolder_fragment_of_policyengine_api_4084(tmp_path): + """policyengine-api #4084's check passed this file; its release read nothing.""" + root = make_repository(tmp_path) + _open_pull_request(root, {"changelog.d/added/4083.md": FRAGMENT_TEXT}) + + result = _check(root) + + assert result.returncode == 1 + errors = _errors(result) + assert errors[0].startswith("::error file=changelog.d/added/4083.md::") + assert "is in a folder inside changelog.d/" in errors[0] + assert "Move it to changelog.d/4083.added.md" in errors[0] + assert errors[1].startswith(NO_FRAGMENT_ADDED) + + +def test_rejects_a_pull_request_that_adds_no_fragment(tmp_path): + root = make_repository(tmp_path) + _open_pull_request(root, {"src/app.py": "pass\n"}) + + result = _check(root) + + assert result.returncode == 1 + errors = _errors(result) + assert len(errors) == 1 + assert errors[0].startswith(NO_FRAGMENT_ADDED) + assert "changelog.d/..md" in errors[0] + assert "breaking, added, changed, fixed or removed" in errors[0] + + +def test_fragments_already_on_main_do_not_count(tmp_path): + """policyengine-api's check passed any pull request while main had fragments.""" + root = make_repository(tmp_path, {"changelog.d/981.fixed.md": FRAGMENT_TEXT}) + _open_pull_request(root, {"src/app.py": "pass\n"}) + + result = _check(root) + + assert result.returncode == 1 + assert _errors(result)[0].startswith(NO_FRAGMENT_ADDED) + + +def test_editing_a_fragment_already_on_main_does_not_count(tmp_path): + root = make_repository(tmp_path, {"changelog.d/981.fixed.md": FRAGMENT_TEXT}) + _open_pull_request(root, {"changelog.d/981.fixed.md": "Fixed it better.\n"}) + + result = _check(root) + + assert result.returncode == 1 + assert _errors(result)[0].startswith(NO_FRAGMENT_ADDED) + + +def test_a_fragment_name_outside_changelog_d_does_not_count(tmp_path): + root = make_repository(tmp_path) + _open_pull_request(root, {"docs/changelog.d/982.added.md": FRAGMENT_TEXT}) + + result = _check(root) + + assert result.returncode == 1 + assert _errors(result)[0].startswith(NO_FRAGMENT_ADDED) + + +@pytest.mark.parametrize("fragment_type", ["feature", "Added", "bugfix", "doc"]) +def test_rejects_an_unknown_fragment_type(tmp_path, fragment_type): + fragment = f"changelog.d/982.{fragment_type}.md" + root = make_repository(tmp_path) + _open_pull_request(root, {fragment: FRAGMENT_TEXT}) + + result = _check(root) + + assert result.returncode == 1 + errors = _errors(result) + assert errors[0].startswith(f"::error file={fragment}::") + assert f'has the type "{fragment_type}"' in errors[0] + assert "breaking, added, changed, fixed or removed" in errors[0] + assert errors[1].startswith(NO_FRAGMENT_ADDED) + + +@pytest.mark.parametrize("text", ["", "\n", " \n\t\n"], ids=["empty", "newline", "blank"]) +def test_rejects_an_empty_fragment(tmp_path, text): + root = make_repository(tmp_path) + _open_pull_request(root, {"changelog.d/982.added.md": text}) + + result = _check(root) + + assert result.returncode == 1 + errors = _errors(result) + assert errors[0].startswith("::error file=changelog.d/982.added.md::") + assert "is empty" in errors[0] + + +@pytest.mark.parametrize( + "fragment", + [ + "changelog.d/982.added", + "changelog.d/982.added.txt", + "changelog.d/982.added.1.md", + "changelog.d/982.md", + "changelog.d/.added.md", + "changelog.d/982..added.md", + "changelog.d/982 work.added.md", + "changelog.d/README.md", + ], +) +def test_rejects_other_names(tmp_path, fragment): + root = make_repository(tmp_path) + _open_pull_request(root, {fragment: FRAGMENT_TEXT}) + + result = _check(root) + + assert result.returncode == 1 + errors = _errors(result) + assert errors[0].startswith(f"::error file={fragment}::") + assert errors[-1].startswith(NO_FRAGMENT_ADDED) + + +def test_rejects_an_invalid_file_next_to_a_valid_fragment(tmp_path): + """The release reads every file in changelog.d/, so every one must be valid.""" + root = make_repository(tmp_path) + _open_pull_request( + root, + { + "changelog.d/982.added.md": FRAGMENT_TEXT, + "changelog.d/982.feature.md": FRAGMENT_TEXT, + }, + ) + + result = _check(root) + + assert result.returncode == 1 + assert [error.split("::")[1] for error in _errors(result)] == [ + "error file=changelog.d/982.feature.md" + ] + + +def test_rejects_an_invalid_file_already_on_main(tmp_path): + """The next release would fail on it, so the merged result must not hold it.""" + root = make_repository(tmp_path, {"changelog.d/added/4083.md": FRAGMENT_TEXT}) + _open_pull_request(root, {"changelog.d/982.added.md": FRAGMENT_TEXT}) + + result = _check(root) + + assert result.returncode == 1 + errors = _errors(result) + assert len(errors) == 1 + assert errors[0].startswith("::error file=changelog.d/added/4083.md::") + assert "already on the base branch" in errors[0] + + +# --- The merged result GitHub checks out -------------------------------------- + + +def _merge_into_advanced_main(root: Path, pull_request_files: dict[str, str]) -> str: + """Merge a pull request into main after another one merged a fragment first. + + Return the base commit: main's head before the merge, which the merge + commit has as its first parent, like GitHub's pull request merge ref. + """ + _open_pull_request(root, pull_request_files) + git(root, "checkout", "--quiet", "main") + write_files(root, {"changelog.d/981.fixed.md": "Fixed something else.\n"}) + base = commit_all(root, "Merge pull request #981") + git(root, "merge", "--quiet", "--no-ff", "--message", "Merge", "pull-request") + return base + + +def test_counts_the_fragment_the_pull_request_adds_to_the_merged_result(tmp_path): + root = make_repository(tmp_path) + base = _merge_into_advanced_main(root, {"changelog.d/982.added.md": FRAGMENT_TEXT}) + + result = _check(root, base) + + assert result.returncode == 0, result.stdout + assert "changelog.d/982.added.md (added)" in result.stdout + assert "981" not in result.stdout + + +def test_a_fragment_another_pull_request_merged_first_does_not_count(tmp_path): + root = make_repository(tmp_path) + base = _merge_into_advanced_main(root, {"src/app.py": "pass\n"}) + + result = _check(root, base) + + assert result.returncode == 1 + assert _errors(result)[0].startswith(NO_FRAGMENT_ADDED) + + +def test_requires_the_base_revision(tmp_path): + root = make_repository(tmp_path) + environment = dict(GIT_ENVIRONMENT) + environment.pop("BASE_SHA", None) + + result = run_script(CHECK_SCRIPT, env=environment, cwd=root) + + assert result.returncode != 0 + assert "BASE_SHA is required" in result.stderr + + +def test_fails_when_the_base_revision_is_not_in_the_checkout(tmp_path): + root = make_repository(tmp_path) + _open_pull_request(root, {"changelog.d/982.added.md": FRAGMENT_TEXT}) + + result = _check(root, "0123456789abcdef0123456789abcdef01234567") + + assert result.returncode != 0 + assert "Could not find the merge base" in result.stdout + + +# --- Proof -------------------------------------------------------------------- + + +def test_rule_proof_changelog_fragment(tmp_path): + """The check rejects policyengine-api #4084's fragment and accepts it moved. + + #4084 added changelog.d/added/4083.md. policyengine-api's check accepted + it, and its release, which reads only ..md files directly in + changelog.d/, found no fragment and failed. + """ + results = {} + for fixture in ("bad", "good"): + root = make_repository(tmp_path / fixture) + git(root, "checkout", "--quiet", "-b", "pull-request") + shutil.copytree(PROOF_FIXTURES / fixture, root, dirs_exist_ok=True) + commit_all(root, f"Pull request ({fixture} fixture)") + results[fixture] = _check(root) + + assert results["bad"].returncode == 1 + assert _errors(results["bad"])[0].startswith( + "::error file=changelog.d/added/4083.md::" + ) + assert results["good"].returncode == 0, results["good"].stdout + assert "changelog.d/4083.added.md (added)" in results["good"].stdout diff --git a/rules/fixtures/sim.process.changelog-fragment/bad/changelog.d/added/4083.md b/rules/fixtures/sim.process.changelog-fragment/bad/changelog.d/added/4083.md new file mode 100644 index 000000000..18289a059 --- /dev/null +++ b/rules/fixtures/sim.process.changelog-fragment/bad/changelog.d/added/4083.md @@ -0,0 +1 @@ +`GET /deployment` reports the policyengine.py version the API serves and when it went live; the candidate deploy keeps that date across redeploys of the same version. diff --git a/rules/fixtures/sim.process.changelog-fragment/good/changelog.d/4083.added.md b/rules/fixtures/sim.process.changelog-fragment/good/changelog.d/4083.added.md new file mode 100644 index 000000000..18289a059 --- /dev/null +++ b/rules/fixtures/sim.process.changelog-fragment/good/changelog.d/4083.added.md @@ -0,0 +1 @@ +`GET /deployment` reports the policyengine.py version the API serves and when it went live; the candidate deploy keeps that date across redeploys of the same version. From 9784eee470988a44b2c81bc2989701a091a4d8d1 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:43:24 +0400 Subject: [PATCH 02/11] Add towncrier.toml and the changelog fragment check towncrier.toml configures Towncrier at the repository root, which has no pyproject.toml, with policyengine-api's five fragment types. Its ignore list makes Towncrier stop at any file in changelog.d/ it can't read, instead of skipping it. .github/scripts/changelog_fragments.py holds the one definition of a valid fragment: a non-empty UTF-8 file directly in changelog.d/ named ..md, with a type towncrier.toml lists. It reads the directory and the types from towncrier.toml. Its check-pull-request command fails unless the pull request adds a fragment and every file in changelog.d/ is one, and its messages say how to fix each file; for policyengine-api #4084's changelog.d/added/4083.md it names changelog.d/4083.added.md. check-changelog-fragment.sh passes it the files the pull request adds relative to its merge base, so fragments already on main don't count. The test helper registers the module in sys.modules before running it, which its dataclasses need. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/changelog_fragments.py | 311 ++++++++++++++++++ .github/scripts/check-changelog-fragment.sh | 32 ++ changelog.d/.gitkeep | 0 .../tests/changelog_release_support.py | 8 +- .../tests/test_changelog_fragment_check.py | 8 +- towncrier.toml | 48 +++ 6 files changed, 402 insertions(+), 5 deletions(-) create mode 100644 .github/scripts/changelog_fragments.py create mode 100755 .github/scripts/check-changelog-fragment.sh create mode 100644 changelog.d/.gitkeep create mode 100644 towncrier.toml diff --git a/.github/scripts/changelog_fragments.py b/.github/scripts/changelog_fragments.py new file mode 100644 index 000000000..b825c1dc8 --- /dev/null +++ b/.github/scripts/changelog_fragments.py @@ -0,0 +1,311 @@ +"""Changelog fragments: what one is, and the check every pull request runs. + +Rule sim.process.changelog-fragment. Every pull request adds at least one +changelog fragment, and after a merge to main Towncrier builds the pending +fragments into CHANGELOG.md. Everything here reads fragments through +``FragmentDefinition``, so what the check accepts is exactly what the release +reads. + +A fragment is a file directly in changelog.d/ (``directory`` in towncrier.toml) +named ``..md``, where: + +- ```` is groups of letters and digits joined by single dots, hyphens or + underscores, such as ``982`` or ``update-policyengine-6.11.1``; +- ```` is one of the fragment types towncrier.toml lists; +- its text, read as UTF-8, is not empty or only whitespace. + +``.gitkeep``, which keeps changelog.d/ in git once a release has removed every +fragment, is the only other file allowed there. Towncrier skips a file in a +folder or with an unknown type, and writes an empty heading for an empty +fragment, so the release would lose anything else. policyengine-api #4084 +added changelog.d/added/4083.md: its check accepted the file, and its release +found no fragment. + +Command, run from the repository root: + + check-pull-request read the paths a pull request adds, NUL-separated on + stdin, as check-changelog-fragment.sh passes them, and + exit 1 unless one of them is a fragment and every file + in changelog.d/ is one +""" + +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +import tomllib + +RULE_ID = "sim.process.changelog-fragment" +TOWNCRIER_CONFIG = "towncrier.toml" +# Keeps changelog.d/ in git once a release has removed every fragment. +KEEP_FILE = ".gitkeep" +FRAGMENT_FILE_NAME = re.compile( + r"(?P[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*)\.(?P[^.]+)\.md" +) +FRAGMENT_NAME = re.compile(r"[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*") +DOCUMENTATION = "docs/engineering/skills/github-prs.md#changelog-fragments" + + +class ConfigurationError(Exception): + """towncrier.toml does not say where fragments live or which types they have.""" + + +@dataclass(frozen=True) +class Fragment: + """A changelog fragment the release reads: its path, name and type.""" + + path: str + name: str + type: str + + +@dataclass(frozen=True) +class Scan: + """What changelog.d/ holds: its fragments, and why each other file isn't one. + + ``problems`` maps a path from the repository root to its reason. + """ + + fragments: tuple[Fragment, ...] + problems: dict[str, str] + + +def _listed(names: tuple[str, ...]) -> str: + """Return ``names`` as "a, b or c".""" + if len(names) == 1: + return names[0] + return f"{', '.join(names[:-1])} or {names[-1]}" + + +@dataclass(frozen=True) +class FragmentDefinition: + """Where fragments live and which types they may have, read from towncrier.toml.""" + + directory: str + types: tuple[str, ...] + + @classmethod + def load(cls, root: Path) -> FragmentDefinition: + """Read the fragment directory and types from ``root``'s towncrier.toml.""" + config_path = root / TOWNCRIER_CONFIG + try: + config = tomllib.loads(config_path.read_text(encoding="utf-8")) + towncrier = config["tool"]["towncrier"] + directory = towncrier["directory"] + types = tuple(entry["directory"] for entry in towncrier["type"]) + except (OSError, tomllib.TOMLDecodeError, KeyError, TypeError) as error: + msg = ( + f"Could not read the fragment directory and types from " + f"{config_path}: {error!r}" + ) + raise ConfigurationError(msg) from error + if not isinstance(directory, str) or not FRAGMENT_NAME.fullmatch(directory): + msg = ( + f"{TOWNCRIER_CONFIG} names a fragment directory that is not one " + f"folder: {directory!r}" + ) + raise ConfigurationError(msg) + if not types or len(set(types)) != len(types): + msg = f"{TOWNCRIER_CONFIG} must list each fragment type once: {types!r}" + raise ConfigurationError(msg) + return cls(directory=directory, types=types) + + def how_to_add(self) -> str: + """Return the sentence that says how to add a fragment.""" + return ( + f"Add a file {self.directory}/..md, directly in " + f"{self.directory}/, that says in a sentence or two what changed for " + f"people who use or run the simulation API. is " + f"{_listed(self.types)}, and is the issue number or another " + f"label made of letters, digits, dots, hyphens and underscores. See " + f"{DOCUMENTATION} (rule {RULE_ID})." + ) + + def _suggested_path(self, path: PurePosixPath) -> str | None: + """Return where a fragment filed in a type's folder belongs, if it is clear. + + ``changelog.d/added/4083.md`` belongs at ``changelog.d/4083.added.md``. + """ + folders = path.relative_to(self.directory).parts[:-1] + name = path.name.removesuffix(".md") + if ( + len(folders) == 1 + and folders[0] in self.types + and FRAGMENT_NAME.fullmatch(name) + ): + return f"{self.directory}/{name}.{folders[0]}.md" + return None + + def _nested_problem(self, path: PurePosixPath) -> str: + """Return why a file in a folder inside the fragment directory isn't read.""" + problem = ( + f"{path} is in a folder inside {self.directory}/. Towncrier and the " + f"version bump read only files directly in {self.directory}/, so the " + f"release would leave this one out." + ) + suggested = self._suggested_path(path) + if suggested: + return f"{problem} Move it to {suggested}." + return f"{problem} Move it to {self.directory}/..md." + + def _name_problem(self, path: str) -> str | None: + """Return why ``path`` is not where and how a fragment is named, or None.""" + location = PurePosixPath(path) + if location.parent != PurePosixPath(self.directory): + return self._nested_problem(location) + match = FRAGMENT_FILE_NAME.fullmatch(location.name) + if not match: + return ( + f"{path} is not named ..md, so the release can't read " + f"it. Rename it, or move it out of {self.directory}/." + ) + fragment_type = match.group("type") + if fragment_type not in self.types: + return ( + f'{path} has the type "{fragment_type}", which is not one of the ' + f"types towncrier.toml lists: {_listed(self.types)}. Rename it." + ) + return None + + @staticmethod + def _text_problem(path: str, file: Path) -> str | None: + """Return why ``file`` holds no fragment text, or None.""" + if file.is_symlink() or not file.is_file(): + return f"{path} is not a regular file; a fragment is a text file." + try: + text = file.read_text(encoding="utf-8") + except UnicodeDecodeError: + return f"{path} is not UTF-8 text." + if not text.strip(): + return ( + f"{path} is empty. Write what changed in it: Towncrier would " + f"publish an empty entry." + ) + return None + + def problem_with(self, path: str, file: Path) -> str | None: + """Return why ``file``, at ``path`` from the repository root, is not a fragment. + + Return None when it is one. + """ + return self._name_problem(path) or self._text_problem(path, file) + + def scan(self, root: Path) -> Scan: + """Return the fragments in ``root``'s fragment directory, and every other file. + + Every file in a folder inside the directory is a problem. An empty + folder is not: git can't commit one, so no checkout the release builds + from has it. + """ + directory = root / self.directory + fragments: list[Fragment] = [] + problems: dict[str, str] = {} + if not directory.is_dir(): + return Scan(fragments=(), problems=problems) + for entry in sorted(directory.iterdir()): + path = f"{self.directory}/{entry.name}" + if entry.name == KEEP_FILE and entry.is_file(): + continue + if entry.is_dir() and not entry.is_symlink(): + nested_files = sorted( + nested for nested in entry.rglob("*") if not nested.is_dir() + ) + for nested_file in nested_files: + nested_path = nested_file.relative_to(root).as_posix() + problems[nested_path] = self._nested_problem( + PurePosixPath(nested_path) + ) + continue + problem = self.problem_with(path, entry) + if problem is None: + match = FRAGMENT_FILE_NAME.fullmatch(entry.name) + fragments.append( + Fragment( + path=path, name=match.group("name"), type=match.group("type") + ) + ) + else: + problems[path] = problem + return Scan(fragments=tuple(fragments), problems=problems) + + +def _annotation_text(message: str) -> str: + """Escape ``message`` so the runner reads it as one annotation message.""" + return message.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + + +def check_pull_request(root: Path, added_paths: set[str]) -> int: + """Check the merged result in ``root`` of a pull request that adds ``added_paths``. + + Print one error annotation for each file in the fragment directory that is + not a fragment, and one if the pull request adds no fragment; return the + exit status. + """ + definition = FragmentDefinition.load(root) + scan = definition.scan(root) + for path, problem in scan.problems.items(): + message = problem + if path not in added_paths: + message += ( + " It is already on the base branch, and the next release can't " + "be built until it is fixed." + ) + print(f"::error file={path}::{_annotation_text(message)}") + + added = [fragment for fragment in scan.fragments if fragment.path in added_paths] + if not added: + message = ( + "This pull request adds no changelog fragment. Fragments already on " + f"the base branch belong to the pull requests that added them. " + f"{definition.how_to_add()}" + ) + print(f"::error::{_annotation_text(message)}") + if scan.problems or not added: + return 1 + + listed = ", ".join(f"{fragment.path} ({fragment.type})" for fragment in added) + count = ( + "1 changelog fragment" + if len(added) == 1 + else f"{len(added)} changelog fragments" + ) + print(f"This pull request adds {count}: {listed}.") + return 0 + + +def _paths_from_stdin() -> set[str]: + """Return the NUL-separated paths on stdin, as ``git diff -z`` prints them.""" + data = sys.stdin.buffer.read() + return { + path.decode("utf-8", errors="surrogateescape") + for path in data.split(b"\0") + if path + } + + +def main(arguments: list[str] | None = None) -> int: + """Run the command named in ``arguments``; return the exit status.""" + parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser( + "check-pull-request", + help="check the merged result of a pull request; its added paths on stdin", + ) + options = parser.parse_args(arguments) + root = Path.cwd() + try: + if options.command == "check-pull-request": + return check_pull_request(root, _paths_from_stdin()) + except ConfigurationError as error: + print(f"::error::{_annotation_text(str(error))}") + return 1 + parser.error(f"unknown command {options.command}") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/check-changelog-fragment.sh b/.github/scripts/check-changelog-fragment.sh new file mode 100755 index 000000000..3379716e1 --- /dev/null +++ b/.github/scripts/check-changelog-fragment.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +# Require a changelog fragment in a pull request (rule +# sim.process.changelog-fragment). The "Changelog fragment" job in pr.yml runs +# this on the merged result that GitHub checks out. +# +# It lists the files the pull request adds relative to its base and passes +# them to changelog_fragments.py, which fails unless one of them is a fragment +# and every file in changelog.d/ is one. A rename counts as a removal and an +# addition, so moving a misplaced file into place counts. Fragments already on +# the base branch belong to the pull requests that merged them, and don't +# count. +# +# Run from the repository root. +# +# Environment: +# BASE_SHA the commit the pull request merges into +# (github.event.pull_request.base.sha); locally, for example, +# BASE_SHA=origin/main + +set -euo pipefail + +base_revision="${BASE_SHA:?BASE_SHA is required: the commit the pull request merges into, such as origin/main}" +script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if ! merge_base="$(git merge-base "${base_revision}" HEAD)"; then + echo "::error::Could not find the merge base of ${base_revision} and HEAD; the checkout needs the base branch's history (fetch-depth: 0)." + exit 1 +fi + +git diff --no-renames --diff-filter=A --name-only -z "${merge_base}" HEAD \ + | python3 "${script_directory}/changelog_fragments.py" check-pull-request diff --git a/changelog.d/.gitkeep b/changelog.d/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/projects/policyengine-simulation-entry/tests/changelog_release_support.py b/projects/policyengine-simulation-entry/tests/changelog_release_support.py index a416f4384..0a080ac10 100644 --- a/projects/policyengine-simulation-entry/tests/changelog_release_support.py +++ b/projects/policyengine-simulation-entry/tests/changelog_release_support.py @@ -14,6 +14,7 @@ import os import shutil import subprocess +import sys from typing import TYPE_CHECKING from workflow_script_support import REPOSITORY_ROOT, SCRIPTS_DIR @@ -84,10 +85,15 @@ def make_repository(tmp_path: Path, files: dict[str, str] | None = None) -> Path def load_fragments_module() -> ModuleType: - """Load ``.github/scripts/changelog_fragments.py`` as a module in this process.""" + """Load ``.github/scripts/changelog_fragments.py`` as a module in this process. + + The module is registered in ``sys.modules`` first, because its dataclasses + look their module up there while they are created. + """ spec = importlib.util.spec_from_file_location( "changelog_fragments", FRAGMENTS_MODULE ) module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module spec.loader.exec_module(module) return module diff --git a/projects/policyengine-simulation-entry/tests/test_changelog_fragment_check.py b/projects/policyengine-simulation-entry/tests/test_changelog_fragment_check.py index cdf4cee97..e84bd226f 100644 --- a/projects/policyengine-simulation-entry/tests/test_changelog_fragment_check.py +++ b/projects/policyengine-simulation-entry/tests/test_changelog_fragment_check.py @@ -49,9 +49,7 @@ def _open_pull_request( def _check(root: Path, base: str = "main") -> subprocess.CompletedProcess[str]: - return run_script( - CHECK_SCRIPT, env={**GIT_ENVIRONMENT, "BASE_SHA": base}, cwd=root - ) + return run_script(CHECK_SCRIPT, env={**GIT_ENVIRONMENT, "BASE_SHA": base}, cwd=root) def _errors(result: subprocess.CompletedProcess[str]) -> list[str]: @@ -203,7 +201,9 @@ def test_rejects_an_unknown_fragment_type(tmp_path, fragment_type): assert errors[1].startswith(NO_FRAGMENT_ADDED) -@pytest.mark.parametrize("text", ["", "\n", " \n\t\n"], ids=["empty", "newline", "blank"]) +@pytest.mark.parametrize( + "text", ["", "\n", " \n\t\n"], ids=["empty", "newline", "blank"] +) def test_rejects_an_empty_fragment(tmp_path, text): root = make_repository(tmp_path) _open_pull_request(root, {"changelog.d/982.added.md": text}) diff --git a/towncrier.toml b/towncrier.toml new file mode 100644 index 000000000..524724997 --- /dev/null +++ b/towncrier.toml @@ -0,0 +1,48 @@ +# Towncrier builds CHANGELOG.md from the fragments in changelog.d/, one or more +# files per pull request, when the versioning job in +# .github/workflows/simulation-deploy.yml releases a merge to main. See +# docs/engineering/skills/github-prs.md#changelog-fragments (rule +# sim.process.changelog-fragment). +# +# .github/scripts/changelog_fragments.py reads `directory` and the fragment +# types below, so the pull request check, the version bump and Towncrier use +# one list. A type's `directory` is the type's name in a fragment's file name: +# changelog.d/..md. Adding or removing a type also needs its +# version bump in changelog_fragments.py, which refuses to run otherwise. + +[tool.towncrier] +directory = "changelog.d" +filename = "CHANGELOG.md" +start_string = "\n" +title_format = "## [{version}] - {project_date}" +# Fragment names are issue numbers or other labels; entries cite none of them. +issue_format = "" +underlines = ["", "", ""] +# With an ignore list, Towncrier stops at any other file in changelog.d/ instead +# of skipping it. +ignore = [".gitkeep"] + +[[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 From f39fe3ebf3328348c1a8b33491e4f121ba49e6c9 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:44:32 +0400 Subject: [PATCH 03/11] Test the release version bump and changelog build before they exist changelog_fragments.py bump must raise VERSION by the largest part the pending fragments' types call for, as policyengine-api's bump_version.py does: breaking makes a major release, added or removed a minor one, changed or fixed a patch. It must read fragments by the check's definition and refuse to run while changelog.d/ holds no fragment or any file it can't read, so a fragment like policyengine-api #4084's stops the release with a reason instead of being skipped. A fragment type without a bump must stop both the check and the bump. build-changelog.sh must run the bump, then Towncrier from a locked .github/towncrier project with the new version, and with --draft print the next section without changing a file. make changelog and make changelog-draft run it. These tests fail: the bump, VERSION, the build script and the Towncrier project do not exist yet. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_release_version_bump.py | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 projects/policyengine-simulation-entry/tests/test_release_version_bump.py diff --git a/projects/policyengine-simulation-entry/tests/test_release_version_bump.py b/projects/policyengine-simulation-entry/tests/test_release_version_bump.py new file mode 100644 index 000000000..e3767238a --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_release_version_bump.py @@ -0,0 +1,313 @@ +"""Tests for the version bump and the changelog build that release a merge to main. + +After a merge to main, .github/scripts/build-changelog.sh raises VERSION by +the largest bump the pending fragments' types call for (``changelog_fragments.py +bump``), then Towncrier, from the locked environment in .github/towncrier, +writes their CHANGELOG.md section. The bump reads fragments by the same +definition as the pull request check, so it refuses to run while changelog.d/ +holds anything else. Tests of the bump run it in a throwaway repository; +tests of the build script put a fake ``uv`` first on PATH, so no test runs +Towncrier. +""" + +from __future__ import annotations + +import subprocess +import sys +from typing import TYPE_CHECKING + +import pytest +import tomllib +from changelog_release_support import ( + FRAGMENT_TYPES, + FRAGMENTS_MODULE, + TOWNCRIER_CONFIG, + load_fragments_module, + make_repository, +) +from workflow_script_support import REPOSITORY_ROOT, FakeCommands, run_script + +if TYPE_CHECKING: + from pathlib import Path + +BUILD_SCRIPT = "build-changelog.sh" +TOWNCRIER_PROJECT = REPOSITORY_ROOT / ".github" / "towncrier" +FRAGMENT_TEXT = "Changed something people notice.\n" +# Records each call's VERSION, so a test can tell the bump ran before Towncrier. +FAKE_UV = 'cat VERSION >> "$FAKE_UV_SAW_VERSION"\n' + + +def _repository( + tmp_path: Path, fragments: dict[str, str], version: str = "0.1.0" +) -> Path: + return make_repository(tmp_path, {"VERSION": f"{version}\n", **fragments}) + + +def _run_fragments_command( + root: Path, command: str +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(FRAGMENTS_MODULE), command], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + + +def _version(root: Path) -> str: + return (root / "VERSION").read_text(encoding="utf-8") + + +# --- The bump ----------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("version", "fragment_types", "expected"), + [ + ("0.1.0", ["fixed"], "0.1.1"), + ("0.1.0", ["changed"], "0.1.1"), + ("0.1.0", ["added"], "0.2.0"), + ("0.1.0", ["removed"], "0.2.0"), + ("0.1.0", ["breaking"], "1.0.0"), + ("0.1.0", ["fixed", "added", "changed"], "0.2.0"), + ("0.1.0", ["added", "breaking", "fixed"], "1.0.0"), + ("1.4.2", ["added"], "1.5.0"), + ("1.4.2", ["breaking"], "2.0.0"), + ("1.4.9", ["fixed"], "1.4.10"), + ], +) +def test_the_bump_raises_the_largest_part_the_fragments_call_for( + tmp_path, version, fragment_types, expected +): + fragments = { + f"changelog.d/98{index}.{fragment_type}.md": FRAGMENT_TEXT + for index, fragment_type in enumerate(fragment_types) + } + root = _repository(tmp_path, fragments, version) + + result = _run_fragments_command(root, "bump") + + assert result.returncode == 0, result.stdout + result.stderr + assert _version(root) == f"{expected}\n" + assert f"VERSION: {version} -> {expected}" in result.stdout + # Towncrier removes the fragments it builds; the bump leaves them. + assert all((root / path).exists() for path in fragments) + + +def test_next_version_prints_the_bump_and_changes_nothing(tmp_path): + root = _repository(tmp_path, {"changelog.d/982.added.md": FRAGMENT_TEXT}) + + result = _run_fragments_command(root, "next-version") + + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout == "0.2.0\n" + assert _version(root) == "0.1.0\n" + + +def test_the_bump_refuses_to_release_without_fragments(tmp_path): + root = _repository(tmp_path, {}) + + result = _run_fragments_command(root, "bump") + + assert result.returncode == 1 + assert result.stdout.startswith("::error::changelog.d/ holds no fragments") + assert _version(root) == "0.1.0\n" + + +def test_the_bump_refuses_while_changelog_d_holds_a_file_it_cannot_read(tmp_path): + """policyengine-api #4084's fragment would have stopped the release, saying why.""" + root = _repository( + tmp_path, + { + "changelog.d/982.added.md": FRAGMENT_TEXT, + "changelog.d/added/4083.md": FRAGMENT_TEXT, + }, + ) + + result = _run_fragments_command(root, "bump") + + assert result.returncode == 1 + assert "::error file=changelog.d/added/4083.md::" in result.stdout + assert "Move it to changelog.d/4083.added.md" in result.stdout + assert _version(root) == "0.1.0\n" + + +@pytest.mark.parametrize("version", ["0.1", "v0.1.0", "01.1.0", "0.1.0-rc1", ""]) +def test_the_bump_refuses_a_malformed_version(tmp_path, version): + root = _repository(tmp_path, {"changelog.d/982.fixed.md": FRAGMENT_TEXT}, version) + + result = _run_fragments_command(root, "bump") + + assert result.returncode == 1 + assert "VERSION must hold one version" in result.stdout + assert _version(root) == f"{version}\n" + + +def test_the_bump_refuses_without_a_version_file(tmp_path): + root = make_repository(tmp_path, {"changelog.d/982.fixed.md": FRAGMENT_TEXT}) + + result = _run_fragments_command(root, "bump") + + assert result.returncode == 1 + assert "VERSION must hold one version" in result.stdout + + +def test_every_fragment_type_has_a_bump(): + module = load_fragments_module() + + assert tuple(module.BUMP_BY_TYPE) == FRAGMENT_TYPES + assert module.BUMP_BY_TYPE == { + "breaking": "major", + "added": "minor", + "changed": "patch", + "fixed": "patch", + "removed": "minor", + } + + +def test_a_type_without_a_bump_stops_the_check_and_the_bump(tmp_path): + """Both read towncrier.toml through one definition, so neither can drift.""" + root = _repository(tmp_path, {"changelog.d/982.security.md": FRAGMENT_TEXT}) + config = (root / "towncrier.toml").read_text(encoding="utf-8") + (root / "towncrier.toml").write_text( + config + + '\n[[tool.towncrier.type]]\ndirectory = "security"\nname = "Security"\n' + "showcontent = true\n", + encoding="utf-8", + ) + module = load_fragments_module() + + with pytest.raises(module.ConfigurationError, match='"security"'): + module.FragmentDefinition.load(root) + result = _run_fragments_command(root, "bump") + assert result.returncode == 1 + assert '"security"' in result.stdout + + +# --- build-changelog.sh ------------------------------------------------------- + + +@pytest.fixture +def fakes(tmp_path: Path) -> FakeCommands: + return FakeCommands.create(tmp_path) + + +def _build(fakes: FakeCommands, root: Path, *arguments: str): + fakes.add("uv", FAKE_UV) + seen_versions = root.parent / "uv-saw-version" + result = run_script( + BUILD_SCRIPT, + *arguments, + env=fakes.env(FAKE_UV_SAW_VERSION=str(seen_versions)), + cwd=root, + ) + seen = seen_versions.read_text(encoding="utf-8") if seen_versions.exists() else "" + return result, seen + + +def test_the_build_bumps_the_version_then_runs_towncrier_from_the_lock(fakes, tmp_path): + root = _repository(tmp_path, {"changelog.d/982.added.md": FRAGMENT_TEXT}) + + result, version_towncrier_saw = _build(fakes, root) + + assert result.returncode == 0, result.stdout + result.stderr + assert _version(root) == "0.2.0\n" + assert version_towncrier_saw == "0.2.0\n" + assert fakes.calls() == [ + [ + "uv", + "run", + "--frozen", + "--project", + ".github/towncrier", + "towncrier", + "build", + "--yes", + "--version", + "0.2.0", + ] + ] + + +def test_the_draft_shows_the_next_release_and_changes_nothing(fakes, tmp_path): + root = _repository(tmp_path, {"changelog.d/982.fixed.md": FRAGMENT_TEXT}) + + result, _ = _build(fakes, root, "--draft") + + assert result.returncode == 0, result.stdout + result.stderr + assert _version(root) == "0.1.0\n" + assert fakes.calls() == [ + [ + "uv", + "run", + "--frozen", + "--project", + ".github/towncrier", + "towncrier", + "build", + "--draft", + "--version", + "0.1.1", + ] + ] + + +def test_the_build_runs_no_towncrier_when_the_bump_refuses(fakes, tmp_path): + root = _repository(tmp_path, {"changelog.d/added/4083.md": FRAGMENT_TEXT}) + + result, _ = _build(fakes, root) + + assert result.returncode == 1 + assert "changelog.d/added/4083.md" in result.stdout + assert fakes.calls() == [] + assert _version(root) == "0.1.0\n" + + +def test_the_build_rejects_an_unknown_argument(fakes, tmp_path): + root = _repository(tmp_path, {"changelog.d/982.fixed.md": FRAGMENT_TEXT}) + + result, _ = _build(fakes, root, "--publish") + + assert result.returncode == 2 + assert "Usage: build-changelog.sh [--draft]" in result.stderr + assert fakes.calls() == [] + + +# --- Where Towncrier and the version come from ---------------------------------- + + +def test_towncrier_runs_from_a_locked_project_of_its_own(): + project = tomllib.loads( + (TOWNCRIER_PROJECT / "pyproject.toml").read_text(encoding="utf-8") + ) + lock = tomllib.loads((TOWNCRIER_PROJECT / "uv.lock").read_text(encoding="utf-8")) + + assert project["tool"]["uv"]["package"] is False + assert [ + requirement.split(">", 1)[0].split("=", 1)[0].strip() + for requirement in project["project"]["dependencies"] + ] == ["towncrier"] + assert "towncrier" in {package["name"] for package in lock["package"]} + + +def test_the_repository_has_one_release_version(): + module = load_fragments_module() + version = (REPOSITORY_ROOT / "VERSION").read_text(encoding="utf-8") + + assert module.VERSION_PATTERN.fullmatch(version.removesuffix("\n")) + assert ( + "version" + not in tomllib.loads(TOWNCRIER_CONFIG.read_text(encoding="utf-8"))["tool"][ + "towncrier" + ] + ) + + +def test_make_changelog_runs_the_release_build(): + makefile = (REPOSITORY_ROOT / "Makefile").read_text(encoding="utf-8") + + assert "\nchangelog:\n\t.github/scripts/build-changelog.sh\n" in makefile + assert "\nchangelog-draft:\n\t.github/scripts/build-changelog.sh --draft\n" in ( + makefile + ) From 3ef284117d12700d3935f74bfdac07e10fe29c70 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:47:08 +0400 Subject: [PATCH 04/11] Add the release version bump and the changelog build VERSION holds the repository's release version, 0.1.0, the version every package already declares. changelog_fragments.py bump raises it by the largest part the pending fragments' types call for: breaking a major release, added or removed a minor one, changed or fixed a patch. It reads fragments by the check's definition and refuses to run while changelog.d/ holds no fragment or anything else, naming each file. Its BUMP_BY_TYPE must cover exactly the types towncrier.toml lists, or the check and the bump both refuse to run. build-changelog.sh runs the bump, then Towncrier with the new version from .github/towncrier, a project of its own whose uv.lock pins Towncrier 26.9.0, so the release installs from a lock like every other environment. make update relocks it with the other projects. make changelog builds a release locally and make changelog-draft prints the next one without changing a file. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/build-changelog.sh | 38 ++++++ .github/scripts/changelog_fragments.py | 176 +++++++++++++++++++++++-- .github/towncrier/pyproject.toml | 16 +++ .github/towncrier/uv.lock | 76 +++++++++++ Makefile | 15 ++- VERSION | 1 + 6 files changed, 311 insertions(+), 11 deletions(-) create mode 100755 .github/scripts/build-changelog.sh create mode 100644 .github/towncrier/pyproject.toml create mode 100644 .github/towncrier/uv.lock create mode 100644 VERSION diff --git a/.github/scripts/build-changelog.sh b/.github/scripts/build-changelog.sh new file mode 100755 index 000000000..02ae971cf --- /dev/null +++ b/.github/scripts/build-changelog.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +# Build the next release: raise VERSION from the pending changelog fragments, +# then let Towncrier write their section of CHANGELOG.md and remove them with +# git rm. The versioning job in simulation-deploy.yml runs this after a merge +# to main and commits the result with commit-release.sh; make changelog runs +# it locally. +# +# build-changelog.sh build the release +# build-changelog.sh --draft print the section the next release would add, +# and change no file +# +# changelog_fragments.py refuses to bump unless changelog.d/ holds at least +# one fragment and nothing else, so Towncrier never skips a file. Towncrier +# runs from the locked environment in .github/towncrier (its uv.lock). +# +# Run from the repository root, with Python 3.11 or later as python3. + +set -euo pipefail + +script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +fragments="${script_directory}/changelog_fragments.py" + +case "${1:-}" in + "") + python3 "${fragments}" bump + read -r version < VERSION + uv run --frozen --project .github/towncrier towncrier build --yes --version "${version}" + ;; + --draft) + next_version="$(python3 "${fragments}" next-version)" + uv run --frozen --project .github/towncrier towncrier build --draft --version "${next_version}" + ;; + *) + echo "Usage: build-changelog.sh [--draft]" >&2 + exit 2 + ;; +esac diff --git a/.github/scripts/changelog_fragments.py b/.github/scripts/changelog_fragments.py index b825c1dc8..3a67f14b9 100644 --- a/.github/scripts/changelog_fragments.py +++ b/.github/scripts/changelog_fragments.py @@ -1,10 +1,11 @@ -"""Changelog fragments: what one is, and the check every pull request runs. +"""Changelog fragments: what one is, the pull request check and the version bump. Rule sim.process.changelog-fragment. Every pull request adds at least one -changelog fragment, and after a merge to main Towncrier builds the pending -fragments into CHANGELOG.md. Everything here reads fragments through -``FragmentDefinition``, so what the check accepts is exactly what the release -reads. +changelog fragment. After a merge to main, the versioning job in +simulation-deploy.yml raises VERSION from the pending fragments and Towncrier +builds them into CHANGELOG.md. The check and the bump both read fragments +through ``FragmentDefinition``, so what the check accepts is exactly what the +release reads. A fragment is a file directly in changelog.d/ (``directory`` in towncrier.toml) named ``..md``, where: @@ -21,12 +22,22 @@ added changelog.d/added/4083.md: its check accepted the file, and its release found no fragment. -Command, run from the repository root: +A fragment's type sets the version bump (``BUMP_BY_TYPE``): breaking raises +the major version, added and removed the minor version, and changed and fixed +the patch version. A release raises the largest part any pending fragment +calls for and resets the parts after it, so 0.1.3 with an added and a fixed +fragment becomes 0.2.0. + +Commands, run from the repository root: check-pull-request read the paths a pull request adds, NUL-separated on stdin, as check-changelog-fragment.sh passes them, and exit 1 unless one of them is a fragment and every file in changelog.d/ is one + bump write the next version to VERSION; exit 1 unless + changelog.d/ holds at least one fragment and nothing + else (build-changelog.sh runs it) + next-version print the version bump would write, changing nothing """ from __future__ import annotations @@ -34,6 +45,7 @@ import argparse import re import sys +from collections import Counter from dataclasses import dataclass from pathlib import Path, PurePosixPath @@ -48,10 +60,39 @@ ) FRAGMENT_NAME = re.compile(r"[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*") DOCUMENTATION = "docs/engineering/skills/github-prs.md#changelog-fragments" +# The repository's release version: MAJOR.MINOR.PATCH on one line. +VERSION_FILE = "VERSION" +VERSION_PATTERN = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)") +# The part of VERSION each fragment type raises, in towncrier.toml's order. +# FragmentDefinition.load refuses a towncrier.toml whose types differ. +BUMP_BY_TYPE = { + "breaking": "major", + "added": "minor", + "changed": "patch", + "fixed": "patch", + "removed": "minor", +} +# Version parts from the smallest bump to the largest. +BUMP_SIZES = ("patch", "minor", "major") class ConfigurationError(Exception): - """towncrier.toml does not say where fragments live or which types they have.""" + """towncrier.toml does not say where fragments live or which types they have. + + Also raised when its types are not the ones ``BUMP_BY_TYPE`` has a bump for. + """ + + +class ReleaseError(Exception): + """The pending fragments or VERSION can't make a release. + + ``problems`` maps each file in changelog.d/ that is not a fragment to why. + """ + + def __init__(self, message: str, problems: dict[str, str] | None = None) -> None: + """Keep ``message`` as the error's text, and the files that stop the release.""" + super().__init__(message) + self.problems = problems or {} @dataclass(frozen=True) @@ -74,6 +115,11 @@ class Scan: problems: dict[str, str] +def _quoted(names: tuple[str, ...]) -> str: + """Return ``names`` in double quotes, comma-separated, or "none".""" + return ", ".join(f'"{name}"' for name in names) or "none" + + def _listed(names: tuple[str, ...]) -> str: """Return ``names`` as "a, b or c".""" if len(names) == 1: @@ -112,6 +158,17 @@ def load(cls, root: Path) -> FragmentDefinition: if not types or len(set(types)) != len(types): msg = f"{TOWNCRIER_CONFIG} must list each fragment type once: {types!r}" raise ConfigurationError(msg) + without_bump = [name for name in types if name not in BUMP_BY_TYPE] + if without_bump or len(types) != len(BUMP_BY_TYPE): + unlisted = [name for name in BUMP_BY_TYPE if name not in types] + msg = ( + f"{TOWNCRIER_CONFIG} lists the fragment types {_quoted(types)}, but " + f"BUMP_BY_TYPE in changelog_fragments.py has a version bump for " + f"{_quoted(tuple(BUMP_BY_TYPE))}. Types with no bump: " + f"{_quoted(tuple(without_bump))}; bumps with no type: " + f"{_quoted(tuple(unlisted))}. Change both together." + ) + raise ConfigurationError(msg) return cls(directory=directory, types=types) def how_to_add(self) -> str: @@ -287,6 +344,98 @@ def _paths_from_stdin() -> set[str]: } +@dataclass(frozen=True) +class Release: + """The next release: the version it raises and the fragments it builds.""" + + current_version: str + version: str + bump: str + fragments: tuple[Fragment, ...] + + def summary(self) -> str: + """Return one line that says what the release raises, and why.""" + counts = Counter(fragment.type for fragment in self.fragments) + listed = ", ".join( + f"{counts[fragment_type]} {fragment_type}" + for fragment_type in BUMP_BY_TYPE + if counts[fragment_type] + ) + noun = "fragment" if len(self.fragments) == 1 else "fragments" + return ( + f"VERSION: {self.current_version} -> {self.version}, a {self.bump} " + f"release for {listed} {noun}." + ) + + +def read_version(root: Path) -> str: + """Return the version in ``root``'s VERSION file.""" + try: + text = (root / VERSION_FILE).read_text(encoding="utf-8") + except OSError: + text = None + if text is None or not VERSION_PATTERN.fullmatch(text.removesuffix("\n")): + msg = ( + f"{VERSION_FILE} must hold one version, MAJOR.MINOR.PATCH such as " + f"0.1.0, on one line; it holds {text!r}." + ) + raise ReleaseError(msg) + return text.removesuffix("\n") + + +def raised_version(version: str, bump: str) -> str: + """Return ``version`` with its ``bump`` part raised and the parts after it reset.""" + 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 plan_release(root: Path) -> Release: + """Return the release the pending fragments in ``root`` make. + + Raise ``ReleaseError`` when changelog.d/ holds anything that is not a + fragment, holds no fragment, or VERSION holds no version. + """ + definition = FragmentDefinition.load(root) + scan = definition.scan(root) + if scan.problems: + msg = ( + f"The release reads every file in {definition.directory}/, and " + f"{len(scan.problems)} of them can't be read as fragments. Fix them " + f"in a pull request; this release can't be built until then." + ) + raise ReleaseError(msg, scan.problems) + if not scan.fragments: + msg = ( + f"{definition.directory}/ holds no fragments, so there is nothing to " + f"release. Every pull request adds one (rule {RULE_ID}); merge one " + f"that does to release this change too." + ) + raise ReleaseError(msg) + current_version = read_version(root) + bump = max( + (BUMP_BY_TYPE[fragment.type] for fragment in scan.fragments), + key=BUMP_SIZES.index, + ) + return Release( + current_version=current_version, + version=raised_version(current_version, bump), + bump=bump, + fragments=scan.fragments, + ) + + +def bump(root: Path) -> int: + """Write the next release's version to ``root``'s VERSION file.""" + release = plan_release(root) + (root / VERSION_FILE).write_text(f"{release.version}\n", encoding="utf-8") + print(release.summary()) + return 0 + + def main(arguments: list[str] | None = None) -> int: """Run the command named in ``arguments``; return the exit status.""" parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) @@ -295,16 +444,25 @@ def main(arguments: list[str] | None = None) -> int: "check-pull-request", help="check the merged result of a pull request; its added paths on stdin", ) + commands.add_parser("bump", help="write the next version to VERSION") + commands.add_parser("next-version", help="print the next version") options = parser.parse_args(arguments) root = Path.cwd() try: if options.command == "check-pull-request": return check_pull_request(root, _paths_from_stdin()) + if options.command == "bump": + return bump(root) + print(plan_release(root).version) except ConfigurationError as error: print(f"::error::{_annotation_text(str(error))}") return 1 - parser.error(f"unknown command {options.command}") - return 2 + except ReleaseError as error: + for path, problem in error.problems.items(): + print(f"::error file={path}::{_annotation_text(problem)}") + print(f"::error::{_annotation_text(str(error))}") + return 1 + return 0 if __name__ == "__main__": diff --git a/.github/towncrier/pyproject.toml b/.github/towncrier/pyproject.toml new file mode 100644 index 000000000..7d0c10b6f --- /dev/null +++ b/.github/towncrier/pyproject.toml @@ -0,0 +1,16 @@ +# The locked environment Towncrier runs in. .github/scripts/build-changelog.sh +# runs it with `uv run --frozen --project .github/towncrier towncrier build`, +# so every release builds CHANGELOG.md with the Towncrier release in uv.lock. +# `make update` relocks it with every other project. This project is never +# built or installed as a package; the repository's release version is in +# VERSION, not here. See docs/engineering/skills/dependencies.md. + +[project] +name = "policyengine-sim-api-towncrier" +version = "0.1.0" +description = "Towncrier, locked for building CHANGELOG.md from changelog.d/ fragments." +requires-python = ">=3.13,<3.14" +dependencies = ["towncrier>=26.9.0,<27"] + +[tool.uv] +package = false diff --git a/.github/towncrier/uv.lock b/.github/towncrier/uv.lock new file mode 100644 index 000000000..180cc6f4a --- /dev/null +++ b/.github/towncrier/uv.lock @@ -0,0 +1,76 @@ +version = 1 +revision = 5 +requires-python = "==3.13.*" + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, +] + +[[package]] +name = "policyengine-sim-api-towncrier" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "towncrier" }, +] + +[package.metadata] +requires-dist = [{ name = "towncrier", specifier = ">=26.9.0,<27" }] + +[[package]] +name = "towncrier" +version = "26.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "jinja2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/d4/e85ce614bf45b1a7fe13b82cc687819c2eaac5a1dd94b84834322095fd98/towncrier-26.9.0.tar.gz", hash = "sha256:ace9031631c718cc0709107b8755e6e31350eb8acdc877af43df3177addb9a55", size = 79338, upload-time = "2026-09-04T12:57:25.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/88/f9340b545dafa64de053798e760515a2736fbb60092cf9aa6b146ccf3ede/towncrier-26.9.0-py3-none-any.whl", hash = "sha256:ae4d223e6aadaff98d29b7f09e58d9c9ccf4cd3b455a2d2e0486d432cd8bf660", size = 67030, upload-time = "2026-09-04T12:57:23.986Z" }, +] diff --git a/Makefile b/Makefile index 94484d22c..4c79846e1 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # Simplified Makefile using docker-compose -.PHONY: help dev up down build test clean logs format check require-ruff-version push-pr-branch +.PHONY: help dev up down build test clean logs format check require-ruff-version push-pr-branch changelog changelog-draft # Load environment variables if .env exists ifneq (,$(wildcard deployment/.env)) @@ -32,6 +32,8 @@ help: @echo " make format - Format code with ruff" @echo " make check - CI's ruff checks + Pyright in each package" @echo " make update - uv lock --upgrade across packages" + @echo " make changelog-draft - Print the CHANGELOG.md section the next release adds" + @echo " make changelog - Build the next release: raise VERSION, build CHANGELOG.md" @echo " make clean - Remove caches and stop containers" @echo " make push-pr-branch - Push current branch to origin for a PR" @echo "" @@ -134,7 +136,7 @@ test-complete: test test-integration-with-services # Update dependencies update: @echo "Updating dependencies for all packages..." - @for pyproject in libs/*/pyproject.toml projects/*/pyproject.toml; do \ + @for pyproject in libs/*/pyproject.toml projects/*/pyproject.toml .github/towncrier/pyproject.toml; do \ if [ -f "$$pyproject" ]; then \ dir=$$(dirname "$$pyproject"); \ echo "Updating $$dir..."; \ @@ -143,6 +145,15 @@ update: done @echo "✅ All dependencies updated" +# Changelog. The versioning job in simulation-deploy.yml builds each release +# after a merge to main; changelog-draft shows what the next one adds. See +# docs/engineering/skills/github-prs.md#changelog-fragments. +changelog: + .github/scripts/build-changelog.sh + +changelog-draft: + .github/scripts/build-changelog.sh --draft + # Code quality format: require-ruff-version @echo "Formatting code with ruff $(RUFF_VERSION)..." diff --git a/VERSION b/VERSION new file mode 100644 index 000000000..6e8bf73aa --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 From e3f294a1ea65111ca821903e00142735a29cd86f Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:48:06 +0400 Subject: [PATCH 05/11] Test that CHANGELOG.md takes over changelog_entry.yaml's history The 63 entries of the shared changelog_entry.yaml must move, word for word and in order, into CHANGELOG.md as its 0.1.0 section, and the YAML file must go. The test pins the per-type counts (17 added, 28 changed, 18 fixed), a SHA-256 of the entries, and the two entries YAML read as mappings because of an unquoted ": ", so a conversion through a YAML parser can't pass. It also checks that Towncrier's start marker comes before every section, that VERSION names the newest section, and that sections are listed newest first. These tests fail: CHANGELOG.md does not exist and changelog_entry.yaml is still there. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_changelog_history.py | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 projects/policyengine-simulation-entry/tests/test_changelog_history.py diff --git a/projects/policyengine-simulation-entry/tests/test_changelog_history.py b/projects/policyengine-simulation-entry/tests/test_changelog_history.py new file mode 100644 index 000000000..40ea41cfb --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_changelog_history.py @@ -0,0 +1,134 @@ +"""Tests for CHANGELOG.md and the history it took over from changelog_entry.yaml. + +Before changelog fragments, every pull request added a line to one shared +changelog_entry.yaml, which nothing read. Its 63 entries moved, word for word +and in order, into CHANGELOG.md as the 0.1.0 section. Each of those changes +deployed when its pull request merged, so the section describes main as it +stood when fragments started, under the version every package already +declared. Towncrier writes each later release above it, after the start +marker towncrier.toml names, and the release commit raises VERSION to match. +""" + +from __future__ import annotations + +import hashlib +import re + +import tomllib +from changelog_release_support import TOWNCRIER_CONFIG +from workflow_script_support import REPOSITORY_ROOT + +CHANGELOG = REPOSITORY_ROOT / "CHANGELOG.md" +SECTION_HEADING = re.compile( + r"^## \[(?P[0-9]+\.[0-9]+\.[0-9]+)\] - " + r"(?P[0-9]{4}-[0-9]{2}-[0-9]{2})$", + re.MULTILINE, +) +TYPE_HEADING = re.compile(r"^### (?P.+)$", re.MULTILINE) +# SHA-256 of the 63 entries of changelog_entry.yaml, joined by newlines in +# file order (added, changed, fixed), as they were on main before the move. +HISTORY_SHA256 = "2a2b3336fd21a40a71bd83244e450c506a51e7a03193d0f88fa819833e3a7178" +# Two entries YAML read as mappings, because of an unquoted ": ". Copying the +# file's text, not what YAML parsed, keeps them whole. +ENTRIES_YAML_READ_AS_MAPPINGS = ( + "Added the rule sim.quality.python-file-length, checked by a new file-length " + "quality guard with proof fixtures that its test writes to a temporary " + "directory: a tracked Python file has at most 800 lines", + "Enforced the file-length rule sim.quality.python-file-length, now that every " + "tracked Python file is within its limit: its empty baseline and its downgrade " + "section are gone", +) + + +def _towncrier() -> dict: + return tomllib.loads(TOWNCRIER_CONFIG.read_text(encoding="utf-8"))["tool"][ + "towncrier" + ] + + +def _sections() -> list[tuple[str, str, str]]: + """Return each release section as (version, date, body), newest first.""" + text = CHANGELOG.read_text(encoding="utf-8") + headings = list(SECTION_HEADING.finditer(text)) + return [ + ( + heading.group("version"), + heading.group("date"), + text[ + heading.end() : ( + headings[index + 1].start() + if index + 1 < len(headings) + else len(text) + ) + ], + ) + for index, heading in enumerate(headings) + ] + + +def _entries_by_type(body: str) -> dict[str, list[str]]: + """Return the bulleted entries under each ``### Type`` heading of ``body``.""" + headings = list(TYPE_HEADING.finditer(body)) + entries = {} + for index, heading in enumerate(headings): + end = headings[index + 1].start() if index + 1 < len(headings) else len(body) + entries[heading.group("name")] = [ + line.removeprefix("- ") + for line in body[heading.end() : end].splitlines() + if line.startswith("- ") + ] + return entries + + +def test_the_shared_changelog_entry_file_is_gone(): + assert not (REPOSITORY_ROOT / "changelog_entry.yaml").exists() + + +def test_towncrier_writes_releases_after_its_start_marker(): + text = CHANGELOG.read_text(encoding="utf-8") + towncrier = _towncrier() + start_marker = towncrier["start_string"] + + assert towncrier["filename"] == "CHANGELOG.md" + assert text.count(start_marker) == 1 + assert text.index(start_marker) < SECTION_HEADING.search(text).start() + assert towncrier["title_format"] == "## [{version}] - {project_date}" + + +def test_the_first_release_holds_the_history_from_changelog_entry_yaml(): + version, date, body = _sections()[-1] + entries = _entries_by_type(body) + + assert (version, date) == ("0.1.0", "2026-10-28") + assert list(entries) == ["Added", "Changed", "Fixed"] + assert [len(listed) for listed in entries.values()] == [17, 28, 18] + history = [entry for listed in entries.values() for entry in listed] + assert hashlib.sha256("\n".join(history).encode()).hexdigest() == HISTORY_SHA256 + assert entries["Added"][0].startswith( + "Recorded in the routing state when each policyengine.py route was first " + "published" + ) + assert entries["Fixed"][-1].startswith( + "Gave Stage 12 baseline and reform simulations one shared wait window" + ) + for prefix in ENTRIES_YAML_READ_AS_MAPPINGS: + assert sum(entry.startswith(prefix) for entry in history) == 1, prefix + + +def test_version_names_the_newest_release(): + """The release commit raises VERSION and writes its section together.""" + newest_version = _sections()[0][0] + + assert (REPOSITORY_ROOT / "VERSION").read_text(encoding="utf-8") == ( + f"{newest_version}\n" + ) + + +def test_releases_are_listed_newest_first(): + versions = [ + tuple(int(part) for part in version.split(".")) + for version, _date, _body in _sections() + ] + + assert versions == sorted(versions, reverse=True) + assert len(set(versions)) == len(versions) From 08d3ef953fef87818c74f792fa9a6e83ec277e60 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:48:51 +0400 Subject: [PATCH 06/11] Move changelog_entry.yaml's history into CHANGELOG.md CHANGELOG.md starts with Towncrier's start marker and one section, 0.1.0, holding the 63 entries of changelog_entry.yaml word for word and in order, under Added, Changed and Fixed. Towncrier rendered the section from one temporary fragment per entry, so it is formatted as every later release will be, and its entries are byte-identical to the YAML file's lines. changelog_entry.yaml is removed. The history is a section rather than 63 pending fragments because those changes already deployed when their pull requests merged: as fragments, the first release built after this one would have listed months of deployed work under its own version and date, and bumped the version for it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 91 ++++++++++++++++++++++++++++++++++++++++++++ changelog_entry.yaml | 68 --------------------------------- 2 files changed, 91 insertions(+), 68 deletions(-) create mode 100644 CHANGELOG.md delete mode 100644 changelog_entry.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..d45b24e68 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,91 @@ +# Changelog + +Each section lists what one release of the simulation API changed, newest +first. After each merge to `main`, the versioning job in +`.github/workflows/simulation-deploy.yml` raises `VERSION` and writes the +release's section from the fragments in `changelog.d/`, and its commit is +the one that deploys. To describe a change, add a fragment to your pull +request instead of editing this file: see +`docs/engineering/skills/github-prs.md#changelog-fragments`. + + + +## [0.1.0] - 2026-10-28 + +Main as it stood when changelog fragments started. These entries were +collected in `changelog_entry.yaml`, one or more per pull request, and moved +here word for word. Each change deployed when its pull request merged, so +this date is the day the entries moved here, not the day any of them +deployed. + +### Added + +- Recorded in the routing state when each policyengine.py route was first published (published_at, by version) and when latest.policyengine last changed (latest_since, set by a publish that advances it, a forced latest or a rollback), and returned both from the gateway's /versions; a date is recorded only when the publish is that event, so states written before this change show no date for what they already route instead of a later, wrong one, and both fields are optional, so earlier states still read and validate; only ISO 8601 times with a timezone are kept (normalized to UTC in the state), and /versions types them as timezone-aware datetimes +- Added is_tax to each detailed_budget program (optional, null in outputs built before it), copied from policyengine.py's program statistics, so consumers sign a program's change as tax revenue or benefit spending without a list of their own; a UK benefit that has no row in the app's list, such as Scottish Child Payment, was otherwise drawn as a tax +- Added the rule sim.dependencies.uv-version-pinned, checked by a new uv-version quality guard with proof fixtures taken from the unpinned setup-uv step and the ghcr.io/astral-sh/uv:latest copy on main; every astral-sh/setup-uv step, uv container image reference, Modal uv_sync or uv_pip_install call, uv install script or release download, pip, pipx or uv tool install of uv, and uv self update must pin the one uv release written in the new .uv-version file, 0.13.6 +- Added the rule sim.quality.python-file-length, checked by a new file-length quality guard with proof fixtures that its test writes to a temporary directory: a tracked Python file has at most 800 lines, or at most 1,500 if it is test code (a file under a tests or fixtures directory, or a conftest.py); the 14 files over their limit on main are listed in a baseline that may only shrink, so the rule starts ratcheted, with a downgrade section, authorized by the maintainer, whose tracking issue closes when the rule is enforced +- Added breakdown "local_areas" to the simulation request for UK requests whose region is absent, uk or one of country/england, country/scotland, country/wales and country/northern_ireland, refused with 422 for other regions (including country/ni), other countries, include_cliffs=true, segmented=true or a region_group (include_cliffs=false stays accepted); the job runs one baseline and reform on the local-area dataset alone, with the region's filter, and returns a LocalAreaBreakdownOutput (kind local_areas, model_version, policyengine_version, dataset, data_version, and constituency_impact and local_authority_impact as lists of their own record types), which the job status result now accepts alongside SingleYearMacroOutput +- Declared local_area_breakdown version 1 in the routing bundle manifest of apps whose policyengine.py bundle routes both UK area region types to one local-area release dataset (regional_release_kind local_area; an area_coded_national bundle, or one routing the two types to different datasets, does not count as routed), and made the gateway refuse breakdown with 400, before spawning, for any route whose manifest does not declare it, including legacy routes and routes pinned to an older version, since older apps ignore the field and would return a national result +- Made a routed local-area dataset fail as it loads, before any simulation, when it lacks any of the four household columns the area outputs read (constituency_code_oa, la_code_oa, region_code_oa and source_household_id), for breakdown, constituency and local-authority runs alike, in the runtime and in the Stage 12 worker; Stage 12 copies the same columns +- Kept breakdown requests out of the Stage 12 shadow comparison, which skips them as unsupported_options, and added beta integration checks of a UK local-area breakdown and of a UK national result without area data +- Added four registered dependency rules, checked by the locked-installs quality guard with proof fixtures; every project commits a uv.lock that PR CI checks with uv lock --check, every install in workflows, scripts, Makefiles, Dockerfiles, Compose files and Modal images installs the locked versions, every test command runs through uv run --frozen, and the daily update-dependencies workflow relocks every project; every uv sync and uv run now passes --frozen, and the five sites that cannot install from a lock are registered exceptions +- Added optional disclosure fields to UK constituency and local-authority records (disclosure_tier, reported or approximate, and the 90% interval bounds of the average and relative income change); records stored before this change still validate +- Added the rule sim.boundaries.sqlalchemy-and-alembic-only, with proof from the psycopg adapter of 08ebe1ca; code connects to databases only through SQLAlchemy, never by importing a database driver, and runtime code never changes the schema, which changes only through Alembic migrations; a guard parses every file under projects/*/src, libs/*/src and .github/scripts and rejects driver imports, alembic imports outside a migrations directory, create_all and drop_all, create and drop on tables, SQLAlchemy DDL statements and DDL text in string literals, which extends the Stage 12 DDL check's pattern to the gateway and every library +- Made the budget's reform-minus-baseline totals log a warning when the baseline and reform household frames carry different weights, since the change is then weighted by the reform's weights alone +- Added weighted-microdata rules to the registry, with proof from the #767 household count; the executor's code never aggregates a weight column as a value, reads raw values from weighted frames only in simulation_output_common.py, where any other read needs a recorded exception in a list that may only shrink and is now empty, and its output-builder tests build entity frames through one shared factory with non-uniform weights, while a doc-only rule records that weights, values, labels and counts each allow different operations +- Made CLAUDE.md and the Copilot instructions thin pointers to AGENTS.md, required workflow logic longer than one line to live in tested and shellchecked scripts, and registered the tracking-issue rule +- Added a rules registry (rules/registry.toml) with a standard-library tool that validates it, checks every rule is enforced where it claims, and renders docs/generated/rules.md and the Rules block in AGENTS.md +- Ran Stage 12 US national simulations as 20 region-group calls each, and built an independent, immutable Stage 12 cache of three annual US datasets and 60 current-law baselines before each deployment, publishing it only after a recorded determinism check and recording its release in the v2 version manifest under a new key, while the existing key keeps a copy that earlier readers accept +- Made the budget refuse a household count outside its country's plausible range (above 200 million in the US or 60 million in the UK, negative or not finite), raising ImplausibleEntityCountError instead of publishing a count like #767's + +### Changed + +- Pinned uv 0.13.6, the release in .uv-version and the one the current uv.lock files were written with, in all 23 astral-sh/setup-uv steps of the PR, image smoke, deployment, rollback, client publishing, package update and dependency update workflows, in the entrypoint and executor Dockerfiles (ghcr.io/astral-sh/uv:0.13.6 instead of :latest), and in the uv_sync calls of the executor's runtime, v2 and Stage 12 precompute Modal images and of the gateway's Modal image; before, setup-uv installed the newest uv release on every run and the Dockerfiles and Modal copied uv from the latest tag, and the Modal images now rebuild once from their uv_sync layer +- Required policyengine-observability 3.0.4 (>=3.0.4,<4) in the observability library, the entrypoint, the gateway and the executor, and locked it in the six locks that include it, where it is the only package that moves; 3.0.4 annotates its span and operation scopes' __exit__ and __aexit__ as returning Literal[False], so the services now open spans and operations with runtime.span and runtime.operation directly, and the policyengine_simulation_observability.scopes module, which only retyped those scopes for Pyright, is removed with its tests; a new observability library test fails if an older release is installed, Pyright still reports no errors in any package, and 3.0.3's fix, which keeps the recorded exception on the request.completed record and server span of a request that ends with an error outcome but passes no exception to end_request, is included +- Removed the None presets and None checks around span and operation blocks that only satisfied Pyright before policyengine-observability 3.0.4; the Stage 12 Modal functions run_single_simulation_segment_us, run_single_simulation_us, run_single_simulation_uk and coordinate_report return their result from inside the operation block, and the Stage 12 segment runner no longer sets the dispatched call, the collected payloads and the merged calculation to None before their spans or checks them afterwards, and returns the merge from inside its span; 3.0.4's scopes never suppress an exception, and each value comes from a function that never returns None, so the removed RuntimeErrors (returned no result, no call, no payloads or no calculation) could not be raised; new tests run each of those blocks through the scopes of the runtime the services build +- Enforced the file-length rule sim.quality.python-file-length, now that every tracked Python file is within its limit: its empty baseline and its downgrade section are gone, and its tier is metric rather than mechanical, because it measures a number against a threshold, with the at-the-limit and one-over fixtures as its below and above proof +- Moved the entrypoint's backend calls, response telemetry and comparison dispatch out of app.py (925 lines, now 583) into entry_runtime.py, which app.py re-exports and which still logs under the policyengine_simulation_entry.app logger; the routes stay in app.py, so FastAPI and ruff read their annotations as before; the 2,251-line deployment asset test file is split into seven test files and a support module named test_deployment_assets_support.py, the rules registry names each moved test by its new file, and its guard-test discovery now covers tests/test_deployment_assets*.py +- Moved the worker's credential setup, request year and policy parsing, and region resolution out of simulation_runtime.py (1,111 lines, now 651) into gcp_credentials.py, request_policy.py and region_resolution.py; simulation_runtime.py keeps dataset selection and loading, simulation building, local-area breakdowns and the run itself, which tests replace by name there, and re-exports the moved names, so every caller still imports them from simulation_runtime; the shared helpers _parse_year, _normalise_policy, _compile_interval_policy, _runtime_policy (now wrapper_policy), _resolve_region, _resolve_dataset_reference and _require_local_area_breakdown are now public, and the credential messages still log under the simulation_runtime logger; the 3,048-line output builder test file is split into seven test files and a module of shared fakes, all named test_simulation_output_*.py so the weighted-frame rule still checks them +- Split the artifact precompute (precompute.py, 931 lines) and the Stage 12 cache precompute (stage12_cache/precompute.py, 844 lines) each into planning, dataset-building and baseline modules, with the manifest and run_precompute left in the original module, which re-exports every public name, so the Modal precompute apps and their tests import from the same paths; the cache precompute's _scoped_planned_simulation and _baseline_identity are now public, and the worker validation imports scoped_planned_simulation from its new module; the 1,835-line precompute test file is split into four, and the cache precompute tests patch each name in the module whose code reads it +- Split the 4,709-line Stage 12 runtime test file into thirteen test files by behaviour (dataset loading, provenance, the single worker and its failures, aggregates, the coordinator and its failures, runtime store retries, failure records, cancellation, child state errors and the wait window) and three support modules for the shared builders, fake stores and fake child calls, whose helpers are now public; the other Stage 12 tests import them from those modules, every test keeps its name and body, and two parametrized cases are named after the renamed bundle helper +- Split Stage 12 report coordination (stage12_runtime/coordination.py, 1,091 lines) into modules for the report coordinator class, its child simulations' records, failures and cancellation, and the parent report's checks and claim, with coordinate_report and every public name still in coordination.py and child failures still logged under its logger; and turned the contract's stage12_execution.py (929 lines) into a stage12_execution package of modules for the shared field types and simulation input, report inputs and output plans, result artifacts, and comparison-run records, which re-exports every name from the same import path, with unchanged JSON schemas; models subclass the base models through the package path ruff.toml lists, so no ruff setting changes +- Split the gateway's endpoints.py (1,454 lines) into modules for the shared router and request helpers, route resolution from the active routing state, Modal reads of job metadata and routing, bundle payloads, and the job submission, job status and version routes, which register their routes in the same order, so the OpenAPI document is unchanged; endpoints.py re-exports every public name, the routes keep logging under the policyengine_simulation_gateway.endpoints logger, and the private helpers the new modules share are now public; its 1,801-line test file is split into five test files, with the shared expected-response helpers in fixtures/gateway_responses.py +- Split the routing publisher (src/modal/utils/update_version_registry.py, 1,584 lines) into a src/modal/routing_publisher package of modules for the bundle manifest, the routing state, routing plans and their publication record, the check that only serialized workflows write routing, Modal Dict access, plan publication, the legacy seed and the command-line options, and its 2,728-line test file into a tests/version_registry directory of nine test files with shared fixtures; update_version_registry.py keeps the command line, still runs with python -m src.modal.utils.update_version_registry and re-exports every public name; behaviour and output are unchanged, and the private helpers the new modules share are now public +- Removed the UK constituency and local-authority weight-matrix region fallback (_build_uk_weight_replacement_region, _require_certified_uk_weight_matrix and its Stage 12 call); each deployment rebuilds only the app for the current policyengine.py version, now 6.10.0, which routes those regions to the local-area dataset, and apps deployed for older versions are not rebuilt and keep their own code; on a bundle that does not route them, an area region is now an unsupported region +- Made UK-wide and nation-level runs on a policyengine.py bundle that routes UK area regions to a local-area release dataset (6.10.0) load only the national dataset and return null for constituency_impact and local_authority_impact, whose results come from a breakdown=local_areas run instead; on a bundle that does not route them, those runs still compute both from the national dataset, and Stage 12 leaves the same breakdowns out of the same reports +- Enabled every ruff rule (select ALL), ignoring only COM812 and ISC001, which conflict with ruff format, and the D203 and D213 halves of the mutually exclusive docstring pairs; complex functions, long parameter lists and magic numbers were split into smaller helpers and grouped parameters, each behind new characterization tests, across the contract and persistence validation, entry settings, app factory and Stage 12 adapter, gateway route resolution, Stage 12 coordination, workers, parity and qualification, the precompute and national reduce, the routing publisher and its command line, the release sentinel, the package update helper and the shared rules tool; Modal function, FastAPI route, Pydantic validator and command-line signatures and every OpenAPI document are unchanged; the contract library's batch-state builder and SPM selection resolver take their route values as one grouped argument, the simulation observability initializer drops a service_version parameter no caller passed, and no PLR0913 suppression is needed +- Enabled ruff's argument, idiom and pytest-style rules (ARG, ASYNC, B, C4, FBT, PD, PERF, PGH, PLW, PT, PTH, RUF, SIM); unused arguments are removed, underscored or explained, FastAPI auth dependencies use Annotated, re-raised errors keep their cause, and boolean options of in-repo helpers are keyword-only; OpenAPI documents are unchanged, and the shared rules tool's unittest tests are exempt from pytest-style rules; tests take the unused-argument rules (ARG001, ARG002, ARG005) as file-level exceptions, since their fakes and stubs mirror real signatures; best-effort telemetry and cleanup calls that swallow any error use contextlib.suppress, with the reason as a comment +- Enabled ruff's structure and import rules (INP, PLC, SLF, N, A, FAST, E); long lines are wrapped, deferred imports that Modal images, test seams, import cycles or heavy dependencies need carry a reason, script directories are declared to ruff as namespace packages, four internal exceptions gained an Error suffix, and published field names, OpenAPI documents and logged error types are unchanged; tests are also exempt from SLF001, INP001 and PLC0415; heavy or optional packages that load only inside functions are listed in ruff's banned-module-level-imports, which keeps them out of module-level imports (TID253) and lets their deferred imports go without a suppression, and the Modal utility programs take INP001 file-wide +- Enabled ruff's security, logging and debugging rules (S, T20, LOG, G, DTZ, ERA, TD, FIX, EXE); asserts outside tests became explicit checks, command-line scripts keep their printed output, test subprocess calls go through helpers, and every deliberate exception carries a reasoned suppression; the gateway's auth misconfiguration log no longer repeats its message, and the deployed-service tests share the unit-test ruff exemptions; tests, CI/CD scripts and command-line scripts take their deliberate exceptions as registered file-level ruff ignores instead of inline suppressions, and the rules guard rejects file-level ignores anywhere else +- Enabled ruff's exception rules (TRY, EM, BLE); exception messages are built before the raise, raises inside try blocks moved into helpers, and every deliberate catch-all or traceback-free error log carries a reasoned suppression; message text is unchanged, and error types change only in an operator script's two type checks +- Enabled ruff's docstring rules (D) and documented every module, class and function outside tests; published contract models and routes now carry these descriptions in their JSON schema and OpenAPI, with no structural schema change +- Enabled ruff's annotation rules (ANN) and annotated every function outside tests; test files are exempt from ANN, missing docstrings, magic numbers and bare-assert rules +- Enabled ruff's typing-only import rules (TC) and moved annotation-only imports under TYPE_CHECKING, keeping runtime imports for Pydantic models and FastAPI endpoints +- Listed policyengine.py's Simulation, pydantic-settings' BaseSettings and the contract's SingleYearMacroOutput as runtime-evaluated bases for ruff, imported SingleYearMacroOutput from the contract in the executor's compat models instead of through a re-export, and added executor and entrypoint tests that fail when ruff, reading a model's bases through its module's imports, would not treat a Pydantic model as runtime-evaluated, or when a model's annotations do not resolve +- Ran ruff check in CI with the first group of rule families (imports, pyupgrade and other safe auto-fixes) and fixed every finding +- Moved the multi-line `run` blocks of the simulation deployment workflows into single-line steps and tested scripts without changing their behaviour or outputs, leaving only the release-sentinel and routing-rollback blocks in the one-line `run` rule's baseline +- Moved multi-line `run` blocks in the PR, image smoke and client publishing workflows into single-line steps and tested scripts, tested the dependency-update scripts, fixed the shellcheck finding in modal-precompute.sh, and enforced the CI-script testing and shellcheck rules with empty baselines +- Added CODEOWNERS for the rule machinery, pinned CI's ruff to 0.15.22 with an explicit rule selection, and documented that agents never bypass branch rules +- Ran the Modal image smoke for Stage 12 persistence changes and added policyengine-fastapi to the PR test matrix +- Ran the full PR workflow and the Modal image smoke only on the lowest unmerged pull request in a stack, behind a new CI complete gate job that fails on the others + +### Fixed + +- Stopped trusting the compromised eMudhra (emSign) intermediate CA certificates; every lockfile now pins certifi 2026.10.19, which drops emSign ECC Root CA - G3, every Modal image upgrades Debian's ca-certificates package right after its base layer, and the Modal images and the Cloud Run entrypoint image set GRPC_DEFAULT_SSL_ROOTS_FILE_PATH so grpcio verifies servers against the operating-system CA bundle instead of the CA list compiled into it +- Built UK constituency and local-authority results with policyengine's disclosure-controlled compute_uk_geography_disclosure instead of the deprecated, unprotected functions, in one release nested in regions and the nation; an area that rests on fewer than 10 survey households, is dominated by one, would be recovered by subtraction, or has a non-positive baseline income is left out of the list, so small areas no longer show exploding relative changes such as +1372% for a £192 loss +- Rebuilt the daily dependency update's branch from main on every run and replaced the remote branch with a lease, instead of merging main into the open update PR's branch, which mixed two uv.lock resolutions into locks uv could not parse; the push is skipped when the branch already holds the update, and the open update PR is reused with a rewritten description +- Kept the policyengine.py updater trying to inspect a new release for about ten minutes instead of 90 seconds, and refreshed uv's cached package index on every attempt and on each retried lock, so a release dispatched before PyPI serves it is no longer skipped +- Preserved bounded reform stop dates in UK and US simulations while retaining current-law baseline artifact reuse +- Resolved PolicyEngine-managed regional datasets using bundled data versions instead of release-manifest commit hashes when loading from GCS +- Added a Modal app-release bundle backfill for missing data artifact revisions +- Ensured Stage 12-only deployments run authenticated candidate tests and verify every required deployment phase before reporting success +- Allowed Cloud Run stable endpoint checks to wait for documented traffic propagation before restoring the previous revision +- Added a dedicated read-only Hugging Face credential for private UK runtime datasets, validated it before synchronization, and tested its worker configuration +- Accepted a UK dataset without area codes only when the policyengine.py bundle routes constituency and local-authority regions to another dataset, and kept requiring area codes on every constituency and local-authority run +- Raised a clear error, never an omission, when a UK constituency or local-authority breakdown is built over a dataset without area codes +- Resolved UK constituency and local-authority regions by name or by GSS code in any case once policyengine.py routes them, as row filters from its region registry, so policyengine-api's constituency/{name} and local_authority/{name} requests keep working without weight matrices, and the 17 districts replaced in April 2023 are unsupported regions +- Made the executor and Stage 12 image smokes check the routed dataset's area codes, and that its household table has all four columns the area outputs read (reading only the column names), downloading it with the Hugging Face credential, when the installed UK default carries none, and stopped Stage 12 copying area codes for a routed UK national or nation-level report, whose aggregate leaves the two area breakdowns out +- Released each Stage 12 database session after its operation instead of holding pooled sessions through calculations, retried opening each Stage 12 coordinator and worker database connection that the pooler refused without ever retrying a statement or commit, and reported Stage 12 failures to telemetry before recording them in the database so a failed write cannot hide the original error +- Accepted a null SPM provenance in Stage 12 aggregate artifacts, so UK and other reports without SPM calculations are saved, while every supplied provenance record is still validated +- Recorded a redacted, bounded Stage 12 failure cause naming the exception, failing stage, simulation role and message instead of only the exception class, and kept a failed worker's recorded cause on its simulation and report +- Gave Stage 12 baseline and reform simulations one shared wait window that opens when the coordinator starts and fits inside the coordinator's Modal timeout, and cancelled every unfinished simulation, recording it as failed with simulation_cancelled, when a simulation fails, the window closes, or the coordinator fails after starting a simulation diff --git a/changelog_entry.yaml b/changelog_entry.yaml deleted file mode 100644 index 07925a8fe..000000000 --- a/changelog_entry.yaml +++ /dev/null @@ -1,68 +0,0 @@ -- bump: patch - changes: - added: - - Recorded in the routing state when each policyengine.py route was first published (published_at, by version) and when latest.policyengine last changed (latest_since, set by a publish that advances it, a forced latest or a rollback), and returned both from the gateway's /versions; a date is recorded only when the publish is that event, so states written before this change show no date for what they already route instead of a later, wrong one, and both fields are optional, so earlier states still read and validate; only ISO 8601 times with a timezone are kept (normalized to UTC in the state), and /versions types them as timezone-aware datetimes - - Added is_tax to each detailed_budget program (optional, null in outputs built before it), copied from policyengine.py's program statistics, so consumers sign a program's change as tax revenue or benefit spending without a list of their own; a UK benefit that has no row in the app's list, such as Scottish Child Payment, was otherwise drawn as a tax - - Added the rule sim.dependencies.uv-version-pinned, checked by a new uv-version quality guard with proof fixtures taken from the unpinned setup-uv step and the ghcr.io/astral-sh/uv:latest copy on main; every astral-sh/setup-uv step, uv container image reference, Modal uv_sync or uv_pip_install call, uv install script or release download, pip, pipx or uv tool install of uv, and uv self update must pin the one uv release written in the new .uv-version file, 0.13.6 - - Added the rule sim.quality.python-file-length, checked by a new file-length quality guard with proof fixtures that its test writes to a temporary directory: a tracked Python file has at most 800 lines, or at most 1,500 if it is test code (a file under a tests or fixtures directory, or a conftest.py); the 14 files over their limit on main are listed in a baseline that may only shrink, so the rule starts ratcheted, with a downgrade section, authorized by the maintainer, whose tracking issue closes when the rule is enforced - - Added breakdown "local_areas" to the simulation request for UK requests whose region is absent, uk or one of country/england, country/scotland, country/wales and country/northern_ireland, refused with 422 for other regions (including country/ni), other countries, include_cliffs=true, segmented=true or a region_group (include_cliffs=false stays accepted); the job runs one baseline and reform on the local-area dataset alone, with the region's filter, and returns a LocalAreaBreakdownOutput (kind local_areas, model_version, policyengine_version, dataset, data_version, and constituency_impact and local_authority_impact as lists of their own record types), which the job status result now accepts alongside SingleYearMacroOutput - - Declared local_area_breakdown version 1 in the routing bundle manifest of apps whose policyengine.py bundle routes both UK area region types to one local-area release dataset (regional_release_kind local_area; an area_coded_national bundle, or one routing the two types to different datasets, does not count as routed), and made the gateway refuse breakdown with 400, before spawning, for any route whose manifest does not declare it, including legacy routes and routes pinned to an older version, since older apps ignore the field and would return a national result - - Made a routed local-area dataset fail as it loads, before any simulation, when it lacks any of the four household columns the area outputs read (constituency_code_oa, la_code_oa, region_code_oa and source_household_id), for breakdown, constituency and local-authority runs alike, in the runtime and in the Stage 12 worker; Stage 12 copies the same columns - - Kept breakdown requests out of the Stage 12 shadow comparison, which skips them as unsupported_options, and added beta integration checks of a UK local-area breakdown and of a UK national result without area data - - Added four registered dependency rules, checked by the locked-installs quality guard with proof fixtures; every project commits a uv.lock that PR CI checks with uv lock --check, every install in workflows, scripts, Makefiles, Dockerfiles, Compose files and Modal images installs the locked versions, every test command runs through uv run --frozen, and the daily update-dependencies workflow relocks every project; every uv sync and uv run now passes --frozen, and the five sites that cannot install from a lock are registered exceptions - - Added optional disclosure fields to UK constituency and local-authority records (disclosure_tier, reported or approximate, and the 90% interval bounds of the average and relative income change); records stored before this change still validate - - Added the rule sim.boundaries.sqlalchemy-and-alembic-only, with proof from the psycopg adapter of 08ebe1ca; code connects to databases only through SQLAlchemy, never by importing a database driver, and runtime code never changes the schema, which changes only through Alembic migrations; a guard parses every file under projects/*/src, libs/*/src and .github/scripts and rejects driver imports, alembic imports outside a migrations directory, create_all and drop_all, create and drop on tables, SQLAlchemy DDL statements and DDL text in string literals, which extends the Stage 12 DDL check's pattern to the gateway and every library - - Made the budget's reform-minus-baseline totals log a warning when the baseline and reform household frames carry different weights, since the change is then weighted by the reform's weights alone - - Added weighted-microdata rules to the registry, with proof from the #767 household count; the executor's code never aggregates a weight column as a value, reads raw values from weighted frames only in simulation_output_common.py, where any other read needs a recorded exception in a list that may only shrink and is now empty, and its output-builder tests build entity frames through one shared factory with non-uniform weights, while a doc-only rule records that weights, values, labels and counts each allow different operations - - Made CLAUDE.md and the Copilot instructions thin pointers to AGENTS.md, required workflow logic longer than one line to live in tested and shellchecked scripts, and registered the tracking-issue rule - - Added a rules registry (rules/registry.toml) with a standard-library tool that validates it, checks every rule is enforced where it claims, and renders docs/generated/rules.md and the Rules block in AGENTS.md - - Ran Stage 12 US national simulations as 20 region-group calls each, and built an independent, immutable Stage 12 cache of three annual US datasets and 60 current-law baselines before each deployment, publishing it only after a recorded determinism check and recording its release in the v2 version manifest under a new key, while the existing key keeps a copy that earlier readers accept - - Made the budget refuse a household count outside its country's plausible range (above 200 million in the US or 60 million in the UK, negative or not finite), raising ImplausibleEntityCountError instead of publishing a count like #767's - changed: - - Pinned uv 0.13.6, the release in .uv-version and the one the current uv.lock files were written with, in all 23 astral-sh/setup-uv steps of the PR, image smoke, deployment, rollback, client publishing, package update and dependency update workflows, in the entrypoint and executor Dockerfiles (ghcr.io/astral-sh/uv:0.13.6 instead of :latest), and in the uv_sync calls of the executor's runtime, v2 and Stage 12 precompute Modal images and of the gateway's Modal image; before, setup-uv installed the newest uv release on every run and the Dockerfiles and Modal copied uv from the latest tag, and the Modal images now rebuild once from their uv_sync layer - - Required policyengine-observability 3.0.4 (>=3.0.4,<4) in the observability library, the entrypoint, the gateway and the executor, and locked it in the six locks that include it, where it is the only package that moves; 3.0.4 annotates its span and operation scopes' __exit__ and __aexit__ as returning Literal[False], so the services now open spans and operations with runtime.span and runtime.operation directly, and the policyengine_simulation_observability.scopes module, which only retyped those scopes for Pyright, is removed with its tests; a new observability library test fails if an older release is installed, Pyright still reports no errors in any package, and 3.0.3's fix, which keeps the recorded exception on the request.completed record and server span of a request that ends with an error outcome but passes no exception to end_request, is included - - Removed the None presets and None checks around span and operation blocks that only satisfied Pyright before policyengine-observability 3.0.4; the Stage 12 Modal functions run_single_simulation_segment_us, run_single_simulation_us, run_single_simulation_uk and coordinate_report return their result from inside the operation block, and the Stage 12 segment runner no longer sets the dispatched call, the collected payloads and the merged calculation to None before their spans or checks them afterwards, and returns the merge from inside its span; 3.0.4's scopes never suppress an exception, and each value comes from a function that never returns None, so the removed RuntimeErrors (returned no result, no call, no payloads or no calculation) could not be raised; new tests run each of those blocks through the scopes of the runtime the services build - - Enforced the file-length rule sim.quality.python-file-length, now that every tracked Python file is within its limit: its empty baseline and its downgrade section are gone, and its tier is metric rather than mechanical, because it measures a number against a threshold, with the at-the-limit and one-over fixtures as its below and above proof - - Moved the entrypoint's backend calls, response telemetry and comparison dispatch out of app.py (925 lines, now 583) into entry_runtime.py, which app.py re-exports and which still logs under the policyengine_simulation_entry.app logger; the routes stay in app.py, so FastAPI and ruff read their annotations as before; the 2,251-line deployment asset test file is split into seven test files and a support module named test_deployment_assets_support.py, the rules registry names each moved test by its new file, and its guard-test discovery now covers tests/test_deployment_assets*.py - - Moved the worker's credential setup, request year and policy parsing, and region resolution out of simulation_runtime.py (1,111 lines, now 651) into gcp_credentials.py, request_policy.py and region_resolution.py; simulation_runtime.py keeps dataset selection and loading, simulation building, local-area breakdowns and the run itself, which tests replace by name there, and re-exports the moved names, so every caller still imports them from simulation_runtime; the shared helpers _parse_year, _normalise_policy, _compile_interval_policy, _runtime_policy (now wrapper_policy), _resolve_region, _resolve_dataset_reference and _require_local_area_breakdown are now public, and the credential messages still log under the simulation_runtime logger; the 3,048-line output builder test file is split into seven test files and a module of shared fakes, all named test_simulation_output_*.py so the weighted-frame rule still checks them - - Split the artifact precompute (precompute.py, 931 lines) and the Stage 12 cache precompute (stage12_cache/precompute.py, 844 lines) each into planning, dataset-building and baseline modules, with the manifest and run_precompute left in the original module, which re-exports every public name, so the Modal precompute apps and their tests import from the same paths; the cache precompute's _scoped_planned_simulation and _baseline_identity are now public, and the worker validation imports scoped_planned_simulation from its new module; the 1,835-line precompute test file is split into four, and the cache precompute tests patch each name in the module whose code reads it - - Split the 4,709-line Stage 12 runtime test file into thirteen test files by behaviour (dataset loading, provenance, the single worker and its failures, aggregates, the coordinator and its failures, runtime store retries, failure records, cancellation, child state errors and the wait window) and three support modules for the shared builders, fake stores and fake child calls, whose helpers are now public; the other Stage 12 tests import them from those modules, every test keeps its name and body, and two parametrized cases are named after the renamed bundle helper - - Split Stage 12 report coordination (stage12_runtime/coordination.py, 1,091 lines) into modules for the report coordinator class, its child simulations' records, failures and cancellation, and the parent report's checks and claim, with coordinate_report and every public name still in coordination.py and child failures still logged under its logger; and turned the contract's stage12_execution.py (929 lines) into a stage12_execution package of modules for the shared field types and simulation input, report inputs and output plans, result artifacts, and comparison-run records, which re-exports every name from the same import path, with unchanged JSON schemas; models subclass the base models through the package path ruff.toml lists, so no ruff setting changes - - Split the gateway's endpoints.py (1,454 lines) into modules for the shared router and request helpers, route resolution from the active routing state, Modal reads of job metadata and routing, bundle payloads, and the job submission, job status and version routes, which register their routes in the same order, so the OpenAPI document is unchanged; endpoints.py re-exports every public name, the routes keep logging under the policyengine_simulation_gateway.endpoints logger, and the private helpers the new modules share are now public; its 1,801-line test file is split into five test files, with the shared expected-response helpers in fixtures/gateway_responses.py - - Split the routing publisher (src/modal/utils/update_version_registry.py, 1,584 lines) into a src/modal/routing_publisher package of modules for the bundle manifest, the routing state, routing plans and their publication record, the check that only serialized workflows write routing, Modal Dict access, plan publication, the legacy seed and the command-line options, and its 2,728-line test file into a tests/version_registry directory of nine test files with shared fixtures; update_version_registry.py keeps the command line, still runs with python -m src.modal.utils.update_version_registry and re-exports every public name; behaviour and output are unchanged, and the private helpers the new modules share are now public - - Removed the UK constituency and local-authority weight-matrix region fallback (_build_uk_weight_replacement_region, _require_certified_uk_weight_matrix and its Stage 12 call); each deployment rebuilds only the app for the current policyengine.py version, now 6.10.0, which routes those regions to the local-area dataset, and apps deployed for older versions are not rebuilt and keep their own code; on a bundle that does not route them, an area region is now an unsupported region - - Made UK-wide and nation-level runs on a policyengine.py bundle that routes UK area regions to a local-area release dataset (6.10.0) load only the national dataset and return null for constituency_impact and local_authority_impact, whose results come from a breakdown=local_areas run instead; on a bundle that does not route them, those runs still compute both from the national dataset, and Stage 12 leaves the same breakdowns out of the same reports - - Enabled every ruff rule (select ALL), ignoring only COM812 and ISC001, which conflict with ruff format, and the D203 and D213 halves of the mutually exclusive docstring pairs; complex functions, long parameter lists and magic numbers were split into smaller helpers and grouped parameters, each behind new characterization tests, across the contract and persistence validation, entry settings, app factory and Stage 12 adapter, gateway route resolution, Stage 12 coordination, workers, parity and qualification, the precompute and national reduce, the routing publisher and its command line, the release sentinel, the package update helper and the shared rules tool; Modal function, FastAPI route, Pydantic validator and command-line signatures and every OpenAPI document are unchanged; the contract library's batch-state builder and SPM selection resolver take their route values as one grouped argument, the simulation observability initializer drops a service_version parameter no caller passed, and no PLR0913 suppression is needed - - Enabled ruff's argument, idiom and pytest-style rules (ARG, ASYNC, B, C4, FBT, PD, PERF, PGH, PLW, PT, PTH, RUF, SIM); unused arguments are removed, underscored or explained, FastAPI auth dependencies use Annotated, re-raised errors keep their cause, and boolean options of in-repo helpers are keyword-only; OpenAPI documents are unchanged, and the shared rules tool's unittest tests are exempt from pytest-style rules; tests take the unused-argument rules (ARG001, ARG002, ARG005) as file-level exceptions, since their fakes and stubs mirror real signatures; best-effort telemetry and cleanup calls that swallow any error use contextlib.suppress, with the reason as a comment - - Enabled ruff's structure and import rules (INP, PLC, SLF, N, A, FAST, E); long lines are wrapped, deferred imports that Modal images, test seams, import cycles or heavy dependencies need carry a reason, script directories are declared to ruff as namespace packages, four internal exceptions gained an Error suffix, and published field names, OpenAPI documents and logged error types are unchanged; tests are also exempt from SLF001, INP001 and PLC0415; heavy or optional packages that load only inside functions are listed in ruff's banned-module-level-imports, which keeps them out of module-level imports (TID253) and lets their deferred imports go without a suppression, and the Modal utility programs take INP001 file-wide - - Enabled ruff's security, logging and debugging rules (S, T20, LOG, G, DTZ, ERA, TD, FIX, EXE); asserts outside tests became explicit checks, command-line scripts keep their printed output, test subprocess calls go through helpers, and every deliberate exception carries a reasoned suppression; the gateway's auth misconfiguration log no longer repeats its message, and the deployed-service tests share the unit-test ruff exemptions; tests, CI/CD scripts and command-line scripts take their deliberate exceptions as registered file-level ruff ignores instead of inline suppressions, and the rules guard rejects file-level ignores anywhere else - - Enabled ruff's exception rules (TRY, EM, BLE); exception messages are built before the raise, raises inside try blocks moved into helpers, and every deliberate catch-all or traceback-free error log carries a reasoned suppression; message text is unchanged, and error types change only in an operator script's two type checks - - Enabled ruff's docstring rules (D) and documented every module, class and function outside tests; published contract models and routes now carry these descriptions in their JSON schema and OpenAPI, with no structural schema change - - Enabled ruff's annotation rules (ANN) and annotated every function outside tests; test files are exempt from ANN, missing docstrings, magic numbers and bare-assert rules - - Enabled ruff's typing-only import rules (TC) and moved annotation-only imports under TYPE_CHECKING, keeping runtime imports for Pydantic models and FastAPI endpoints - - Listed policyengine.py's Simulation, pydantic-settings' BaseSettings and the contract's SingleYearMacroOutput as runtime-evaluated bases for ruff, imported SingleYearMacroOutput from the contract in the executor's compat models instead of through a re-export, and added executor and entrypoint tests that fail when ruff, reading a model's bases through its module's imports, would not treat a Pydantic model as runtime-evaluated, or when a model's annotations do not resolve - - Ran ruff check in CI with the first group of rule families (imports, pyupgrade and other safe auto-fixes) and fixed every finding - - Moved the multi-line `run` blocks of the simulation deployment workflows into single-line steps and tested scripts without changing their behaviour or outputs, leaving only the release-sentinel and routing-rollback blocks in the one-line `run` rule's baseline - - Moved multi-line `run` blocks in the PR, image smoke and client publishing workflows into single-line steps and tested scripts, tested the dependency-update scripts, fixed the shellcheck finding in modal-precompute.sh, and enforced the CI-script testing and shellcheck rules with empty baselines - - Added CODEOWNERS for the rule machinery, pinned CI's ruff to 0.15.22 with an explicit rule selection, and documented that agents never bypass branch rules - - Ran the Modal image smoke for Stage 12 persistence changes and added policyengine-fastapi to the PR test matrix - - Ran the full PR workflow and the Modal image smoke only on the lowest unmerged pull request in a stack, behind a new CI complete gate job that fails on the others - fixed: - - Stopped trusting the compromised eMudhra (emSign) intermediate CA certificates; every lockfile now pins certifi 2026.10.19, which drops emSign ECC Root CA - G3, every Modal image upgrades Debian's ca-certificates package right after its base layer, and the Modal images and the Cloud Run entrypoint image set GRPC_DEFAULT_SSL_ROOTS_FILE_PATH so grpcio verifies servers against the operating-system CA bundle instead of the CA list compiled into it - - Built UK constituency and local-authority results with policyengine's disclosure-controlled compute_uk_geography_disclosure instead of the deprecated, unprotected functions, in one release nested in regions and the nation; an area that rests on fewer than 10 survey households, is dominated by one, would be recovered by subtraction, or has a non-positive baseline income is left out of the list, so small areas no longer show exploding relative changes such as +1372% for a £192 loss - - Rebuilt the daily dependency update's branch from main on every run and replaced the remote branch with a lease, instead of merging main into the open update PR's branch, which mixed two uv.lock resolutions into locks uv could not parse; the push is skipped when the branch already holds the update, and the open update PR is reused with a rewritten description - - Kept the policyengine.py updater trying to inspect a new release for about ten minutes instead of 90 seconds, and refreshed uv's cached package index on every attempt and on each retried lock, so a release dispatched before PyPI serves it is no longer skipped - - Preserved bounded reform stop dates in UK and US simulations while retaining current-law baseline artifact reuse - - Resolved PolicyEngine-managed regional datasets using bundled data versions instead of release-manifest commit hashes when loading from GCS - - Added a Modal app-release bundle backfill for missing data artifact revisions - - Ensured Stage 12-only deployments run authenticated candidate tests and verify every required deployment phase before reporting success - - Allowed Cloud Run stable endpoint checks to wait for documented traffic propagation before restoring the previous revision - - Added a dedicated read-only Hugging Face credential for private UK runtime datasets, validated it before synchronization, and tested its worker configuration - - Accepted a UK dataset without area codes only when the policyengine.py bundle routes constituency and local-authority regions to another dataset, and kept requiring area codes on every constituency and local-authority run - - Raised a clear error, never an omission, when a UK constituency or local-authority breakdown is built over a dataset without area codes - - Resolved UK constituency and local-authority regions by name or by GSS code in any case once policyengine.py routes them, as row filters from its region registry, so policyengine-api's constituency/{name} and local_authority/{name} requests keep working without weight matrices, and the 17 districts replaced in April 2023 are unsupported regions - - Made the executor and Stage 12 image smokes check the routed dataset's area codes, and that its household table has all four columns the area outputs read (reading only the column names), downloading it with the Hugging Face credential, when the installed UK default carries none, and stopped Stage 12 copying area codes for a routed UK national or nation-level report, whose aggregate leaves the two area breakdowns out - - Released each Stage 12 database session after its operation instead of holding pooled sessions through calculations, retried opening each Stage 12 coordinator and worker database connection that the pooler refused without ever retrying a statement or commit, and reported Stage 12 failures to telemetry before recording them in the database so a failed write cannot hide the original error - - Accepted a null SPM provenance in Stage 12 aggregate artifacts, so UK and other reports without SPM calculations are saved, while every supplied provenance record is still validated - - Recorded a redacted, bounded Stage 12 failure cause naming the exception, failing stage, simulation role and message instead of only the exception class, and kept a failed worker's recorded cause on its simulation and report - - Gave Stage 12 baseline and reform simulations one shared wait window that opens when the coordinator starts and fits inside the coordinator's Modal timeout, and cancelled every unfinished simulation, recording it as failed with simulation_cancelled, when a simulation fails, the window closes, or the coordinator fails after starting a simulation From 58d3e5f1d0189bdd58ca1beb53610d3aa5c6c598 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:51:51 +0400 Subject: [PATCH 07/11] Test that each merge to main is released once before it deploys The tests evaluate every job condition of simulation-deploy.yml with the repository's GitHub Actions expression reader, following needs. A merge to main, whether a merge commit, a squash, a rebase or a squash titled like the release, must run only the versioning job; the release commit it pushes must run only the deploy jobs, so it starts no release of its own; a manual dispatch must deploy without a release. Following the pushes one merge causes must end after two, with one deploy. The versioning job must push with the GitHub App token from APP_ID and APP_PRIVATE_KEY, which starts the next run where the job's own token would not, and run build-changelog.sh then commit-release.sh. Client publication must follow only the release commit's successful push run. commit-release.sh runs against a real local remote: it pushes one commit with the release message and only VERSION, CHANGELOG.md and the removed fragments; it exits 0 when main moved on to a newer merge, whose run releases both; it fails when main no longer holds the merge, when anything else changed, or when nothing did. pr.yml must run the changelog fragment check on the merged result with the base commit, in every full CI run, as a job CI complete needs. The proof fixture is simulation-deploy.yml as it is on main, which deploys every push. These tests fail: there is no versioning job, commit-release.sh or check-changelog job yet. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_release_workflow.py | 519 ++++++++++++++++++ .../bad_simulation-deploy.yml | 88 +++ 2 files changed, 607 insertions(+) create mode 100644 projects/policyengine-simulation-entry/tests/test_release_workflow.py create mode 100644 rules/fixtures/sim.deploy.release-commit-deploys-once/bad_simulation-deploy.yml diff --git a/projects/policyengine-simulation-entry/tests/test_release_workflow.py b/projects/policyengine-simulation-entry/tests/test_release_workflow.py new file mode 100644 index 000000000..7781480ea --- /dev/null +++ b/projects/policyengine-simulation-entry/tests/test_release_workflow.py @@ -0,0 +1,519 @@ +"""Tests for how a merge to main is released, deployed once, and checked first. + +Rule sim.deploy.release-commit-deploys-once. A push to main starts +simulation-deploy.yml. For a merge, only the versioning job runs: it raises +VERSION, builds CHANGELOG.md and pushes one release commit with a fixed +message, using a GitHub App token, so that push starts the workflow again. +For the release commit, only the deploy jobs run, and the versioning job is +skipped, so nothing is released twice and nothing loops. A manual dispatch +deploys without a release. Client publication follows only the release +commit's run, which is the one that deployed. + +The workflow tests evaluate each job's ``if`` with the GitHub Actions +expression reader in test_deployment_assets_support, following ``needs``, so +they test what the conditions decide rather than their text. +commit-release.sh runs against a real local remote. The last tests check +that pr.yml runs the changelog fragment check (rule +sim.process.changelog-fragment) on every pull request CI runs on. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import yaml +from changelog_release_support import ( + GIT_ENVIRONMENT, + commit_all, + git, + write_files, +) +from test_deployment_assets_support import evaluate_github_expression +from workflow_script_support import REPOSITORY_ROOT, WORKFLOWS_DIR, run_script + +if TYPE_CHECKING: + import subprocess + from pathlib import Path + +DEPLOY_WORKFLOW = WORKFLOWS_DIR / "simulation-deploy.yml" +PUBLISH_WORKFLOW = WORKFLOWS_DIR / "publish-clients.yml" +PR_WORKFLOW = WORKFLOWS_DIR / "pr.yml" +REUSABLE_DEPLOY = "./.github/workflows/simulation-deploy.reusable.yml" +BUILD_CHANGELOG = ".github/scripts/build-changelog.sh" +COMMIT_RELEASE = ".github/scripts/commit-release.sh" +RELEASE_MESSAGE = "Update PolicyEngine simulation API" +PROOF_FIXTURES = ( + REPOSITORY_ROOT / "rules" / "fixtures" / "sim.deploy.release-commit-deploys-once" +) +# The head commit message of each way a pull request reaches main. +MERGE_MESSAGES = { + "merge commit": ( + "Merge pull request #982 from PolicyEngine/towncrier-changelog\n\n" + "Move the changelog to Towncrier fragments" + ), + "squash merge": "Move the changelog to Towncrier fragments (#982)", + "rebase merge": "Build CHANGELOG.md when a merge to main is released", + # GitHub adds the pull request number to a squash merge's title. + "squash merge titled like the release": f"{RELEASE_MESSAGE} (#982)", +} +FULL_CI_CONDITION = ( + "${{ !github.event.pull_request.stack || " + "github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref }}" +) +# A release commit must never start another; more pushes than this is a loop. +MAX_PUSHES_PER_MERGE = 5 + + +def _workflow(path: Path) -> dict: + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def _push(message: str) -> dict: + return { + "github": {"event_name": "push", "event": {"head_commit": {"message": message}}} + } + + +def _dispatch() -> dict: + return {"github": {"event_name": "workflow_dispatch", "event": {"inputs": {}}}} + + +def _needs(job: dict) -> list[str]: + needs = job.get("needs") or [] + return [needs] if isinstance(needs, str) else list(needs) + + +def jobs_that_run(workflow: dict, context: dict) -> set[str]: + """Return the jobs of ``workflow`` that run for an event, in GitHub's rules. + + A job runs when its ``if`` holds, or ``success()`` when it has none, given + the results of the jobs it needs: success for a job that ran, skipped for + one that didn't. + """ + jobs = workflow["jobs"] + results: dict[str, str] = {} + while len(results) < len(jobs): + ready = [ + job_id + for job_id, job in jobs.items() + if job_id not in results and all(need in results for need in _needs(job)) + ] + assert ready, f"jobs whose needs never run: {sorted(set(jobs) - set(results))}" + for job_id in ready: + job = jobs[job_id] + needs = {need: {"result": results[need]} for need in _needs(job)} + condition = str(job.get("if", "${{ success() }}")) + runs = evaluate_github_expression( + condition, {**context, "needs": needs}, job_if=True + ) + results[job_id] = "success" if runs else "skipped" + return {job_id for job_id, result in results.items() if result == "success"} + + +def _deploy_jobs(workflow: dict) -> set[str]: + return { + job_id + for job_id, job in workflow["jobs"].items() + if job.get("uses") == REUSABLE_DEPLOY + } + + +def _release_jobs(workflow: dict) -> set[str]: + return { + job_id + for job_id, job in workflow["jobs"].items() + if any(step.get("run") == COMMIT_RELEASE for step in job.get("steps") or []) + } + + +def release_commit_message(workflow: dict) -> str | None: + """Return the message the release job's commit-release.sh step commits with.""" + for job_id in sorted(_release_jobs(workflow)): + for step in workflow["jobs"][job_id]["steps"]: + if step.get("run") == COMMIT_RELEASE: + return (step.get("env") or {}).get("RELEASE_COMMIT_MESSAGE") + return None + + +def _event_violations( + workflow: dict, event: str, context: dict, *, releases: bool, deploys: bool +) -> list[str]: + """Return how the jobs that run for ``event`` differ from what it should run.""" + runs = jobs_that_run(workflow, context) + released = bool(_release_jobs(workflow) & runs) + deployed = sorted(_deploy_jobs(workflow) & runs) + missing = sorted(_deploy_jobs(workflow) - runs) + violations = [] + if releases and not released: + violations.append(f"{event} builds no release") + if not releases and released: + violations.append(f"{event} builds a release") + if deploys and missing: + violations.append(f"{event} does not run {', '.join(missing)}") + if not deploys and deployed: + violations.append( + f"{event} deploys before its release commit: {', '.join(deployed)}" + ) + return violations + + +def release_violations(workflow: dict) -> list[str]: + """Return every way a push to main could deploy unreleased, twice or in a loop.""" + message = release_commit_message(workflow) + violations = [] + if not _release_jobs(workflow): + violations.append(f"no job commits a release with {COMMIT_RELEASE}") + elif not message: + violations.append("the release commit's RELEASE_COMMIT_MESSAGE is not set") + for kind, merge_message in MERGE_MESSAGES.items(): + violations += _event_violations( + workflow, + f"a {kind} to main", + _push(merge_message), + releases=True, + deploys=False, + ) + if message: + violations += _event_violations( + workflow, "the release commit", _push(message), releases=False, deploys=True + ) + violations += _event_violations( + workflow, "a manual dispatch", _dispatch(), releases=False, deploys=True + ) + return violations + + +# --- simulation-deploy.yml ------------------------------------------------------ + + +def test_the_deploy_workflow_releases_each_merge_before_deploying_it(): + assert release_violations(_workflow(DEPLOY_WORKFLOW)) == [] + + +def test_the_release_commit_message_is_fixed(): + assert release_commit_message(_workflow(DEPLOY_WORKFLOW)) == RELEASE_MESSAGE + + +@pytest.mark.parametrize("kind", sorted(MERGE_MESSAGES)) +def test_a_merge_to_main_only_builds_its_release(kind): + workflow = _workflow(DEPLOY_WORKFLOW) + + assert jobs_that_run(workflow, _push(MERGE_MESSAGES[kind])) == {"versioning"} + + +def test_the_release_commit_only_deploys(): + workflow = _workflow(DEPLOY_WORKFLOW) + + assert jobs_that_run(workflow, _push(RELEASE_MESSAGE)) == { + "beta", + "prod", + "summary", + } + + +def test_a_manual_dispatch_deploys_without_a_release(): + workflow = _workflow(DEPLOY_WORKFLOW) + + assert jobs_that_run(workflow, _dispatch()) == {"beta", "prod", "summary"} + + +def test_each_merge_deploys_once_and_its_release_commit_starts_nothing(): + """Follow the pushes one merge causes until none is left.""" + workflow = _workflow(DEPLOY_WORKFLOW) + message = release_commit_message(workflow) + pushes = [MERGE_MESSAGES["merge commit"]] + handled = [] + deploys = 0 + while pushes: + assert len(handled) < MAX_PUSHES_PER_MERGE, f"the pushes loop: {handled}" + head_commit_message = pushes.pop(0) + handled.append(head_commit_message) + runs = jobs_that_run(workflow, _push(head_commit_message)) + if _release_jobs(workflow) & runs: + pushes.append(message) + if _deploy_jobs(workflow) <= runs: + deploys += 1 + + assert handled == [MERGE_MESSAGES["merge commit"], RELEASE_MESSAGE] + assert deploys == 1 + + +def test_the_versioning_job_pushes_its_release_with_the_app_token(): + """A push made with the job's own token would start no deploy run. + + The GitHub App's token starts one, and the App may push to main past the + branch ruleset that requires pull requests. + """ + job = _workflow(DEPLOY_WORKFLOW)["jobs"]["versioning"] + steps = job["steps"] + token_step, checkout_step = steps[0], steps[1] + + assert job["permissions"] == {"contents": "read"} + assert token_step["uses"] == "actions/create-github-app-token@v3" + assert token_step["with"] == { + "app-id": "${{ secrets.APP_ID }}", + "private-key": "${{ secrets.APP_PRIVATE_KEY }}", + "permission-contents": "write", + } + assert checkout_step["uses"] == "actions/checkout@v6" + assert checkout_step["with"] == { + "token": f"${{{{ steps.{token_step['id']}.outputs.token }}}}" + } + runs = [step["run"] for step in steps if "run" in step] + assert runs == [BUILD_CHANGELOG, COMMIT_RELEASE] + + +def test_the_versioning_job_runs_on_the_python_and_uv_ci_pins(): + steps = _workflow(DEPLOY_WORKFLOW)["jobs"]["versioning"]["steps"] + setup = {step["uses"]: step.get("with") for step in steps if "uses" in step} + + assert setup["actions/setup-python@v6"] == {"python-version": "3.13"} + assert setup["astral-sh/setup-uv@v8.1.0"] == {"version": "0.13.6"} + + +def test_rule_proof_release_commit_deploys_once(): + """The workflow before releases deployed every push; the new one deploys once.""" + bad = yaml.safe_load( + (PROOF_FIXTURES / "bad_simulation-deploy.yml").read_text(encoding="utf-8") + ) + good = yaml.safe_load( + (PROOF_FIXTURES / "good_simulation-deploy.yml").read_text(encoding="utf-8") + ) + + assert release_violations(bad) == [ + f"no job commits a release with {COMMIT_RELEASE}", + "a merge commit to main builds no release", + "a merge commit to main deploys before its release commit: beta, prod", + "a squash merge to main builds no release", + "a squash merge to main deploys before its release commit: beta, prod", + "a rebase merge to main builds no release", + "a rebase merge to main deploys before its release commit: beta, prod", + "a squash merge titled like the release to main builds no release", + ( + "a squash merge titled like the release to main deploys before its " + "release commit: beta, prod" + ), + ] + assert release_violations(good) == [] + + +# --- publish-clients.yml -------------------------------------------------------- + + +def _publication_runs(conclusion: str, event: str, message: str) -> bool: + job = _workflow(PUBLISH_WORKFLOW)["jobs"]["publish"] + context = { + "github": { + "event": { + "workflow_run": { + "conclusion": conclusion, + "event": event, + "head_commit": {"message": message}, + } + } + } + } + return bool(evaluate_github_expression(job["if"], context, job_if=True)) + + +def test_clients_publish_after_the_release_commit_deploys(): + assert _publication_runs("success", "push", RELEASE_MESSAGE) + + +@pytest.mark.parametrize( + ("conclusion", "event", "message"), + [ + # The merge's run only built the release; nothing deployed. + ("success", "push", MERGE_MESSAGES["merge commit"]), + ("success", "push", MERGE_MESSAGES["squash merge"]), + ("failure", "push", RELEASE_MESSAGE), + ("cancelled", "push", RELEASE_MESSAGE), + # A manual deployment never published clients, and still doesn't. + ("success", "workflow_dispatch", RELEASE_MESSAGE), + ], +) +def test_clients_publish_after_no_other_run(conclusion, event, message): + assert not _publication_runs(conclusion, event, message) + + +# --- commit-release.sh ---------------------------------------------------------- + + +def _released_repository(tmp_path: Path) -> tuple[Path, Path]: + """Return a clone of a remote main, changed as build-changelog.sh changes it. + + Return the clone and the bare remote it pushes to. + """ + remote = tmp_path / "remote.git" + git(tmp_path, "init", "--quiet", "--bare", "--initial-branch", "main", str(remote)) + seed = tmp_path / "seed" + git(tmp_path, "clone", "--quiet", str(remote), str(seed)) + write_files( + seed, + { + "VERSION": "0.1.0\n", + "CHANGELOG.md": "# Changelog\n\n\n", + "README.md": "Readme.\n", + "changelog.d/.gitkeep": "", + "changelog.d/982.added.md": "Built CHANGELOG.md at each release.\n", + }, + ) + commit_all(seed, "Merge pull request #982 from PolicyEngine/towncrier-changelog") + git(seed, "push", "--quiet", "origin", "HEAD:refs/heads/main") + + clone = tmp_path / "clone" + git(tmp_path, "clone", "--quiet", str(remote), str(clone)) + write_files( + clone, + { + "VERSION": "0.2.0\n", + "CHANGELOG.md": ( + "# Changelog\n\n\n\n" + "## [0.2.0] - 2026-10-28\n\n### Added\n\n" + "- Built CHANGELOG.md at each release.\n" + ), + # An untracked file the build left behind; no release commits it. + "stray-build-output.txt": "not part of the release\n", + }, + ) + git(clone, "add", "CHANGELOG.md") + git(clone, "rm", "--quiet", "changelog.d/982.added.md") + return clone, remote + + +def _commit_release( + clone: Path, **environment: str +) -> subprocess.CompletedProcess[str]: + return run_script( + "commit-release.sh", + env={ + **GIT_ENVIRONMENT, + "RELEASE_COMMIT_MESSAGE": RELEASE_MESSAGE, + **environment, + }, + cwd=clone, + ) + + +def _remote_main(remote: Path) -> str: + return git(remote, "rev-parse", "refs/heads/main") + + +def test_commit_release_pushes_one_release_commit_to_main(tmp_path): + clone, remote = _released_repository(tmp_path) + merged = git(clone, "rev-parse", "HEAD") + + result = _commit_release(clone) + + assert result.returncode == 0, result.stdout + result.stderr + released = _remote_main(remote) + assert git(remote, "rev-parse", f"{released}^") == merged + assert git(remote, "log", "-1", "--format=%B", released) == RELEASE_MESSAGE + assert git(remote, "log", "-1", "--format=%an <%ae>", released) == ( + "github-actions[bot] " + ) + assert git( + remote, "diff-tree", "--no-commit-id", "--name-status", "-r", released + ).splitlines() == [ + "M\tCHANGELOG.md", + "M\tVERSION", + "D\tchangelog.d/982.added.md", + ] + assert f"Pushed release commit {released} to main" in result.stdout + + +def test_commit_release_leaves_the_release_to_a_newer_merge(tmp_path): + """Another merge's run waits for this one, and releases both merges' fragments.""" + clone, remote = _released_repository(tmp_path) + other = tmp_path / "other" + git(tmp_path, "clone", "--quiet", str(remote), str(other)) + write_files(other, {"changelog.d/983.fixed.md": "Fixed another thing.\n"}) + newer_merge = commit_all(other, "Merge pull request #983") + git(other, "push", "--quiet", "origin", "HEAD:refs/heads/main") + + result = _commit_release(clone) + + assert result.returncode == 0, result.stdout + result.stderr + assert _remote_main(remote) == newer_merge + assert f"::notice::main moved on to {newer_merge}" in result.stdout + + +def test_commit_release_fails_when_main_no_longer_holds_the_merge(tmp_path): + clone, remote = _released_repository(tmp_path) + other = tmp_path / "other" + git(tmp_path, "init", "--quiet", "--initial-branch", "main", str(other)) + write_files(other, {"README.md": "Unrelated history.\n"}) + unrelated = commit_all(other, "Unrelated") + git(other, "push", "--quiet", "--force", str(remote), "HEAD:refs/heads/main") + + result = _commit_release(clone) + + assert result.returncode == 1 + assert _remote_main(remote) == unrelated + assert "::error::Could not push the release commit to main" in result.stdout + + +def test_commit_release_refuses_a_change_outside_the_release(tmp_path): + clone, remote = _released_repository(tmp_path) + before = _remote_main(remote) + write_files(clone, {"README.md": "Changed by the build.\n"}) + + result = _commit_release(clone) + + assert result.returncode == 1 + assert "README.md" in result.stdout + assert _remote_main(remote) == before + + +def test_commit_release_refuses_an_empty_release(tmp_path): + clone, remote = _released_repository(tmp_path) + git(clone, "reset", "--quiet", "--hard") + before = _remote_main(remote) + + result = _commit_release(clone) + + assert result.returncode == 1 + assert "::error::Nothing to release" in result.stdout + assert _remote_main(remote) == before + + +def test_commit_release_requires_the_message(tmp_path): + clone, _remote = _released_repository(tmp_path) + + result = _commit_release(clone, RELEASE_COMMIT_MESSAGE="") + + assert result.returncode != 0 + assert "RELEASE_COMMIT_MESSAGE is required" in result.stderr + + +# --- pr.yml --------------------------------------------------------------------- + + +def test_every_full_ci_run_checks_the_changelog_fragment(): + jobs = _workflow(PR_WORKFLOW)["jobs"] + job = jobs["check-changelog"] + + assert job["if"] == FULL_CI_CONDITION + assert "needs" not in job + assert "check-changelog" in jobs["ci-complete"]["needs"] + assert job["permissions"] == {"contents": "read"} + + +def test_the_fragment_check_compares_the_merged_result_with_its_base(): + steps = _workflow(PR_WORKFLOW)["jobs"]["check-changelog"]["steps"] + checkout, python, check = steps + + assert checkout["uses"] == "actions/checkout@v6" + # The merge base needs the base branch's history. + assert checkout["with"] == {"fetch-depth": 0} + assert python == { + "name": "Set up Python", + "uses": "actions/setup-python@v6", + "with": {"python-version": "3.13"}, + } + assert check == { + "name": "Require a changelog fragment", + "env": {"BASE_SHA": "${{ github.event.pull_request.base.sha }}"}, + "run": ".github/scripts/check-changelog-fragment.sh", + } diff --git a/rules/fixtures/sim.deploy.release-commit-deploys-once/bad_simulation-deploy.yml b/rules/fixtures/sim.deploy.release-commit-deploys-once/bad_simulation-deploy.yml new file mode 100644 index 000000000..4a0de8191 --- /dev/null +++ b/rules/fixtures/sim.deploy.release-commit-deploys-once/bad_simulation-deploy.yml @@ -0,0 +1,88 @@ +# simulation-deploy.yml on main before releases (ae72776b): every push to main +# deployed, so nothing built a release first. Proof fixture for rule +# sim.deploy.release-commit-deploys-once. +name: Deploy Simulation API + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + force_latest: + description: "Force latest routing pointers for an intentional rollback" + required: false + default: false + type: boolean + force_recompute: + description: "Recompute artifacts even when the store already has them" + required: false + default: false + type: boolean + +permissions: + contents: read + +concurrency: + # Serialize routing writes across deployments, Stage 12, and rollbacks. + group: simulation-deploy-main + cancel-in-progress: false + +jobs: + beta: + name: Complete beta deployment + permissions: + contents: read + id-token: write + uses: ./.github/workflows/simulation-deploy.reusable.yml + with: + release_environment: beta + deployment_environment: staging + modal_environment: staging + entrypoint_service: policyengine-simulation-entry-staging + entrypoint_revision_prefix: s- + entrypoint_min_instances: 0 + entrypoint_max_instances: 2 + promote_entrypoint: false + run_full_integration: true + deploy_existing_stack: true + deploy_stage12_v2: true + force_latest: ${{ inputs.force_latest || false }} + force_recompute: ${{ inputs.force_recompute || false }} + secrets: inherit + + prod: + name: Complete prod deployment + needs: beta + permissions: + contents: read + id-token: write + uses: ./.github/workflows/simulation-deploy.reusable.yml + with: + release_environment: prod + deployment_environment: production + modal_environment: main + entrypoint_service: policyengine-simulation-entry + entrypoint_revision_prefix: p- + entrypoint_min_instances: 1 + entrypoint_max_instances: 20 + promote_entrypoint: true + run_full_integration: false + deploy_existing_stack: true + # Deploy Stage 12 in production. The prod environment's STAGE12_ENABLED + # variable independently controls automatic invocation from report requests. + deploy_stage12_v2: true + force_latest: ${{ inputs.force_latest || false }} + force_recompute: ${{ inputs.force_recompute || false }} + secrets: inherit + + summary: + name: Deployment summary + needs: [beta, prod] + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Report promotion status + run: .github/scripts/simulation-deployment-summary.sh "${{ needs.beta.result }}" "${{ needs.beta.outputs.simulation_api_url }}" "${{ needs.prod.result }}" "${{ needs.prod.outputs.stable_url }}" From 505b72be9cfe67d781e5febad21165d8a5ba9c48 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:56:20 +0400 Subject: [PATCH 08/11] Release each merge to main before it deploys, and check for fragments simulation-deploy.yml gets a versioning job, mirroring the one in policyengine-api's push.yml. For a push to main whose head commit is not the release commit, it creates a GitHub App token from APP_ID and APP_PRIVATE_KEY with contents: write, checks out with it, runs build-changelog.sh, and runs commit-release.sh, which commits VERSION, CHANGELOG.md and the removed fragments as "Update PolicyEngine simulation API" and pushes it to main. The App's push starts the workflow again. The beta deploy, and so prod, and the summary now run only for that release commit or a manual dispatch, and the versioning job never runs for either, so each merge deploys once and the release commit starts no release. publish-clients.yml publishes only after the release commit's run. The workflow-level concurrency group is unchanged. When main moves on to a newer merge while a release is built, commit-release.sh exits 0 instead of failing: the newer merge's run waits in the same concurrency group and releases both merges' fragments. pr.yml gets the check-changelog job, which runs check-changelog-fragment.sh on the merged result against github.event.pull_request.base.sha, with the stack condition, and CI complete needs it. The registry gains sim.process.changelog-fragment, enforced by that job and proven with policyengine-api #4084's fragment, and sim.deploy.release-commit-deploys-once, enforced by the workflow tests and proven with simulation-deploy.yml as it was. rules.md and AGENTS.md are re-rendered. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/commit-release.sh | 64 ++++++++ .github/workflows/pr.yml | 31 +++- .github/workflows/publish-clients.yml | 4 +- .github/workflows/simulation-deploy.yml | 62 +++++++- AGENTS.md | 4 +- docs/generated/rules.md | 15 +- .../good_simulation-deploy.yml | 148 ++++++++++++++++++ rules/registry.toml | 60 +++++++ 8 files changed, 381 insertions(+), 7 deletions(-) create mode 100755 .github/scripts/commit-release.sh create mode 100644 rules/fixtures/sim.deploy.release-commit-deploys-once/good_simulation-deploy.yml diff --git a/.github/scripts/commit-release.sh b/.github/scripts/commit-release.sh new file mode 100755 index 000000000..214ebf6cf --- /dev/null +++ b/.github/scripts/commit-release.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +# Commit the release build-changelog.sh prepared, and push it to main. +# +# The release commit holds only the raised VERSION, the rebuilt CHANGELOG.md +# and the removal of the fragments Towncrier built it from; any other change +# to a tracked file fails the script. Its whole message is +# RELEASE_COMMIT_MESSAGE. simulation-deploy.yml deploys a push to main whose +# head commit has that message and releases every other push, so this push +# deploys, and releases nothing again (rule +# sim.deploy.release-commit-deploys-once). +# +# git pushes with the GitHub App token the checkout stored. The App is on the +# main branch ruleset's bypass list, so it may push without a pull request. +# +# When main moved on to a newer merge while this release was built, the push +# is refused. That merge's run waits for this one in the deployment +# concurrency group, and its release builds these fragments too, so the script +# says so and exits 0. Any other refusal fails it. +# +# Environment: +# RELEASE_COMMIT_MESSAGE the release commit's message + +set -euo pipefail + +message="${RELEASE_COMMIT_MESSAGE:?RELEASE_COMMIT_MESSAGE is required}" +release_paths=(VERSION CHANGELOG.md changelog.d) + +merge_commit="$(git rev-parse HEAD)" +git add --all -- "${release_paths[@]}" + +outside_release="$(git status --porcelain --untracked-files=no -- . "${release_paths[@]/#/:!}")" +if [[ -n "${outside_release}" ]]; then + echo "::error::The release build changed files outside VERSION, CHANGELOG.md and changelog.d/; nothing was pushed:" + echo "${outside_release}" + exit 1 +fi +if git diff --cached --quiet; then + echo "::error::Nothing to release: build-changelog.sh changed none of VERSION, CHANGELOG.md and changelog.d/." + exit 1 +fi + +# The identity variables take precedence over any git configuration. +bot_name="github-actions[bot]" +bot_email="github-actions[bot]@users.noreply.github.com" +GIT_AUTHOR_NAME="${bot_name}" GIT_AUTHOR_EMAIL="${bot_email}" \ + GIT_COMMITTER_NAME="${bot_name}" GIT_COMMITTER_EMAIL="${bot_email}" \ + git commit --quiet --message "${message}" +release_commit="$(git rev-parse HEAD)" + +if git push origin "HEAD:refs/heads/main"; then + echo "Pushed release commit ${release_commit} to main." + exit 0 +fi + +git fetch --quiet origin main +remote_main="$(git rev-parse FETCH_HEAD)" +if [[ "${remote_main}" != "${merge_commit}" ]] \ + && git merge-base --is-ancestor "${merge_commit}" "${remote_main}"; then + echo "::notice::main moved on to ${remote_main} while this release was built. The run for that push releases these fragments with its own." + exit 0 +fi +echo "::error::Could not push the release commit to main, and main (${remote_main}) does not hold the merge ${merge_commit} this run released." +exit 1 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 24cc27c3c..548fa8063 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,7 +16,7 @@ jobs: # every job passed. See docs/engineering/skills/github-prs.md. name: CI complete if: ${{ always() }} - needs: [test, lint, pyright, quality-guards, docker-build, test-integration] + needs: [test, lint, pyright, quality-guards, check-changelog, docker-build, test-integration] runs-on: ubuntu-latest permissions: contents: read @@ -145,6 +145,35 @@ jobs: GH_TOKEN: ${{ github.token }} run: python scripts/run_quality_guards.py + check-changelog: + # Rule sim.process.changelog-fragment: every pull request adds a changelog + # fragment the release can read. See + # docs/engineering/skills/github-prs.md#changelog-fragments. + name: Changelog fragment + # Full CI runs only on the lowest unmerged pull request in a stack. + if: ${{ !github.event.pull_request.stack || github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + + steps: + - uses: actions/checkout@v6 + with: + # The check diffs the merged result against its merge base with the + # base branch, so it needs the base branch's history. + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Require a changelog fragment + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: .github/scripts/check-changelog-fragment.sh + pyright: name: Pyright # Full CI runs only on the lowest unmerged pull request in a stack. diff --git a/.github/workflows/publish-clients.yml b/.github/workflows/publish-clients.yml index bec9598be..97a83c1be 100644 --- a/.github/workflows/publish-clients.yml +++ b/.github/workflows/publish-clients.yml @@ -13,7 +13,9 @@ jobs: publish: name: Publish API clients runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' }} + # Only the release commit's push run deploys; a merge's run only builds + # its release (see simulation-deploy.yml). + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_commit.message == 'Update PolicyEngine simulation API' }} environment: pypi permissions: contents: read diff --git a/.github/workflows/simulation-deploy.yml b/.github/workflows/simulation-deploy.yml index ab3d6d6f4..5e2a9f533 100644 --- a/.github/workflows/simulation-deploy.yml +++ b/.github/workflows/simulation-deploy.yml @@ -1,5 +1,21 @@ name: Deploy Simulation API +# A merge to main is released, then deployed (rule +# sim.deploy.release-commit-deploys-once): +# +# 1. The merge's push runs only the versioning job. It raises VERSION and +# builds CHANGELOG.md from the pending changelog fragments, and pushes one +# release commit, "Update PolicyEngine simulation API", with the GitHub App +# token. A push made with that token starts this workflow again; one made +# with the job's own token would not. +# 2. The release commit's push runs only the deploy jobs. The versioning job +# is skipped for it, so nothing is released twice and nothing loops. +# +# A manual dispatch deploys the chosen branch without a release. Each `if` +# below repeats the release commit's message, which the commit step sets; +# projects/policyengine-simulation-entry/tests/test_release_workflow.py checks +# that they agree. See docs/engineering/skills/github-prs.md#changelog-fragments. + on: push: branches: @@ -26,8 +42,51 @@ concurrency: cancel-in-progress: false jobs: + versioning: + name: Release the merge + if: ${{ github.event_name == 'push' && github.event.head_commit.message != 'Update PolicyEngine simulation API' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + # The release commit is pushed with the GitHub App's token, not this one. + permissions: + contents: read + 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 }} + permission-contents: write + + # git push in commit-release.sh uses the token the checkout stores. + - name: Check out the merge + 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 + with: + version: "0.13.6" + + - name: Raise VERSION and build CHANGELOG.md from the fragments + run: .github/scripts/build-changelog.sh + + - name: Push the release commit to main + env: + RELEASE_COMMIT_MESSAGE: Update PolicyEngine simulation API + run: .github/scripts/commit-release.sh + beta: name: Complete beta deployment + # Only the release commit, or a manual dispatch, deploys. + if: ${{ github.event_name == 'workflow_dispatch' || github.event.head_commit.message == 'Update PolicyEngine simulation API' }} permissions: contents: read id-token: write @@ -76,7 +135,8 @@ jobs: summary: name: Deployment summary needs: [beta, prod] - if: always() + # After every deployment, even a failed one; never for a merge's release. + if: ${{ always() && (github.event_name == 'workflow_dispatch' || github.event.head_commit.message == 'Update PolicyEngine simulation API') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/AGENTS.md b/AGENTS.md index 56d03c7d4..ac70ab234 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,7 +124,7 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`. ### Deployment pipeline -- Checked: `sim.deploy.container-context-minimal`, `sim.deploy.docker-images-build`, `sim.deploy.gcloud-without-terraform`, `sim.deploy.modal-image-smoke`, `sim.deploy.numerical-sentinel-gate`, `sim.deploy.permission-enforcement-wired`, `sim.deploy.promotion-order-explicit`, `sim.deploy.release-identity-smoke`, `sim.deploy.revision-tags-valid`, `sim.deploy.routing-publication-journaled`, `sim.deploy.shell-scripts-valid`, `sim.deploy.smoke-tests-strict`, `sim.deploy.traffic-changes-exact`, `sim.deploy.workflow-shell-safety` +- Checked: `sim.deploy.container-context-minimal`, `sim.deploy.docker-images-build`, `sim.deploy.gcloud-without-terraform`, `sim.deploy.modal-image-smoke`, `sim.deploy.numerical-sentinel-gate`, `sim.deploy.permission-enforcement-wired`, `sim.deploy.promotion-order-explicit`, `sim.deploy.release-commit-deploys-once`, `sim.deploy.release-identity-smoke`, `sim.deploy.revision-tags-valid`, `sim.deploy.routing-publication-journaled`, `sim.deploy.shell-scripts-valid`, `sim.deploy.smoke-tests-strict`, `sim.deploy.traffic-changes-exact`, `sim.deploy.workflow-shell-safety` - `sim.deploy.image-smoke-covers-every-image-lib`: The image smoke path filter includes every library mounted into a Modal image, so a change to any of them runs the smoke. (doc-only, not checked: follow it yourself) ### API contracts and integration @@ -160,7 +160,7 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`. ### Process and repository hygiene -- Checked: `sim.agents.adapters-point-to-agents-md`, `sim.process.pr-tracking-issue`, `sim.process.rules-registry`, `sim.process.stacked-prs-full-ci-at-bottom` +- Checked: `sim.agents.adapters-point-to-agents-md`, `sim.process.changelog-fragment`, `sim.process.pr-tracking-issue`, `sim.process.rules-registry`, `sim.process.stacked-prs-full-ci-at-bottom` - `sim.process.agents-never-bypass`: Agents never bypass branch rules, even with admin credentials: no gh pr merge --admin and no bypass option; a blocked merge is reported to a human. (doc-only, not checked: follow it yourself) - `sim.process.rule-machinery-code-owned`: Changes to the rule machinery (rules/, scripts/rules/, scripts/run_quality_guards.py and CODEOWNERS) need approval from a core developer other than the author. (doc-only, not checked: follow it yourself) - `sim.process.same-repo-draft-prs`: PRs are opened as drafts from branches in PolicyEngine/policyengine-sim-api, never from forks. (doc-only, not checked: follow it yourself) diff --git a/docs/generated/rules.md b/docs/generated/rules.md index e2304ea3d..d9b80a709 100644 --- a/docs/generated/rules.md +++ b/docs/generated/rules.md @@ -8,11 +8,11 @@ Every rule that governs `policyengine-sim-api` is listed here with how it is enf | Tier | enforced | ratcheted | advisory | doc-only | Total | | --- | ---: | ---: | ---: | ---: | ---: | -| mechanical | 54 | 2 | 0 | 14 | 70 | +| mechanical | 56 | 2 | 0 | 14 | 72 | | metric | 1 | 0 | 0 | 0 | 1 | | judgment | 0 | 0 | 0 | 0 | 0 | -**Blocking:** 57 of 71 rules. +**Blocking:** 59 of 73 rules. ## Gaps @@ -150,6 +150,7 @@ flowchart LR | `sim.deploy.numerical-sentinel-gate` | An existing-stack deployment computes the numerical release sentinel from the exact bundle it installs, and the candidate and stable services must reproduce it; a failing sentinel check fails its step. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_promotion.py`, `projects/policyengine-simulation-entry/tests/test_deployment_assets_release_gates.py` | `pr.yml:test` | **unproven** | | `sim.deploy.permission-enforcement-wired` | Simulation Entrypoint permission enforcement defaults to log-only, rejects any value other than 0 or 1, and reaches the candidate revision and every deployed authentication run. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py` | `pr.yml:test` | **unproven** | | `sim.deploy.promotion-order-explicit` | Full-stack promotion order is explicit, and the reusable deployment rejects unexpectedly skipped phases. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_promotion.py` | `pr.yml:test` | **unproven** | +| `sim.deploy.release-commit-deploys-once` | A merge to main deploys only through the release commit the versioning job in simulation-deploy.yml pushes with the GitHub App token: every other push to main runs only that job, the release commit runs only the deploy jobs and never another release, a manual dispatch deploys without a release, and client publication follows only the release commit's run, so each merge deploys once and nothing loops. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_release_workflow.py` | `pr.yml:test` | yes | | `sim.deploy.release-identity-smoke` | Deploy gates pass the whole release identity to the entrypoint smoke, which requires every expectation, refuses malformed ones, and rejects a release whose routes, latest pointers, bundle, UK model or UK dataset differ from that identity. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_identity_smoke.py`, `projects/policyengine-simulation-entry/tests/test_deployment_assets_release_gates.py`, `projects/policyengine-simulation-entry/tests/test_deployment_assets_routing.py` | `pr.yml:test` | **unproven** | | `sim.deploy.revision-tags-valid` | Cloud Run revision tags are valid on a workflow's first run and fit the existing service names. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py` | `pr.yml:test` | **unproven** | | `sim.deploy.routing-publication-journaled` | Each routing publication is named by its run, attempt and environment and journaled before it is applied; an always-running finalizer resolves it from the journal with the release-gate outcome, a release reports success only after that finalizer completes, and routing jobs never tolerate a failed command. | mechanical | enforced | yes | pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets_release_gates.py`, `projects/policyengine-simulation-entry/tests/test_deployment_assets_routing.py` | `pr.yml:test` | **unproven** | @@ -179,6 +180,8 @@ flowchart LR e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_cloud_run_py["pytest projects/policyengine-simulation-entry/tests/test_deployment_assets_cloud_run.py"] r_sim_deploy_promotion_order_explicit("sim.deploy.promotion-order-explicit (enforced)") e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_promotion_py["pytest projects/policyengine-simulation-entry/tests/test_deployment_assets_promotion.py"] + r_sim_deploy_release_commit_deploys_once("sim.deploy.release-commit-deploys-once (enforced)") + e_pytest_projects_policyengine_simulation_entry_tests_test_release_workflow_py["pytest projects/policyengine-simulation-entry/tests/test_release_workflow.py"] r_sim_deploy_release_identity_smoke("sim.deploy.release-identity-smoke (enforced)") e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_identity_smoke_py__projects_policyengine_simulation_entry_tests_test_deployment_assets_release_gates_py__projects_policyengine_simulation_entry_tests_test_deployment_assets_routing_py["pytest projects/policyengine-simulation-entry/tests/test_deployment_assets_identity_smoke.py, projects/policyengine-simulation-entry/tests/test_deployment_assets_release_gates.py, projects/policyengine-simulation-entry/tests/test_deployment_assets_routing.py"] r_sim_deploy_revision_tags_valid("sim.deploy.revision-tags-valid (enforced)") @@ -204,6 +207,8 @@ flowchart LR e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_cloud_run_py --> j_CI_pr_yml_test r_sim_deploy_promotion_order_explicit --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_promotion_py e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_promotion_py --> j_CI_pr_yml_test + r_sim_deploy_release_commit_deploys_once --> e_pytest_projects_policyengine_simulation_entry_tests_test_release_workflow_py + e_pytest_projects_policyengine_simulation_entry_tests_test_release_workflow_py --> j_CI_pr_yml_test r_sim_deploy_release_identity_smoke --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_identity_smoke_py__projects_policyengine_simulation_entry_tests_test_deployment_assets_release_gates_py__projects_policyengine_simulation_entry_tests_test_deployment_assets_routing_py e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_identity_smoke_py__projects_policyengine_simulation_entry_tests_test_deployment_assets_release_gates_py__projects_policyengine_simulation_entry_tests_test_deployment_assets_routing_py --> j_CI_pr_yml_test r_sim_deploy_revision_tags_valid --> e_pytest_projects_policyengine_simulation_entry_tests_test_deployment_assets_cloud_run_py @@ -456,6 +461,7 @@ flowchart LR | --- | --- | --- | --- | --- | --- | --- | --- | | `sim.agents.adapters-point-to-agents-md` | CLAUDE.md and .github/copilot-instructions.md only point to AGENTS.md; skills and rules are listed in AGENTS.md alone. | mechanical | enforced | yes | quality guard `rules-registry` | `pr.yml:quality-guards` | yes | | `sim.process.agents-never-bypass` | Agents never bypass branch rules, even with admin credentials: no gh pr merge --admin and no bypass option; a blocked merge is reported to a human. | mechanical | doc-only | no | nothing | — | — | +| `sim.process.changelog-fragment` | Every pull request adds at least one changelog fragment: a non-empty file directly in changelog.d/, named ..md with a type towncrier.toml lists. Every file in changelog.d/ other than .gitkeep is such a fragment, and the release's version bump reads fragments by the same definition, so the check accepts exactly what the release builds. | mechanical | enforced | yes | CI job `check-changelog` | `pr.yml:check-changelog` | yes | | `sim.process.pr-tracking-issue` | The first line of every PR's description is Fixes #ISSUE_NUMBER, naming an open issue, not a pull request, in this repository; automated PRs open or reuse such an issue. | mechanical | enforced | yes | CI job `pr-description` | `pr-description.yml:pr-description` | yes | | `sim.process.rule-machinery-code-owned` | Changes to the rule machinery (rules/, scripts/rules/, scripts/run_quality_guards.py and CODEOWNERS) need approval from a core developer other than the author. | mechanical | doc-only | no | nothing | — | — | | `sim.process.rules-registry` | Every rule is registered in rules/registry.toml, is enforced where it claims to be, and its rendered views are current. | mechanical | enforced | yes | quality guard `rules-registry` | `pr.yml:quality-guards` | **unproven** | @@ -469,6 +475,9 @@ flowchart LR j_CI_pr_yml_quality_guards["CI pr.yml:quality-guards"] r_sim_process_agents_never_bypass("sim.process.agents-never-bypass (doc-only)") gap_unenforced>"not enforced"] + r_sim_process_changelog_fragment("sim.process.changelog-fragment (enforced)") + e_CI_job_check_changelog["CI job check-changelog"] + j_CI_pr_yml_check_changelog["CI pr.yml:check-changelog"] r_sim_process_pr_tracking_issue("sim.process.pr-tracking-issue (enforced)") e_CI_job_pr_description["CI job pr-description"] j_CI_pr_description_yml_pr_description["CI pr-description.yml:pr-description"] @@ -482,6 +491,8 @@ flowchart LR r_sim_agents_adapters_point_to_agents_md --> e_quality_guard_rules_registry e_quality_guard_rules_registry --> j_CI_pr_yml_quality_guards r_sim_process_agents_never_bypass -.-> gap_unenforced + r_sim_process_changelog_fragment --> e_CI_job_check_changelog + e_CI_job_check_changelog --> j_CI_pr_yml_check_changelog r_sim_process_pr_tracking_issue --> e_CI_job_pr_description e_CI_job_pr_description --> j_CI_pr_description_yml_pr_description r_sim_process_rule_machinery_code_owned -.-> gap_unenforced diff --git a/rules/fixtures/sim.deploy.release-commit-deploys-once/good_simulation-deploy.yml b/rules/fixtures/sim.deploy.release-commit-deploys-once/good_simulation-deploy.yml new file mode 100644 index 000000000..af1441142 --- /dev/null +++ b/rules/fixtures/sim.deploy.release-commit-deploys-once/good_simulation-deploy.yml @@ -0,0 +1,148 @@ +# simulation-deploy.yml as the release commit change left it: a merge to main +# runs only the versioning job, and its release commit only the deploy jobs. +# Proof fixture for rule sim.deploy.release-commit-deploys-once. +name: Deploy Simulation API + +# A merge to main is released, then deployed (rule +# sim.deploy.release-commit-deploys-once): +# +# 1. The merge's push runs only the versioning job. It raises VERSION and +# builds CHANGELOG.md from the pending changelog fragments, and pushes one +# release commit, "Update PolicyEngine simulation API", with the GitHub App +# token. A push made with that token starts this workflow again; one made +# with the job's own token would not. +# 2. The release commit's push runs only the deploy jobs. The versioning job +# is skipped for it, so nothing is released twice and nothing loops. +# +# A manual dispatch deploys the chosen branch without a release. Each `if` +# below repeats the release commit's message, which the commit step sets; +# projects/policyengine-simulation-entry/tests/test_release_workflow.py checks +# that they agree. See docs/engineering/skills/github-prs.md#changelog-fragments. + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + force_latest: + description: "Force latest routing pointers for an intentional rollback" + required: false + default: false + type: boolean + force_recompute: + description: "Recompute artifacts even when the store already has them" + required: false + default: false + type: boolean + +permissions: + contents: read + +concurrency: + # Serialize routing writes across deployments, Stage 12, and rollbacks. + group: simulation-deploy-main + cancel-in-progress: false + +jobs: + versioning: + name: Release the merge + if: ${{ github.event_name == 'push' && github.event.head_commit.message != 'Update PolicyEngine simulation API' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + # The release commit is pushed with the GitHub App's token, not this one. + permissions: + contents: read + 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 }} + permission-contents: write + + # git push in commit-release.sh uses the token the checkout stores. + - name: Check out the merge + 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 + with: + version: "0.13.6" + + - name: Raise VERSION and build CHANGELOG.md from the fragments + run: .github/scripts/build-changelog.sh + + - name: Push the release commit to main + env: + RELEASE_COMMIT_MESSAGE: Update PolicyEngine simulation API + run: .github/scripts/commit-release.sh + + beta: + name: Complete beta deployment + # Only the release commit, or a manual dispatch, deploys. + if: ${{ github.event_name == 'workflow_dispatch' || github.event.head_commit.message == 'Update PolicyEngine simulation API' }} + permissions: + contents: read + id-token: write + uses: ./.github/workflows/simulation-deploy.reusable.yml + with: + release_environment: beta + deployment_environment: staging + modal_environment: staging + entrypoint_service: policyengine-simulation-entry-staging + entrypoint_revision_prefix: s- + entrypoint_min_instances: 0 + entrypoint_max_instances: 2 + promote_entrypoint: false + run_full_integration: true + deploy_existing_stack: true + deploy_stage12_v2: true + force_latest: ${{ inputs.force_latest || false }} + force_recompute: ${{ inputs.force_recompute || false }} + secrets: inherit + + prod: + name: Complete prod deployment + needs: beta + permissions: + contents: read + id-token: write + uses: ./.github/workflows/simulation-deploy.reusable.yml + with: + release_environment: prod + deployment_environment: production + modal_environment: main + entrypoint_service: policyengine-simulation-entry + entrypoint_revision_prefix: p- + entrypoint_min_instances: 1 + entrypoint_max_instances: 20 + promote_entrypoint: true + run_full_integration: false + deploy_existing_stack: true + # Deploy Stage 12 in production. The prod environment's STAGE12_ENABLED + # variable independently controls automatic invocation from report requests. + deploy_stage12_v2: true + force_latest: ${{ inputs.force_latest || false }} + force_recompute: ${{ inputs.force_recompute || false }} + secrets: inherit + + summary: + name: Deployment summary + needs: [beta, prod] + # After every deployment, even a failed one; never for a merge's release. + if: ${{ always() && (github.event_name == 'workflow_dispatch' || github.event.head_commit.message == 'Update PolicyEngine simulation API') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Report promotion status + run: .github/scripts/simulation-deployment-summary.sh "${{ needs.beta.result }}" "${{ needs.beta.outputs.simulation_api_url }}" "${{ needs.prod.result }}" "${{ needs.prod.outputs.stable_url }}" diff --git a/rules/registry.toml b/rules/registry.toml index 3eb7f4ca6..f02e2ba7f 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -530,6 +530,39 @@ workflow = ".github/workflows/pr.yml" job = "test" invocation = "uv run --frozen pytest tests/" +[[rule]] +id = "sim.deploy.release-commit-deploys-once" +category = "deployment" +statement = "A merge to main deploys only through the release commit the versioning job in simulation-deploy.yml pushes with the GitHub App token: every other push to main runs only that job, the release commit runs only the deploy jobs and never another release, a manual dispatch deploys without a release, and client publication follows only the release commit's run, so each merge deploys once and nothing loops." +rationale = "docs/engineering/skills/github-prs.md#changelog-fragments" +scope = [".github/workflows/simulation-deploy.yml", ".github/workflows/publish-clients.yml", ".github/scripts/build-changelog.sh", ".github/scripts/commit-release.sh"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "pytest" +project = "projects/policyengine-simulation-entry" +nodes = [ + "tests/test_release_workflow.py::test_the_deploy_workflow_releases_each_merge_before_deploying_it", + "tests/test_release_workflow.py::test_each_merge_deploys_once_and_its_release_commit_starts_nothing", + "tests/test_release_workflow.py::test_the_versioning_job_pushes_its_release_with_the_app_token", + "tests/test_release_workflow.py::test_clients_publish_after_the_release_commit_deploys", + "tests/test_release_workflow.py::test_clients_publish_after_no_other_run", + "tests/test_release_workflow.py::test_commit_release_pushes_one_release_commit_to_main", + "tests/test_release_workflow.py::test_commit_release_leaves_the_release_to_a_newer_merge", +] +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "test" +invocation = "uv run --frozen pytest tests/" +[rule.proof] +# The bad fixture is simulation-deploy.yml as it was on main before releases, +# when every push to main deployed. +bad_fixture = "rules/fixtures/sim.deploy.release-commit-deploys-once/bad_simulation-deploy.yml" +good_fixture = "rules/fixtures/sim.deploy.release-commit-deploys-once/good_simulation-deploy.yml" +project = "projects/policyengine-simulation-entry" +test = "tests/test_release_workflow.py::test_rule_proof_release_commit_deploys_once" + [[rule]] id = "sim.deploy.container-context-minimal" category = "deployment" @@ -1554,6 +1587,33 @@ workflow = ".github/workflows/pr.yml" job = "test" invocation = "uv run --frozen pytest tests/" +[[rule]] +id = "sim.process.changelog-fragment" +category = "process" +statement = "Every pull request adds at least one changelog fragment: a non-empty file directly in changelog.d/, named ..md with a type towncrier.toml lists. Every file in changelog.d/ other than .gitkeep is such a fragment, and the release's version bump reads fragments by the same definition, so the check accepts exactly what the release builds." +rationale = "docs/engineering/skills/github-prs.md#changelog-fragments" +scope = [".github/workflows/pr.yml", ".github/scripts/check-changelog-fragment.sh", ".github/scripts/changelog_fragments.py", "towncrier.toml", "changelog.d/.gitkeep"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "workflow-job" +step = "Require a changelog fragment" +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "check-changelog" +invocation = "check-changelog-fragment.sh" +[rule.proof] +# The bad fixture is policyengine-api #4084's fragment, changelog.d/added/4083.md: +# that repository's check accepted it, and its release found no fragment. +bad_fixture = "rules/fixtures/sim.process.changelog-fragment/bad" +good_fixture = "rules/fixtures/sim.process.changelog-fragment/good" +project = "projects/policyengine-simulation-entry" +test = "tests/test_changelog_fragment_check.py::test_rule_proof_changelog_fragment" +workflow = ".github/workflows/pr.yml" +job = "test" +invocation = "uv run --frozen pytest tests/" + [[rule]] id = "sim.process.same-repo-draft-prs" category = "process" From 2bf07c10013f91ab171ddb676373a8cdd8b7b9d0 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:58:01 +0400 Subject: [PATCH 09/11] Test that automated pull requests add a changelog fragment The daily dependency update and the policyengine.py package update open pull requests that CI checks like any other, so each must add a fragment the shared definition accepts: changelog.d/update-dependencies.changed.md with a fixed text, so the dependency update still compares only the update with its remote branch, and changelog.d/update-policyengine-.changed.md naming both versions. Neither writes one when it changes nothing. The dependency-update tests now run the script in an empty checkout folder, since it writes there. The two fragment tests fail: neither script writes a fragment yet. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_workflow_scripts.py | 55 ++++++++++++++++- ...est_policyengine_package_update_scripts.py | 59 +++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/projects/policyengine-simulation-entry/tests/test_workflow_scripts.py b/projects/policyengine-simulation-entry/tests/test_workflow_scripts.py index 5a4f012e4..98354303a 100644 --- a/projects/policyengine-simulation-entry/tests/test_workflow_scripts.py +++ b/projects/policyengine-simulation-entry/tests/test_workflow_scripts.py @@ -11,6 +11,7 @@ import pytest import yaml +from changelog_release_support import load_fragments_module from workflow_script_support import ( REPOSITORY_ROOT, WORKFLOWS_DIR, @@ -179,13 +180,35 @@ def test_update_dependencies_workflow_passes_the_recorded_head_to_the_push(): } -def _commit_and_push(fakes: FakeCommands, tmp_path: Path, **env: str): +# Every pull request adds a changelog fragment (rule +# sim.process.changelog-fragment); the update's never changes, so the push +# still compares only the update with the remote branch. +DEPENDENCY_UPDATE_FRAGMENT = "changelog.d/update-dependencies.changed.md" +DEPENDENCY_UPDATE_FRAGMENT_TEXT = ( + "Updated every project's locked dependencies to the newest versions their " + "requirements allow.\n" +) +# Records which fragments exist when git add stages the update. +FAKE_GIT_RECORDING_FRAGMENTS = """\ +if [[ "$1" == "add" ]]; then + ls changelog.d > "$FAKE_FRAGMENTS_AT_ADD" 2>/dev/null || true +fi +""" + + +def _commit_and_push( + fakes: FakeCommands, tmp_path: Path, git_body: str = FAKE_GIT, **env: str +): + """Run the script in an empty checkout folder, which it may write a fragment to.""" output = tmp_path / "github-output" - fakes.add("git", FAKE_GIT) + checkout = tmp_path / "checkout" + checkout.mkdir(exist_ok=True) + fakes.add("git", git_body) fakes.add("date", "echo 2026-10-13\n") result = run_script( COMMIT_AND_PUSH, env=fakes.env(GITHUB_OUTPUT=str(output), **env), + cwd=checkout, ) return result, output @@ -216,6 +239,34 @@ def test_commit_and_push_replaces_the_branch_with_a_lease_on_the_recorded_head( assert fakes.calls("date") == [["date", "-u", "+%Y-%m-%d"]] +def test_commit_and_push_adds_a_changelog_fragment_to_the_update(fakes, tmp_path): + fragments_at_add = tmp_path / "fragments-at-add" + result, _output = _commit_and_push( + fakes, + tmp_path, + git_body=FAKE_GIT_RECORDING_FRAGMENTS + FAKE_GIT, + REMOTE_HEAD=REMOTE_HEAD, + FAKE_GIT_STATUS=" M uv.lock\n", + FAKE_FRAGMENTS_AT_ADD=str(fragments_at_add), + ) + + assert result.returncode == 0, result.stderr + fragment = tmp_path / "checkout" / DEPENDENCY_UPDATE_FRAGMENT + assert fragment.read_text(encoding="utf-8") == DEPENDENCY_UPDATE_FRAGMENT_TEXT + assert fragments_at_add.read_text(encoding="utf-8") == ( + "update-dependencies.changed.md\n" + ) + definition = load_fragments_module().FragmentDefinition.load(REPOSITORY_ROOT) + assert definition.problem_with(DEPENDENCY_UPDATE_FRAGMENT, fragment) is None + + +def test_commit_and_push_adds_no_fragment_without_an_update(fakes, tmp_path): + result, _output = _commit_and_push(fakes, tmp_path, REMOTE_HEAD=REMOTE_HEAD) + + assert result.returncode == 0, result.stderr + assert not (tmp_path / "checkout" / "changelog.d").exists() + + def test_commit_and_push_creates_the_branch_with_a_plain_push(fakes, tmp_path): result, output = _commit_and_push( fakes, tmp_path, REMOTE_HEAD="", FAKE_GIT_STATUS=" M uv.lock\n" diff --git a/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py b/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py index a64d97751..915a26fbf 100644 --- a/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py +++ b/projects/policyengine-simulation-executor/tests/test_policyengine_package_update_scripts.py @@ -2,11 +2,13 @@ from __future__ import annotations +import importlib.util import subprocess import sys from typing import TYPE_CHECKING import pytest +from fixtures.test_modal_scripts import REPO_ROOT from fixtures.test_policyengine_package_update_scripts import ( SCRIPT, BundledVersions, @@ -294,6 +296,63 @@ def test_update_policyengine_package_updates_only_wrapper_requirements( assert "pr create" in gh_log.read_text(encoding="utf-8") +def _fragment_problem(relative_path: str, fragment: Path) -> str | None: + """Return why the shared changelog definition rejects ``fragment``, or None.""" + spec = importlib.util.spec_from_file_location( + "changelog_fragments", + REPO_ROOT / ".github" / "scripts" / "changelog_fragments.py", + ) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + definition = module.FragmentDefinition.load(REPO_ROOT) + return definition.problem_with(relative_path, fragment) + + +def test_update_policyengine_package_adds_a_changelog_fragment( + fake_bin: Path, fake_repo: Path, tmp_path: Path +) -> None: + """Every pull request adds one (rule sim.process.changelog-fragment).""" + git_log = tmp_path / "git.log" + install_fake_git( + fake_bin, + root=fake_repo, + log=git_log, + state=FakeGitState(diff_has_changes=True), + ) + install_fake_gh(fake_bin, log=tmp_path / "gh.log") + install_fake_uv(fake_bin, log=tmp_path / "uv.log") + + result = run_updater(env=updater_env(fake_bin, LATEST_OVERRIDE="4.1.0")) + + assert result.returncode == 0, result.stderr + relative_path = "changelog.d/update-policyengine-4.1.0.changed.md" + fragment = fake_repo / relative_path + assert fragment.read_text(encoding="utf-8") == ( + "Updated policyengine.py from 4.0.0 to 4.1.0 in the simulation runtime.\n" + ) + project = fake_repo / "simulation" + assert ( + f"git add {project}/pyproject.toml {project}/uv.lock {fragment}\n" + in git_log.read_text(encoding="utf-8") + ) + assert _fragment_problem(relative_path, fragment) is None + + +def test_update_policyengine_package_adds_no_fragment_without_changes( + fake_bin: Path, fake_repo: Path, tmp_path: Path +) -> None: + install_fake_git(fake_bin, root=fake_repo, log=tmp_path / "git.log") + install_fake_gh(fake_bin, log=tmp_path / "gh.log") + install_fake_uv(fake_bin, log=tmp_path / "uv.log") + + result = run_updater(env=updater_env(fake_bin, LATEST_OVERRIDE="4.1.0")) + + assert result.returncode == 0, result.stderr + assert "No changes after update" in result.stdout + assert not (fake_repo / "changelog.d").exists() + + def test_update_policyengine_package_creates_and_links_missing_issue( fake_bin: Path, fake_repo: Path, tmp_path: Path ) -> None: From 2bf643cbbd1977ef4fc483bd12a04a01ae5be508 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:00:04 +0400 Subject: [PATCH 10/11] Add a changelog fragment to each automated pull request dependency-update-commit-and-push.sh writes changelog.d/update-dependencies.changed.md before it stages an update, and update-policyengine-package.sh writes changelog.d/update-policyengine-.changed.md, naming the old and new versions, and commits it with the lock. Without them, the fragment check would fail every automated pull request. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/dependency-update-commit-and-push.sh | 7 +++++++ .github/scripts/update-policyengine-package.sh | 8 +++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/scripts/dependency-update-commit-and-push.sh b/.github/scripts/dependency-update-commit-and-push.sh index ed12aeef0..b80bb5fdf 100755 --- a/.github/scripts/dependency-update-commit-and-push.sh +++ b/.github/scripts/dependency-update-commit-and-push.sh @@ -4,6 +4,10 @@ # the prepare step rebuilt from main, and replace the remote update branch. # # - When the update changes no file on main, nothing is committed or pushed. +# - Otherwise the commit also adds changelog.d/update-dependencies.changed.md, +# since every pull request adds a changelog fragment (rule +# sim.process.changelog-fragment). Its name and text never change, so the +# comparison below still compares only the update. # - When the remote branch already holds the same files, the push is skipped. # The commit message carries the date, so commits are compared by their files. # - Otherwise the push replaces the remote branch only while it still points at @@ -35,6 +39,9 @@ if [[ -z "$(git status --porcelain)" ]]; then else echo "Changes detected" changes_detected=true + mkdir -p changelog.d + echo "Updated every project's locked dependencies to the newest versions their requirements allow." \ + > changelog.d/update-dependencies.changed.md git add --all git commit -m "Update dependencies ($(date -u +%Y-%m-%d))" diff --git a/.github/scripts/update-policyengine-package.sh b/.github/scripts/update-policyengine-package.sh index f178cfc44..2755517c5 100755 --- a/.github/scripts/update-policyengine-package.sh +++ b/.github/scripts/update-policyengine-package.sh @@ -327,7 +327,13 @@ if git diff --quiet -- "$PYPROJECT" "$LOCKFILE"; then exit 0 fi -git add "$PYPROJECT" "$LOCKFILE" +# Every pull request adds a changelog fragment (rule +# sim.process.changelog-fragment). +FRAGMENT="${ROOT_DIR}/changelog.d/update-policyengine-${LATEST}.changed.md" +mkdir -p "${ROOT_DIR}/changelog.d" +echo "Updated policyengine.py from ${CURRENT} to ${LATEST} in the simulation runtime." > "$FRAGMENT" + +git add "$PYPROJECT" "$LOCKFILE" "$FRAGMENT" git commit -m "chore(deps): update policyengine to ${LATEST}" git push -u origin "$BRANCH" From 37109bb44af235a53d60ddaac17d330720612ae6 Mon Sep 17 00:00:00 2001 From: Anthony Volk <14987227+anth-volk@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:02:19 +0400 Subject: [PATCH 11/11] Document changelog fragments and the release commit, and add fragments github-prs.md gains a Changelog Fragments section: how to name and write a fragment, how each type bumps VERSION, what the check rejects and how to run it locally, and how a merge is released and then deployed by its release commit, including what happens when two merges land close together or the release fails. The PR checklist in github-prs.md and AGENTS.md gains the fragment step, and the PR template a Changelog checkbox. AGENTS.md replaces "There is currently no repository-wide changelog fragment requirement" with the requirement and the release flow, citing both rules. ci-workflows.md says only the versioning job pushes to main, dependencies.md lists the .github/towncrier project and its relock, and README.md and CONTRIBUTING.md describe the release commit and make changelog-draft. This pull request's own fragments are changelog.d/978.added.md and changelog.d/978.changed.md. Co-Authored-By: Claude Opus 5.5 --- .github/CONTRIBUTING.md | 6 ++ .github/pull_request_template.md | 4 + AGENTS.md | 24 +++-- README.md | 16 ++-- changelog.d/978.added.md | 1 + changelog.d/978.changed.md | 1 + docs/engineering/skills/ci-workflows.md | 13 +++ docs/engineering/skills/dependencies.md | 10 ++- docs/engineering/skills/github-prs.md | 112 ++++++++++++++++++++++-- 9 files changed, 166 insertions(+), 21 deletions(-) create mode 100644 changelog.d/978.added.md create mode 100644 changelog.d/978.changed.md diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index f7339a863..41faf3895 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -11,6 +11,7 @@ make check # Ruff check + Pyright over source directories make test # service unit tests in Docker make test-complete # unit tests + local integration tests make push-pr-branch # push current branch to origin with tracking +make changelog-draft # print the CHANGELOG.md section the next release adds ./scripts/generate-clients.sh ``` @@ -52,6 +53,11 @@ gh pr view --repo PolicyEngine/policyengine-sim-api --json isDraft,headRepo Before opening the PR, open or identify a GitHub issue for the work. The first line of the PR description must be `Fixes #ISSUE_NUMBER`. +Every PR adds a changelog fragment, `changelog.d/..md`, where +`` is breaking, added, changed, fixed or removed; don't edit +`CHANGELOG.md` yourself. The release after each merge builds the fragments into +`CHANGELOG.md`. See `docs/engineering/skills/github-prs.md#changelog-fragments`. + The PR is valid only if it is a draft and the head repository is `PolicyEngine/policyengine-sim-api`. If you cannot push to that repository, ask for access instead of opening a fork PR. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f9fd7d5df..c280eab52 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -5,6 +5,10 @@ Fixes # - [ ] This PR is a draft. - [ ] This PR is from a branch in `PolicyEngine/policyengine-sim-api`, not a personal fork. +## Changelog + +- [ ] This PR adds a changelog fragment, `changelog.d/..md`, whose type is breaking, added, changed, fixed or removed. + ## Summary - diff --git a/AGENTS.md b/AGENTS.md index ac70ab234..d173f588a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,11 +71,13 @@ Before creating or sharing any PR, all developers and agents must: already exists. 3. Put `Fixes #ISSUE_NUMBER` as the first line of the PR description, using the issue number from the issue created or found in the previous step. -4. Push the branch to the canonical repository, for example: +4. Add a changelog fragment, `changelog.d/..md`; see + `docs/engineering/skills/github-prs.md#changelog-fragments`. +5. Push the branch to the canonical repository, for example: `make push-pr-branch`. -5. Create the PR as a draft from that same repository, for example: +6. Create the PR as a draft from that same repository, for example: `gh pr create --draft --repo PolicyEngine/policyengine-sim-api --head "$(git branch --show-current)" --base main`. -6. Verify the PR is draft and the head repository is canonical before reporting +7. Verify the PR is draft and the head repository is canonical before reporting it: `gh pr view --repo PolicyEngine/policyengine-sim-api --json isDraft,headRepositoryOwner,headRepository`. @@ -90,11 +92,17 @@ same-repository draft PR. - The simulation service lives in `projects/policyengine-simulation-executor`. - API integration tests live in `projects/policyengine-apis-integ`. - PR CI runs simulation unit tests, Ruff format and lint checks, Pyright for - every package, quality guards (including the rules registry), Docker build, - and local integration tests. A separate `PR description` job checks that - every open PR's description starts with `Fixes #ISSUE_NUMBER` for an open - issue in this repository. -- There is currently no repository-wide changelog fragment requirement. + every package, quality guards (including the rules registry), the changelog + fragment check, Docker build, and local integration tests. A separate + `PR description` job checks that every open PR's description starts with + `Fixes #ISSUE_NUMBER` for an open issue in this repository. +- Every PR adds a changelog fragment, `changelog.d/..md`, and never + edits `CHANGELOG.md` to describe a change (rule + `sim.process.changelog-fragment`). A merge to `main` doesn't deploy itself: + the `versioning` job in `simulation-deploy.yml` raises `VERSION`, builds + `CHANGELOG.md` and pushes a release commit, and that commit deploys (rule + `sim.deploy.release-commit-deploys-once`). See + `docs/engineering/skills/github-prs.md#changelog-fragments`. ## Safety Boundaries diff --git a/README.md b/README.md index df4db6273..f34ebaa49 100644 --- a/README.md +++ b/README.md @@ -82,9 +82,14 @@ make generate-clients # regenerate the OpenAPI Python client Simulation workers and the old gateway continue to deploy to Modal. The permanent Simulation Entrypoint deploys to Cloud Run as tagged no-traffic candidates. Beta qualification uses Cloud Run's generated candidate URLs and -does not require a custom beta domain. On merge to `main`, -`.github/workflows/simulation-deploy.yml` releases one complete stack at a -time: +does not require a custom beta domain. + +A merge to `main` doesn't deploy by itself. Its push runs only the `versioning` +job of `.github/workflows/simulation-deploy.yml`, which raises `VERSION`, builds +the merge's section of `CHANGELOG.md` from the fragments in `changelog.d/`, and +pushes one release commit, `Update PolicyEngine simulation API`. That commit's +push deploys one complete stack at a time (see +`docs/engineering/skills/github-prs.md#how-a-merge-is-released-and-deployed`): 1. prepare the environment's deployment inputs and runtime secrets; 2. deploy and unit test the Entrypoint, Modal gateway, and Modal executor in @@ -99,8 +104,9 @@ time: 7. verify the stable endpoint, restoring its previous revision if immediate post-promotion checks fail. -A successful push-triggered full-stack deployment publishes the API client from -the exact deployed commit. +A successful deployment of a release commit publishes the API client from the +exact deployed commit. A manual dispatch of the workflow deploys without a +release and publishes no client. The stable production service is exposed through a separately managed global external HTTPS load balancer and canonical hostname. Domain ownership, DNS, diff --git a/changelog.d/978.added.md b/changelog.d/978.added.md new file mode 100644 index 000000000..dca8be5b1 --- /dev/null +++ b/changelog.d/978.added.md @@ -0,0 +1 @@ +Added changelog fragments: every pull request adds one or more files `changelog.d/..md`, whose type (breaking, added, changed, fixed or removed) sets the version bump, and the `Changelog fragment` check, which CI complete needs, rejects a pull request that adds none, a file in a folder inside `changelog.d/`, an unknown type or an empty file. Added `CHANGELOG.md`, which starts with a 0.1.0 section holding the 63 entries of the former `changelog_entry.yaml`, and `VERSION`, the repository's release version, starting at 0.1.0. diff --git a/changelog.d/978.changed.md b/changelog.d/978.changed.md new file mode 100644 index 000000000..66ebd0a8a --- /dev/null +++ b/changelog.d/978.changed.md @@ -0,0 +1 @@ +A merge to `main` no longer deploys by itself. The new `versioning` job in `simulation-deploy.yml` raises `VERSION` from the merge's fragments, builds their `CHANGELOG.md` section with Towncrier, and pushes one release commit, `Update PolicyEngine simulation API`, with the GitHub App token; that commit's run deploys beta and prod and then publishes the API clients. A manual dispatch still deploys without a release. The daily dependency update and the policyengine.py update add their own fragments. diff --git a/docs/engineering/skills/ci-workflows.md b/docs/engineering/skills/ci-workflows.md index b7abedbc2..3ecf1e736 100644 --- a/docs/engineering/skills/ci-workflows.md +++ b/docs/engineering/skills/ci-workflows.md @@ -51,6 +51,19 @@ you convert an existing multi-line block into a script, or add a missing test, remove its baseline entry and lower the rule's `baseline.count` in `rules/registry.toml`. +## Pushing to main from a workflow + +Only the `versioning` job in `simulation-deploy.yml` pushes to `main`: one +release commit per merge, with the GitHub App token from `APP_ID` and +`APP_PRIVATE_KEY`, whose App is on the `main` ruleset's bypass list. Its push +starts the workflow again, and only that run deploys. Don't add another job +that pushes to `main`, and don't change the release commit's message in one +place only: each deploy job's `if` and `publish-clients.yml` compare against +it. Rule `sim.deploy.release-commit-deploys-once`, and +`projects/policyengine-simulation-entry/tests/test_release_workflow.py`, check +both; see +[How a merge is released and deployed](github-prs.md#how-a-merge-is-released-and-deployed). + ## Installing uv A step that installs uv with `astral-sh/setup-uv` sets `with: version:` to the diff --git a/docs/engineering/skills/dependencies.md b/docs/engineering/skills/dependencies.md index 7064ba600..3eb8bbdf5 100644 --- a/docs/engineering/skills/dependencies.md +++ b/docs/engineering/skills/dependencies.md @@ -5,7 +5,10 @@ runs tests: a workflow step, a `.github/scripts/` or `scripts/` script, a Makefile target, a Dockerfile, a Compose file or a Modal image definition. Every project directory (`projects/*`, `libs/*`) is its own uv project with its -own `uv.lock`. The repository root has no project. Four rules keep every +own `uv.lock`, and so is `.github/towncrier`, which only locks the Towncrier the +release runs to build `CHANGELOG.md` (`build-changelog.sh` runs +`uv run --frozen --project .github/towncrier towncrier build`). The repository +root has no project. Four rules keep every environment on the locked versions. The `locked-installs` quality guard (`scripts/rules/locked_installs.py`) checks all four on every pull request. A fifth rule, [checked by the `uv-version` guard](#we-pin-one-uv-version), makes @@ -50,8 +53,9 @@ no packages, so they are not reported. `sim.dependencies.scheduled-relock`: `.github/workflows/update-dependencies.yml` runs every day. Its `make update` step runs `uv lock --upgrade` in every -`libs/*` and `projects/*` directory with a `pyproject.toml`, and its scripts -open or update the dependency-update pull request. The guard checks this +`libs/*` and `projects/*` directory with a `pyproject.toml` and in +`.github/towncrier`, and its scripts open or update the dependency-update pull +request, with its changelog fragment. The guard checks this configuration statically: a `schedule:` workflow whose job, through the scripts and Make targets it runs, relocks with upgrades, covers every project directory and runs `gh pr create`. It does not check that the scheduled runs diff --git a/docs/engineering/skills/github-prs.md b/docs/engineering/skills/github-prs.md index 31c6f6e15..c9a51eb58 100644 --- a/docs/engineering/skills/github-prs.md +++ b/docs/engineering/skills/github-prs.md @@ -17,15 +17,18 @@ Before creating or sharing a PR: 3. Put `Fixes #ISSUE_NUMBER` as the first line of the PR description, using the issue number from the issue created or found in the previous step. The `PR description` check fails otherwise; see [The `Fixes` Line](#the-fixes-line). -4. Run formatting and the most relevant tests for the changed surface. If an +4. Add a changelog fragment, `changelog.d/..md`. The + `Changelog fragment` check fails otherwise; see + [Changelog Fragments](#changelog-fragments). +5. Run formatting and the most relevant tests for the changed surface. If an expected check cannot be run, say so in the PR body. -5. Push the current branch to the canonical repository: +6. Push the current branch to the canonical repository: `make push-pr-branch`. -6. Create the PR as a draft from that same repository: +7. Create the PR as a draft from that same repository: `gh pr create --draft --repo PolicyEngine/policyengine-sim-api --head "$(git branch --show-current)" --base main`. -7. Verify the PR is draft and the head repository is canonical: +8. Verify the PR is draft and the head repository is canonical: `gh pr view --repo PolicyEngine/policyengine-sim-api --json isDraft,headRepositoryOwner,headRepository`. -8. Leave the PR as draft unless a maintainer explicitly asks for it to be +9. Leave the PR as draft unless a maintainer explicitly asks for it to be marked ready for review. The PR is valid only if `isDraft` is `true` and the head repository is @@ -102,6 +105,105 @@ dependencies" issue or opens one. It also reuses an update pull request that is already open, and replaces its description, `Fixes` line first, because each run rebuilds the update branch from `main`. +## Changelog Fragments + +Rule `sim.process.changelog-fragment`. Every pull request adds at least one +changelog fragment: a file directly in `changelog.d/`, named +`..md`, that says in a sentence or two what changed for the people +who use or run the simulation API. After the pull request merges, the release +builds every pending fragment into `CHANGELOG.md` with Towncrier and removes +it. Don't edit `CHANGELOG.md` to describe a change. + +- **``** is the tracking issue's number, or another label of letters and + digits joined by single dots, hyphens or underscores, such as `978` or + `update-policyengine-6.11.1`. It only keeps fragments apart; the published + entry doesn't show it. +- **``** is one of the five types `towncrier.toml` lists. It sets the + section the entry appears under and the version bump: + + | Type | Section | Raises | + | --- | --- | --- | + | `breaking` | Breaking changes | the major version | + | `added` | Added | the minor version | + | `removed` | Removed | the minor version | + | `changed` | Changed | the patch version | + | `fixed` | Fixed | the patch version | + +- **The text** is Markdown, not empty. Several sentences or paragraphs are + fine. + +A pull request that changes several things can add several fragments, of the +same type or of different ones: + +```bash +echo "Required a changelog fragment on every pull request." > changelog.d/978.added.md +``` + +### What the check rejects + +The `Changelog fragment` job in `pr.yml` runs +`.github/scripts/check-changelog-fragment.sh` on the merged result. It fails +unless the pull request itself adds a fragment, relative to its base, and +every file in `changelog.d/` other than `.gitkeep` is one. These fail: + +- a pull request that adds no fragment, even while `main` has pending + fragments from other pull requests, and one that only edits a pending + fragment; +- a file in a folder, such as `changelog.d/added/4083.md`: policyengine-api's + check accepted that file in #4084, and its release couldn't read it; +- a type `towncrier.toml` doesn't list, such as `978.feature.md` or + `978.Added.md`; +- another name, such as `978.added`, `978.added.txt`, `978.added.1.md` or + `README.md`; +- an empty file, or one with only whitespace. + +Renaming a misplaced file into place counts as adding it. The definition lives +in `.github/scripts/changelog_fragments.py`, which reads the directory and the +types from `towncrier.toml`, and the release's version bump reads fragments +through the same code. To run the check locally, commit your work and run: + +```bash +BASE_SHA=origin/main .github/scripts/check-changelog-fragment.sh +make changelog-draft # the section the next release would add +``` + +Both need Python 3.11 or later as `python3`; `make changelog-draft` also needs +uv, to run Towncrier from `.github/towncrier/uv.lock`. + +In a stack, the check runs with the rest of CI on the lowest unmerged pull +request only. Each pull request in the stack still adds its own fragment. +Automated pull requests add one too: the daily dependency update adds +`changelog.d/update-dependencies.changed.md`, and the policyengine.py update +adds `changelog.d/update-policyengine-.changed.md`. + +### How a merge is released and deployed + +Rule `sim.deploy.release-commit-deploys-once`. A push to `main` starts +`.github/workflows/simulation-deploy.yml`: + +1. **The merge's push runs only the `versioning` job.** It raises `VERSION` + (the repository's release version) by the largest bump the pending + fragments call for, builds their `CHANGELOG.md` section, and pushes one + commit, `Update PolicyEngine simulation API`, to `main`. It pushes with the + GitHub App token from `APP_ID` and `APP_PRIVATE_KEY`: the App may push to + `main` past the branch ruleset, and its push starts the workflow again, + which a push with the job's own token would not. +2. **The release commit's push runs only the deploy jobs**, beta then prod. + The `versioning` job is skipped for it, so it releases nothing again and + nothing loops. API clients publish after this run, not the merge's. + +A manual dispatch of the workflow deploys without a release. The workflow's +concurrency group holds each run until the one before it finishes. When two +merges land close together, the later one's run releases both merges' +fragments; if the earlier run's push finds `main` already moved on, it says so +and succeeds without pushing. + +If the `versioning` job fails, nothing deploys until a later merge's release +succeeds. It fails, saying which file, when `changelog.d/` holds a file that +isn't a fragment or holds no fragment at all, which only a merge that skipped +the check can cause. Fix it in a pull request, or deploy the current `main` +by dispatching the workflow. + ## PR Title Do not add `[codex]`, `[claude]`, `[copilot]`, or other agent labels to PR