Skip to content

Commit dd29d8e

Browse files
committed
governance(groupings): add generate_groupings.py + wire into deliver workflow
generate_groupings.py: Reads groups/*/dach_position.md (where grouping: true in frontmatter), extracts id/name/blurb/members, outputs groupings.json. Same contract shape as the KAIHACKS groupings rail stub. deliver-available-json.yml: Adds a 'Regenerate groupings.json' step after available.json so both manifests ship in the same idempotent PR against ChBrain/KAIHACKS. PR title updated to reflect both files. branch_scope.py: Adds scripts/generate_groupings.py to GOVERNANCE_GLOB_PATTERNS so it cannot be edited from a non-governance branch. Ref: #545
1 parent 1d8a481 commit dd29d8e

4 files changed

Lines changed: 137 additions & 6 deletions

File tree

.github/workflows/deliver-available-json.yml

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,19 +61,26 @@ jobs:
6161
--release "$RELEASE_TAG" \
6262
--out kaihacks/kaihacks.ai/cultures/public_html/assets/data/available.json
6363
64+
- name: Regenerate groupings.json
65+
run: |
66+
python cultures/scripts/generate_groupings.py \
67+
--out kaihacks/kaihacks.ai/cultures/public_html/assets/data/groupings.json
68+
6469
- name: Open KAIHACKS PR if the manifest changed
6570
uses: peter-evans/create-pull-request@v6
6671
with:
6772
token: ${{ secrets.KAIHACKS }}
6873
path: kaihacks
6974
base: main
7075
branch: cultures/available-json-${{ github.event.release.tag_name || inputs.release || github.run_id }}
71-
commit-message: 'chore: sync Cultures download manifest'
72-
title: 'chore: update Cultures download manifest (available.json)'
76+
commit-message: 'chore: sync Cultures download manifests'
77+
title: 'chore: update Cultures download manifests (available.json, groupings.json)'
7378
body: |
74-
Automated regeneration of `available.json` from the Cultures
75-
release flow -- `data/countries.json` projected to the download
76-
contract shape.
79+
Automated regeneration of `available.json` and `groupings.json`
80+
from the Cultures release flow.
81+
82+
- `available.json`: projected from `data/countries.json`
83+
- `groupings.json`: projected from `groups/*/dach_position.md`
7784
7885
Generated from Cultures release
7986
`${{ github.event.release.tag_name || inputs.release || github.ref_name }}`.

.validation-stamp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
23247fd291bceb52d0e62e0e124ec5875fc55504
1+
df1ca76fe06911f85f2cab6e1fa8cf6d1877bfd4

scripts/generate_groupings.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
#!/usr/bin/env python3
2+
"""Generate the Cultures groupings manifest (groupings.json).
3+
4+
Producer side of the Cultures <-> KAIHACKS groupings contract. Reads every
5+
grouping position file under groups/*/*.md (where grouping: true in the
6+
YAML frontmatter) and projects the declared fields onto the contract shape
7+
consumed by the KAIHACKS cultures map groupings rail.
8+
9+
groupings.json is never hand-extended: a grouping added to groups/ is picked
10+
up automatically; a changed grouping is reflected; regeneration is idempotent.
11+
12+
The release-delivery workflow (deliver-available-json.yml) runs this in the
13+
same step as generate_available_json.py and includes the output in the same
14+
idempotent PR against ChBrain/KAIHACKS.
15+
16+
Usage:
17+
python scripts/generate_groupings.py [--out PATH]
18+
19+
Without --out, the manifest is printed to stdout.
20+
21+
Exit 0 = manifest generated; 1 = a producer-side invariant is violated.
22+
"""
23+
from __future__ import annotations
24+
25+
import argparse
26+
import json
27+
import re
28+
import sys
29+
from pathlib import Path
30+
31+
import yaml
32+
33+
REPO_ROOT = Path(__file__).resolve().parent.parent
34+
GROUPS_DIR = REPO_ROOT / "groups"
35+
SCHEMA_VERSION = 1
36+
37+
# Grouping id must be a lowercase slug.
38+
_ID_RE = re.compile(r"^[a-z0-9_-]+$")
39+
40+
41+
def _parse_frontmatter(text: str) -> dict:
42+
"""Return the YAML frontmatter dict from a position file, or {} on failure."""
43+
lines = text.splitlines()
44+
if not lines or lines[0].strip() != "---":
45+
return {}
46+
try:
47+
end = next(i for i, l in enumerate(lines[1:], 1) if l.strip() == "---")
48+
except StopIteration:
49+
return {}
50+
return yaml.safe_load("\n".join(lines[1:end])) or {}
51+
52+
53+
def _collect_groupings() -> list[dict]:
54+
"""Walk groups/ and return a list of contract-shaped grouping entries."""
55+
if not GROUPS_DIR.is_dir():
56+
return []
57+
entries = []
58+
for group_dir in sorted(GROUPS_DIR.iterdir()):
59+
if not group_dir.is_dir() or group_dir.name.startswith("."):
60+
continue
61+
for md_file in sorted(group_dir.glob("*_position.md")):
62+
fm = _parse_frontmatter(md_file.read_text(encoding="utf-8"))
63+
if not fm.get("grouping"):
64+
continue
65+
entries.append({
66+
"id": fm.get("id", group_dir.name),
67+
"name": fm.get("name", group_dir.name),
68+
"blurb": fm.get("blurb", ""),
69+
"members": fm.get("members", []),
70+
})
71+
return entries
72+
73+
74+
def build_manifest() -> dict:
75+
"""Build the full groupings manifest dict."""
76+
return {
77+
"schemaVersion": SCHEMA_VERSION,
78+
"groupings": _collect_groupings(),
79+
}
80+
81+
82+
def check_manifest(manifest: dict) -> list[str]:
83+
"""Producer-side invariants. Returns a list of problems; empty = OK."""
84+
problems: list[str] = []
85+
ids = [g["id"] for g in manifest["groupings"]]
86+
for gid in ids:
87+
if not _ID_RE.match(gid):
88+
problems.append(f"Invalid grouping id: {gid!r}")
89+
if len(ids) != len(set(ids)):
90+
problems.append("Duplicate grouping ids detected")
91+
for g in manifest["groupings"]:
92+
if not g.get("members"):
93+
problems.append(f"Grouping {g['id']!r} has no members")
94+
if not g.get("blurb"):
95+
problems.append(f"Grouping {g['id']!r} has no blurb")
96+
return problems
97+
98+
99+
def main() -> int:
100+
parser = argparse.ArgumentParser(prog="generate_groupings.py")
101+
parser.add_argument("--out", metavar="PATH",
102+
help="Write JSON to file (default: stdout)")
103+
args = parser.parse_args()
104+
105+
manifest = build_manifest()
106+
problems = check_manifest(manifest)
107+
if problems:
108+
for p in problems:
109+
print(f"ERROR: {p}", file=sys.stderr)
110+
return 1
111+
112+
output = json.dumps(manifest, ensure_ascii=False, indent=2) + "\n"
113+
if args.out:
114+
Path(args.out).write_text(output, encoding="utf-8")
115+
print(f"Written {len(manifest['groupings'])} groupings to {args.out}",
116+
file=sys.stderr)
117+
else:
118+
sys.stdout.write(output)
119+
return 0
120+
121+
122+
if __name__ == "__main__":
123+
sys.exit(main())

tests/branch_scope.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@
183183
"scripts/check_release_gating.py",
184184
"scripts/extract_culture_slugs.py",
185185
"scripts/generate_available_json.py",
186+
"scripts/generate_groupings.py",
186187
"data/hofstede_denylist.yaml",
187188
"data/hofstede_keywords.py",
188189
"data/hofstede_scores.json",

0 commit comments

Comments
 (0)