Skip to content

Commit e77fad5

Browse files
authored
Merge pull request #984 from PolicyEngine/feat/publication-candidate-changelog-snapshot
Snapshot publication candidate changelog fragments
2 parents b547889 + 83e3920 commit e77fad5

7 files changed

Lines changed: 298 additions & 20 deletions

File tree

.github/bump_version.py

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import json
44
import re
5+
import os
6+
import shutil
57
import sys
68
from pathlib import Path
79

@@ -18,6 +20,8 @@
1820
VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE)
1921
SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:rc(\d+))?$")
2022
PUBLICATION_SCOPE_PATH = Path(".github/publication_scope.json")
23+
PUBLICATION_CANDIDATES_DIR = Path(".github/publication_candidates")
24+
CHANGELOG_KEEP_FILE = ".gitkeep"
2125

2226

2327
def get_current_version(pyproject_path: Path) -> str:
@@ -33,9 +37,7 @@ def get_current_version(pyproject_path: Path) -> str:
3337

3438

3539
def infer_bump(changelog_dir: Path) -> str:
36-
fragments = [
37-
f for f in changelog_dir.iterdir() if f.is_file() and f.name != ".gitkeep"
38-
]
40+
fragments = changelog_fragments(changelog_dir)
3941
if not fragments:
4042
print("No changelog fragments found", file=sys.stderr)
4143
sys.exit(1)
@@ -62,13 +64,56 @@ def bump_version(version: str, bump: str) -> str:
6264

6365

6466
def write_publication_scope(path: Path, payload: dict[str, str]) -> None:
67+
path.parent.mkdir(parents=True, exist_ok=True)
6568
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
6669
print(f" Updated {path}")
6770

6871

72+
def changelog_fragments(changelog_dir: Path) -> list[Path]:
73+
return sorted(
74+
f
75+
for f in changelog_dir.iterdir()
76+
if f.is_file() and f.name != CHANGELOG_KEEP_FILE
77+
)
78+
79+
80+
def snapshot_changelog_fragments(
81+
*,
82+
run_id: str,
83+
changelog_dir: Path,
84+
publication_candidates_dir: Path,
85+
) -> Path:
86+
fragments = changelog_fragments(changelog_dir)
87+
if not run_id:
88+
print(
89+
"US_DATA_RUN_ID is required to snapshot changelog fragments",
90+
file=sys.stderr,
91+
)
92+
sys.exit(1)
93+
if not fragments:
94+
print("No changelog fragments found", file=sys.stderr)
95+
sys.exit(1)
96+
97+
snapshot_dir = publication_candidates_dir / run_id / "changelog.d"
98+
if snapshot_dir.exists() and changelog_fragments(snapshot_dir):
99+
print(
100+
f"Candidate changelog snapshot already exists: {snapshot_dir}",
101+
file=sys.stderr,
102+
)
103+
sys.exit(1)
104+
snapshot_dir.mkdir(parents=True, exist_ok=True)
105+
for fragment in fragments:
106+
destination = snapshot_dir / fragment.name
107+
shutil.copy2(fragment, destination)
108+
fragment.unlink()
109+
print(f" Snapshotted {fragment} -> {destination}")
110+
return snapshot_dir
111+
112+
69113
def main():
70114
pyproject = _REPO_ROOT / "pyproject.toml"
71115
changelog_dir = _REPO_ROOT / "changelog.d"
116+
run_id = os.environ.get("US_DATA_RUN_ID", "")
72117

73118
current = get_current_version(pyproject)
74119
bump = infer_bump(changelog_dir)
@@ -80,14 +125,25 @@ def main():
80125
print(f"Release bump: {bump}")
81126
print(f"Would release as at build time: {would_release_as}")
82127

128+
snapshot_changelog_fragments(
129+
run_id=run_id,
130+
changelog_dir=changelog_dir,
131+
publication_candidates_dir=_REPO_ROOT / PUBLICATION_CANDIDATES_DIR,
132+
)
133+
payload = {
134+
"run_id": run_id,
135+
"base_release_version": current,
136+
"release_bump": bump,
137+
"candidate_scope": candidate_scope,
138+
"would_release_as_at_build_time": would_release_as,
139+
}
83140
write_publication_scope(
84141
_REPO_ROOT / PUBLICATION_SCOPE_PATH,
85-
{
86-
"base_release_version": current,
87-
"release_bump": bump,
88-
"candidate_scope": candidate_scope,
89-
"would_release_as_at_build_time": would_release_as,
90-
},
142+
payload,
143+
)
144+
write_publication_scope(
145+
_REPO_ROOT / PUBLICATION_CANDIDATES_DIR / run_id / PUBLICATION_SCOPE_PATH.name,
146+
payload,
91147
)
92148

93149

.github/scripts/resolve_run_context.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,27 @@ def _pyproject_version() -> str:
5757
return tomllib.load(file)["project"]["version"]
5858

5959

60-
def _publication_scope() -> dict[str, str]:
60+
def _publication_scope(env: Mapping[str, str] | None = None) -> dict[str, str]:
61+
env = env or os.environ
62+
run_id = env.get(RUN_ID_ENV, "")
63+
if run_id:
64+
candidate_path = (
65+
_REPO_ROOT
66+
/ ".github"
67+
/ "publication_candidates"
68+
/ run_id
69+
/ "publication_scope.json"
70+
)
71+
if candidate_path.exists():
72+
return json.loads(candidate_path.read_text())
6173
path = _REPO_ROOT / ".github" / "publication_scope.json"
6274
if not path.exists():
6375
return {}
6476
return json.loads(path.read_text())
6577

6678

6779
def _base_release_version(env: Mapping[str, str]) -> str:
68-
scope = _publication_scope()
80+
scope = _publication_scope(env)
6981
value = (
7082
env.get(BASE_RELEASE_VERSION_ENV)
7183
or env.get("BASE_RELEASE_VERSION", "")
@@ -77,7 +89,7 @@ def _base_release_version(env: Mapping[str, str]) -> str:
7789

7890

7991
def _release_bump(env: Mapping[str, str]) -> str:
80-
scope = _publication_scope()
92+
scope = _publication_scope(env)
8193
value = (
8294
env.get(RELEASE_BUMP_ENV)
8395
or env.get("RELEASE_BUMP", "")
@@ -94,7 +106,7 @@ def _candidate_version(
94106
base_release_version: str = "",
95107
release_bump: str = "",
96108
) -> str:
97-
scope = _publication_scope()
109+
scope = _publication_scope(env)
98110
version = (
99111
env.get(CANDIDATE_SCOPE_ENV)
100112
or env.get(CANDIDATE_VERSION_ENV)
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Restore candidate-scoped changelog fragments for final promotion."""
2+
3+
from __future__ import annotations
4+
5+
import filecmp
6+
import os
7+
import shutil
8+
import sys
9+
from pathlib import Path
10+
11+
12+
REPO_ROOT = Path(__file__).resolve().parents[2]
13+
ROOT_CHANGELOG_DIR = REPO_ROOT / "changelog.d"
14+
PUBLICATION_CANDIDATES_DIR = REPO_ROOT / ".github" / "publication_candidates"
15+
CHANGELOG_KEEP_FILE = ".gitkeep"
16+
17+
18+
def _fragments(path: Path) -> list[Path]:
19+
if not path.exists():
20+
return []
21+
return sorted(
22+
item
23+
for item in path.iterdir()
24+
if item.is_file() and item.name != CHANGELOG_KEEP_FILE
25+
)
26+
27+
28+
def _validate_root_fragments_match_snapshot(
29+
*,
30+
root_fragments: list[Path],
31+
snapshot_fragments: list[Path],
32+
) -> None:
33+
snapshot_by_name = {fragment.name: fragment for fragment in snapshot_fragments}
34+
root_by_name = {fragment.name: fragment for fragment in root_fragments}
35+
extra = sorted(set(root_by_name).difference(snapshot_by_name))
36+
missing = sorted(set(snapshot_by_name).difference(root_by_name))
37+
changed = sorted(
38+
name
39+
for name in set(root_by_name).intersection(snapshot_by_name)
40+
if not filecmp.cmp(root_by_name[name], snapshot_by_name[name], shallow=False)
41+
)
42+
if extra or missing or changed:
43+
details = []
44+
if extra:
45+
details.append(f"extra root fragments: {', '.join(extra)}")
46+
if missing:
47+
details.append(f"missing root fragments: {', '.join(missing)}")
48+
if changed:
49+
details.append(f"changed root fragments: {', '.join(changed)}")
50+
raise RuntimeError(
51+
"Root changelog fragments do not match the candidate snapshot; "
52+
+ "; ".join(details)
53+
)
54+
55+
56+
def restore_candidate_changelog(run_id: str) -> Path:
57+
if not run_id:
58+
raise RuntimeError("US_DATA_RUN_ID is required to restore changelog fragments.")
59+
60+
snapshot_dir = PUBLICATION_CANDIDATES_DIR / run_id / "changelog.d"
61+
snapshot_fragments = _fragments(snapshot_dir)
62+
if not snapshot_fragments:
63+
raise RuntimeError(
64+
f"No candidate changelog fragments found for run {run_id}: {snapshot_dir}"
65+
)
66+
67+
ROOT_CHANGELOG_DIR.mkdir(parents=True, exist_ok=True)
68+
root_fragments = _fragments(ROOT_CHANGELOG_DIR)
69+
if root_fragments:
70+
_validate_root_fragments_match_snapshot(
71+
root_fragments=root_fragments,
72+
snapshot_fragments=snapshot_fragments,
73+
)
74+
75+
for fragment in snapshot_fragments:
76+
destination = ROOT_CHANGELOG_DIR / fragment.name
77+
shutil.copy2(fragment, destination)
78+
print(f"Restored {destination} from {fragment}")
79+
80+
return snapshot_dir
81+
82+
83+
def main() -> None:
84+
try:
85+
snapshot_dir = restore_candidate_changelog(os.environ.get("US_DATA_RUN_ID", ""))
86+
except Exception as exc:
87+
print(str(exc), file=sys.stderr)
88+
sys.exit(1)
89+
print(f"Restored candidate changelog fragments from {snapshot_dir}")
90+
91+
92+
if __name__ == "__main__":
93+
main()

.github/workflows/local_area_promote.yaml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ jobs:
4646

4747
- uses: astral-sh/setup-uv@v8.1.0
4848

49-
- name: Install Modal CLI
50-
run: pip install modal
49+
- name: Install promotion CLI deps
50+
run: pip install modal towncrier
5151

5252
- name: Resolve run context
5353
id: run-context
@@ -58,13 +58,15 @@ jobs:
5858

5959
- name: Finalize package version
6060
run: |
61+
python .github/scripts/restore_publication_changelog.py
6162
python .github/scripts/finalize_package_version.py
63+
towncrier build --yes --version "$US_DATA_RELEASE_VERSION"
6264
uv lock
6365
6466
- name: Commit final package version
6567
uses: EndBug/add-and-commit@v10
6668
with:
67-
add: "pyproject.toml uv.lock"
69+
add: "pyproject.toml uv.lock CHANGELOG.md changelog.d"
6870
message: Finalize package version
6971

7072
- name: Build final wheel

.github/workflows/push.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ jobs:
6262
folder: docs/_build/html
6363
clean: true
6464

65-
# ── Publication candidate scope + changelog on ordinary pushes ──
65+
# ── Publication candidate scope + changelog snapshot on ordinary pushes ──
6666
versioning:
6767
name: Versioning
6868
runs-on: ubuntu-latest
@@ -87,11 +87,11 @@ jobs:
8787
with:
8888
python-version: "3.14"
8989
- uses: astral-sh/setup-uv@v8.1.0
90-
- run: pip install towncrier
91-
- name: Bump version and build changelog
90+
- name: Snapshot candidate changelog fragments
91+
env:
92+
US_DATA_RUN_ID: ${{ needs.run-context.outputs.run_id }}
9293
run: |
9394
python .github/bump_version.py
94-
towncrier build --yes --version "$(python .github/scripts/fetch_publication_scope.py would_release_as_at_build_time)"
9595
- name: Generate pipeline documentation artifacts
9696
run: uv run --no-sync --with pyyaml python scripts/extract_pipeline_docs.py
9797
- name: Update lockfile

changelog.d/983.changed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Snapshot publication candidate changelog fragments for final release promotion.

0 commit comments

Comments
 (0)