Skip to content

Commit 67083c8

Browse files
authored
Merge pull request #536 from ChBrain/governance/subnational-aware-bump-logic
fix(release): bump logic + alignment cross-check are sub-national-aware
2 parents 849e242 + ca9f632 commit 67083c8

5 files changed

Lines changed: 329 additions & 19 deletions

File tree

.github/workflows/auto-release.yml

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,33 @@ jobs:
8989
git worktree add --detach "$WT_AFTER" "$AFTER" >/dev/null
9090
trap 'git worktree remove --force "$WT_BEFORE" 2>/dev/null || true; git worktree remove --force "$WT_AFTER" 2>/dev/null || true' EXIT
9191
92+
# Use complete_cultures() so sub-national cultures (ADR-0001)
93+
# join the bump diff -- pre-#536 this ran complete_countries()
94+
# (depth-3 only), so a new sub-national appeared as the parent
95+
# country being UPDATED instead of being ADDED itself (the SH
96+
# promotion patch-bumped to v0.14.2 instead of minor-bumping
97+
# to v0.15.0). The output emits the FULL relative path from
98+
# regions/ so depth-aware UPDATED detection below can tell a
99+
# sub-national change from a sovereign-state change.
100+
#
101+
# Fallback to complete_countries() when the AFTER ref doesn't
102+
# yet have complete_cultures (this PR's predecessor refs);
103+
# produces depth-3 paths so the comparison still works on
104+
# both sides.
92105
PY='
93106
import sys
94107
sys.path.insert(0, "scripts")
95108
try:
96-
from culture_completeness import complete_countries
109+
from culture_completeness import complete_cultures, _REGIONS
110+
rows = complete_cultures()
97111
except Exception:
98-
# Script missing at this ref -- treat as empty set.
99-
sys.exit(0)
100-
for region, path in complete_countries():
101-
print(f"{region}/{path.name}")
112+
try:
113+
from culture_completeness import complete_countries, _REGIONS
114+
rows = complete_countries()
115+
except Exception:
116+
sys.exit(0)
117+
for _region, path in rows:
118+
print(path.relative_to(_REGIONS).as_posix())
102119
'
103120
PREV=$(cd "$WT_BEFORE" && python3 -c "$PY" | sort)
104121
NEXT=$(cd "$WT_AFTER" && python3 -c "$PY" | sort)
@@ -108,18 +125,33 @@ jobs:
108125
KEPT=$(comm -12 <(printf '%s\n' "$PREV") <(printf '%s\n' "$NEXT") | sed '/^$/d')
109126
110127
# Which KEPT cultures had a content change in this push?
111-
# A change to a region's culture content under regions/<slug>/
112-
# is the signal; touching the README of a region without
113-
# changing the culture folder doesn't count.
128+
# A culture is UPDATED only when a file changes AT ITS OWN
129+
# depth -- NOT when a deeper sub-national folder under it
130+
# changes. For sovereign europe/germany (depth 3 under
131+
# regions/), files at regions/europe/germany/<file> count;
132+
# files at regions/europe/germany/schleswig_holstein/<file>
133+
# do NOT (the sub-national has its own entry and triggers its
134+
# own UPDATED / ADDED row). Pre-#536 the grep was prefix-only
135+
# and Germany was flagged as updated for any SH content
136+
# change, double-counting the SH promotion.
114137
CHANGED=$(git diff --name-only "$BEFORE" "$AFTER" -- 'regions/' || true)
115138
116-
UPDATED=""
117-
while IFS= read -r slug; do
118-
[ -z "$slug" ] && continue
119-
if printf '%s\n' "$CHANGED" | grep -q "^regions/$slug/"; then
120-
UPDATED="${UPDATED}${slug}"$'\n'
121-
fi
122-
done <<< "$KEPT"
139+
export KEPT CHANGED
140+
UPDATED=$(python3 -c '
141+
import os
142+
kept = [s for s in os.environ.get("KEPT", "").split("\n") if s.strip()]
143+
changed = [s for s in os.environ.get("CHANGED", "").split("\n") if s.strip()]
144+
# A file counts for slug iff its prefix is regions/<slug>/
145+
# AND its slash count equals prefix slash count (file lives
146+
# at the slug own depth, not inside a sub-folder).
147+
for slug in kept:
148+
prefix = f"regions/{slug}/"
149+
own_depth = prefix.count("/")
150+
for c in changed:
151+
if c.startswith(prefix) and c.count("/") == own_depth:
152+
print(slug)
153+
break
154+
')
123155
UPDATED=$(printf '%s' "$UPDATED" | sed '/^$/d')
124156
125157
{

.github/workflows/validate.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,25 @@ jobs:
609609
- name: Run completeness-alignment tests
610610
run: python -m pytest tests/test_culture_completeness_alignment.py -v
611611

612+
cultures-completeness-module-tests:
613+
name: cultures - Completeness module tests
614+
# Unit tests for scripts/culture_completeness.py itself --
615+
# complete_countries (sovereign-only) and complete_cultures (both
616+
# depths). Locks the depth contracts the auto-release bump diff
617+
# and the alignment cross-check rely on; pre-#536 a sub-national
618+
# could silently slip past both.
619+
runs-on: ubuntu-latest
620+
steps:
621+
- uses: actions/checkout@v4
622+
- name: Set up Python
623+
uses: actions/setup-python@v5
624+
with:
625+
python-version: '3.11'
626+
- name: Install pytest
627+
run: pip install pytest
628+
- name: Run culture-completeness module tests
629+
run: python -m pytest tests/test_culture_completeness.py -v
630+
612631
cultures-hofstede-score-home:
613632
name: cultures - Hofstede score home
614633
# Single-home gate for Hofstede scores: data/hofstede_scores.json is
@@ -1117,6 +1136,7 @@ jobs:
11171136
- cultures-zip-build
11181137
- cultures-pdf-build
11191138
- cultures-completeness-alignment
1139+
- cultures-completeness-module-tests
11201140
- cultures-hofstede-score-home
11211141
- cultures-validator-wiring
11221142
- cultures-metadata-format

scripts/culture_completeness.py

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,31 @@
88
tests/test_culture_completeness_alignment.py -- cross-check that
99
data/countries.json (the website map's registry) and the
1010
complete-cultures set stay in lockstep
11+
.github/workflows/auto-release.yml -- bump-kind decision
12+
(new culture -> minor, updated -> patch)
1113
12-
The rule itself: a country folder is *complete* when it carries a
14+
The rule itself: a culture folder is *complete* when it carries a
1315
position file, at least two personas (accepting all naming forms across
1416
the persona-rename rollout), a place file, a piece file, and a README.
1517
That is exactly what the deployment artifacts need to resolve.
1618
1719
Untested skeleton countries -- a region folder with a stub README but no
1820
content -- fall out. As each one finishes its v0.2.0 fix pass, it
1921
crosses the bar and joins the shipped set automatically.
22+
23+
Two depths, two functions
24+
-------------------------
25+
- ``complete_countries()`` walks ``regions/<region>/<country>/``
26+
(sovereign-state cultures only). The historical signature; build_zips
27+
and build_pdfs depend on its sovereign-only semantics for their
28+
per-country zip loop.
29+
- ``complete_cultures()`` walks both depths -- sovereign at
30+
``regions/<region>/<country>/`` AND sub-national (ADR-0001) at
31+
``regions/<region>/<parent>/<sub>/``. The signature consumers reach
32+
for when "every shippable culture" matters: the release bump count,
33+
the registry alignment cross-check. Build pipelines use the two
34+
depth-specific helpers separately so the zip / PDF loops stay
35+
disjoint (a sub-culture ships as a sibling artifact, not nested).
2036
"""
2137
from __future__ import annotations
2238

@@ -51,7 +67,11 @@ def is_complete_culture(country: Path) -> bool:
5167

5268

5369
def complete_countries() -> list[tuple[str, Path]]:
54-
"""(region_name, country_dir) for every complete culture, sorted.
70+
"""(region_name, country_dir) for every complete *sovereign-state* culture, sorted.
71+
72+
Sovereign-only -- depth 3 (``regions/<region>/<country>/``). For
73+
the "every shippable culture including sub-nationals" set, use
74+
``complete_cultures()``.
5575
5676
Ordered: regions alphabetically, countries within each region
5777
alphabetically. Hidden folders (``.xyz``) skipped.
@@ -64,3 +84,53 @@ def complete_countries() -> list[tuple[str, Path]]:
6484
if is_complete_culture(country):
6585
out.append((region.name, country))
6686
return out
87+
88+
89+
def complete_cultures() -> list[tuple[str, Path]]:
90+
"""(region_name, culture_dir) for every complete culture at any depth, sorted.
91+
92+
Walks both depths:
93+
- sovereign: ``regions/<region>/<slug>/``
94+
- sub-national (ADR-0001): ``regions/<region>/<parent>/<sub>/``
95+
96+
A folder that's complete at the sovereign depth is NEVER also
97+
counted at the sub-national depth -- ``is_complete_culture`` runs
98+
once per candidate path, and the per-depth loops don't re-enter.
99+
100+
Ordering: sovereign cultures sorted by (region, slug), then sub-
101+
national cultures sorted by (region, parent, sub). Stable across
102+
runs so the auto-release bump diff (BEFORE vs AFTER set arithmetic)
103+
is deterministic.
104+
105+
Consumers:
106+
- auto-release bump-kind decision (new culture -> minor)
107+
- test_culture_completeness_alignment (registry <-> complete set
108+
cross-check, fires on culture/release-base PRs and the
109+
culture/release -> main promotion)
110+
111+
Build pipelines DON'T use this -- they iterate sovereigns and
112+
sub-nationals through separate per-depth functions because their
113+
artifact layout differs (sub-cultures ship as sibling zips, not
114+
nested inside the parent's zip).
115+
"""
116+
out: list[tuple[str, Path]] = []
117+
if not _REGIONS.is_dir():
118+
return out
119+
# First pass: sovereigns. Same signature as complete_countries.
120+
sovereigns_seen: set[Path] = set()
121+
for region in sorted(p for p in _REGIONS.iterdir() if p.is_dir() and not p.name.startswith(".")):
122+
for country in sorted(p for p in region.iterdir() if p.is_dir() and not p.name.startswith(".")):
123+
if is_complete_culture(country):
124+
out.append((region.name, country))
125+
sovereigns_seen.add(country)
126+
# Second pass: sub-nationals. Only descend into country folders that
127+
# exist on disk (sovereign or skeleton). A sub-national folder under
128+
# a sovereign that itself isn't complete still ships if the
129+
# sub-national meets the bar -- a Schleswig-Holstein folder can be
130+
# complete while Germany itself is mid-migration.
131+
for region in sorted(p for p in _REGIONS.iterdir() if p.is_dir() and not p.name.startswith(".")):
132+
for country in sorted(p for p in region.iterdir() if p.is_dir() and not p.name.startswith(".")):
133+
for sub in sorted(p for p in country.iterdir() if p.is_dir() and not p.name.startswith(".")):
134+
if is_complete_culture(sub):
135+
out.append((region.name, sub))
136+
return out

tests/test_culture_completeness.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""Tests for scripts/culture_completeness.py.
2+
3+
Locks two contracts:
4+
5+
1. ``complete_countries()`` returns ONLY sovereign-state cultures
6+
(depth 3). build_zips and build_pdfs rely on this signature for
7+
their per-country zip / PDF loop; widening it would double-build
8+
sub-national content.
9+
2. ``complete_cultures()`` returns BOTH depths (sovereign + sub-
10+
national) so the auto-release bump diff and the registry-alignment
11+
cross-check see the full set. Pre-#536 these two consumers were
12+
running on ``complete_countries()``, which meant a sub-national
13+
like Schleswig-Holstein got patch-bumped instead of minor-bumped
14+
and primed a latent alignment-test block on the next promotion.
15+
"""
16+
from __future__ import annotations
17+
18+
import sys
19+
from pathlib import Path
20+
21+
import pytest
22+
23+
REPO_ROOT = Path(__file__).resolve().parent.parent
24+
sys.path.insert(0, str(REPO_ROOT / "scripts"))
25+
26+
import culture_completeness as cc # noqa: E402
27+
from culture_completeness import ( # noqa: E402
28+
complete_countries,
29+
complete_cultures,
30+
is_complete_culture,
31+
)
32+
33+
34+
def _make_complete(d: Path) -> None:
35+
"""Drop the five files is_complete_culture requires into ``d``."""
36+
d.mkdir(parents=True, exist_ok=True)
37+
(d / "culture_x_position.md").write_text("pos", encoding="utf-8")
38+
(d / "culture_x_persona_one.md").write_text("p1", encoding="utf-8")
39+
(d / "culture_x_persona_two.md").write_text("p2", encoding="utf-8")
40+
(d / "culture_x_place_a.md").write_text("place", encoding="utf-8")
41+
(d / "culture_x_piece_b.md").write_text("piece", encoding="utf-8")
42+
(d / "README.md").write_text("readme", encoding="utf-8")
43+
44+
45+
@pytest.fixture
46+
def fake_regions(tmp_path, monkeypatch):
47+
regions = tmp_path / "regions"
48+
regions.mkdir()
49+
monkeypatch.setattr(cc, "_REGIONS", regions)
50+
return regions
51+
52+
53+
class TestIsCompleteCulture:
54+
def test_complete_folder(self, tmp_path):
55+
_make_complete(tmp_path / "x")
56+
assert is_complete_culture(tmp_path / "x")
57+
58+
def test_missing_position(self, tmp_path):
59+
_make_complete(tmp_path / "x")
60+
(tmp_path / "x" / "culture_x_position.md").unlink()
61+
assert not is_complete_culture(tmp_path / "x")
62+
63+
def test_only_one_persona(self, tmp_path):
64+
_make_complete(tmp_path / "x")
65+
(tmp_path / "x" / "culture_x_persona_two.md").unlink()
66+
assert not is_complete_culture(tmp_path / "x")
67+
68+
def test_legacy_persona_name(self, tmp_path):
69+
"""``persona_<name>.md`` (the pre-rename form) counts."""
70+
d = tmp_path / "x"
71+
_make_complete(d)
72+
(d / "culture_x_persona_one.md").unlink()
73+
(d / "culture_x_persona_two.md").unlink()
74+
(d / "persona_alice.md").write_text("a", encoding="utf-8")
75+
(d / "persona_bob.md").write_text("b", encoding="utf-8")
76+
assert is_complete_culture(d)
77+
78+
def test_non_dir(self, tmp_path):
79+
f = tmp_path / "file.md"
80+
f.write_text("x", encoding="utf-8")
81+
assert not is_complete_culture(f)
82+
83+
84+
class TestCompleteCountriesIsSovereignOnly:
85+
"""``complete_countries`` MUST stay sovereign-only -- build_zips
86+
and build_pdfs rely on this depth contract for their per-country
87+
loop. A sub-national must NOT appear here."""
88+
89+
def test_returns_sovereigns_only(self, fake_regions):
90+
_make_complete(fake_regions / "europe" / "germany")
91+
_make_complete(fake_regions / "europe" / "germany" / "schleswig_holstein")
92+
names = [path.name for _r, path in complete_countries()]
93+
assert "germany" in names
94+
assert "schleswig_holstein" not in names
95+
96+
def test_skeleton_country_not_included(self, fake_regions):
97+
"""A region folder without the full chapter set is filtered out."""
98+
(fake_regions / "europe" / "atlantis").mkdir(parents=True)
99+
(fake_regions / "europe" / "atlantis" / "README.md").write_text("stub")
100+
names = [path.name for _r, path in complete_countries()]
101+
assert "atlantis" not in names
102+
103+
def test_skips_hidden_folders(self, fake_regions):
104+
_make_complete(fake_regions / ".hidden_region" / "x")
105+
_make_complete(fake_regions / "europe" / ".hidden")
106+
names = [path.name for _r, path in complete_countries()]
107+
assert ".hidden" not in names
108+
assert "x" not in names
109+
110+
111+
class TestCompleteCulturesWalksBothDepths:
112+
"""``complete_cultures`` returns sovereign AND sub-national folders
113+
at every depth. This is what auto-release and the alignment
114+
cross-check consume after #536."""
115+
116+
def test_includes_sub_national(self, fake_regions):
117+
_make_complete(fake_regions / "europe" / "germany")
118+
_make_complete(fake_regions / "europe" / "germany" / "schleswig_holstein")
119+
names = [path.name for _r, path in complete_cultures()]
120+
assert "germany" in names
121+
assert "schleswig_holstein" in names
122+
123+
def test_subnational_without_complete_parent(self, fake_regions):
124+
"""A complete sub-national surfaces even when its parent is
125+
only a skeleton -- the parent's incompleteness shouldn't hide
126+
a shippable sub-culture (it has its own zip)."""
127+
(fake_regions / "europe" / "germany").mkdir(parents=True)
128+
(fake_regions / "europe" / "germany" / "README.md").write_text("stub")
129+
_make_complete(fake_regions / "europe" / "germany" / "schleswig_holstein")
130+
names = [path.name for _r, path in complete_cultures()]
131+
assert "schleswig_holstein" in names
132+
assert "germany" not in names
133+
134+
def test_skeleton_subnational_not_included(self, fake_regions):
135+
_make_complete(fake_regions / "europe" / "germany")
136+
(fake_regions / "europe" / "germany" / "bavaria").mkdir()
137+
(fake_regions / "europe" / "germany" / "bavaria" / "README.md").write_text("stub")
138+
names = [path.name for _r, path in complete_cultures()]
139+
assert "germany" in names
140+
assert "bavaria" not in names
141+
142+
def test_no_double_count_of_sovereign(self, fake_regions):
143+
"""A sovereign is returned once, not duplicated as its own
144+
sub-self -- the per-depth loops never re-enter."""
145+
_make_complete(fake_regions / "europe" / "germany")
146+
rows = complete_cultures()
147+
germany_rows = [r for r in rows if r[1].name == "germany"]
148+
assert len(germany_rows) == 1
149+
150+
def test_ordering_sovereigns_then_subnationals(self, fake_regions):
151+
"""Sovereigns come first (sorted by region, slug), then sub-
152+
nationals (sorted by region, parent, sub). Stable across runs
153+
so the auto-release BEFORE/AFTER comparison is deterministic."""
154+
_make_complete(fake_regions / "europe" / "germany")
155+
_make_complete(fake_regions / "europe" / "denmark")
156+
_make_complete(fake_regions / "europe" / "germany" / "schleswig_holstein")
157+
names = [path.name for _r, path in complete_cultures()]
158+
assert names.index("denmark") < names.index("germany")
159+
assert names.index("germany") < names.index("schleswig_holstein")
160+
161+
162+
class TestCompleteCulturesAgainstRealRepo:
163+
"""The live regions/ tree must produce a sane complete-cultures
164+
set: every sovereign is present, schleswig_holstein is there as
165+
the only sub-national so far."""
166+
167+
def test_germany_present(self):
168+
names = [path.name for _r, path in complete_cultures()]
169+
assert "germany" in names
170+
171+
def test_schleswig_holstein_present(self):
172+
names = [path.name for _r, path in complete_cultures()]
173+
assert "schleswig_holstein" in names
174+
175+
def test_superset_of_complete_countries(self):
176+
countries = {path.name for _r, path in complete_countries()}
177+
cultures = {path.name for _r, path in complete_cultures()}
178+
assert countries.issubset(cultures)

0 commit comments

Comments
 (0)