Skip to content

culture(united_kingdom): add ## Title section (khai-tests v0.1.19 contract) #1118

culture(united_kingdom): add ## Title section (khai-tests v0.1.19 contract)

culture(united_kingdom): add ## Title section (khai-tests v0.1.19 contract) #1118

Workflow file for this run

name: validate
on:
pull_request:
branches: [main, culture/release]
jobs:
khai-encoding:
name: khai - Encoding rules
needs: [setup, cultures-stamp-check]
# Fork guard: every job that installs the private khai-tests wheel via
# secrets.KAIHACKS runs only for same-repo PRs. Forked PRs get no
# secrets, so these jobs would fail regardless -- skip them cleanly
# instead. Each secrets.KAIHACKS job below carries the same condition.
if: needs.setup.outputs.any == 'true' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install khai-tests
uses: ./.github/actions/install-khai-tests
with:
token: ${{ secrets.KAIHACKS }}
- name: Run encoding tests
run: python -m pytest --pyargs khai_tests.test_khai_encoding --khai-files="${{ needs.setup.outputs.files }}" -v
khai-process:
name: khai - Process rules
needs: [setup, khai-encoding]
if: needs.setup.outputs.any == 'true' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install khai-tests
uses: ./.github/actions/install-khai-tests
with:
token: ${{ secrets.KAIHACKS }}
- name: Run process tests
run: python -m pytest --pyargs khai_tests.components.test_khai_process --khai-files="${{ needs.setup.outputs.files }}" -v
khai-position:
name: khai - Position rules
needs: [setup, khai-encoding]
if: needs.setup.outputs.any == 'true' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install khai-tests
uses: ./.github/actions/install-khai-tests
with:
token: ${{ secrets.KAIHACKS }}
- name: Run position tests
run: python -m pytest --pyargs khai_tests.components.test_khai_position --khai-files="${{ needs.setup.outputs.files }}" -v
khai-piece:
name: khai - Piece rules
needs: [setup, khai-encoding]
if: needs.setup.outputs.any == 'true' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install khai-tests
uses: ./.github/actions/install-khai-tests
with:
token: ${{ secrets.KAIHACKS }}
- name: Run piece tests
run: python -m pytest --pyargs khai_tests.components.test_khai_piece --khai-files="${{ needs.setup.outputs.files }}" -v
khai-place:
name: khai - Place rules
needs: [setup, khai-encoding]
if: needs.setup.outputs.any == 'true' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install khai-tests
uses: ./.github/actions/install-khai-tests
with:
token: ${{ secrets.KAIHACKS }}
- name: Run place tests
run: python -m pytest --pyargs khai_tests.components.test_khai_place --khai-files="${{ needs.setup.outputs.files }}" -v
khai-persona:
name: khai - Persona rules
needs: [setup, khai-encoding]
if: needs.setup.outputs.any == 'true' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install khai-tests
uses: ./.github/actions/install-khai-tests
with:
token: ${{ secrets.KAIHACKS }}
- name: Run persona tests
run: python -m pytest --pyargs khai_tests.components.test_khai_persona --khai-files="${{ needs.setup.outputs.files }}" -v
cultures-sections:
name: cultures - Section structure
needs: [setup, khai-process, khai-position, khai-piece, khai-place, khai-persona]
if: needs.setup.outputs.any == 'true' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install khai-tests
uses: ./.github/actions/install-khai-tests
with:
token: ${{ secrets.KAIHACKS }}
- name: Run section tests
run: python -m pytest tests/test_sections.py --khai-files="${{ needs.setup.outputs.files }}" -v
cultures-branch-scope-tests:
name: cultures - Branch scope tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Python deps
# PyYAML required for E2E test (hook invokes validate_country_bag.py).
run: pip install -r tests/requirements.txt pytest
- name: Run branch_scope unit + E2E tests
run: python -m pytest tests/test_branch_scope.py tests/test_hook_scope_e2e.py -v
cultures-extract-culture-slugs-tests:
name: cultures - Extract-culture-slugs tests
# The release-gating gate (release-gating-gate.yml) and the
# auto-PR opener (release-gating.yml) both extract culture slugs
# from a diff via scripts/extract_culture_slugs.py. Locks the
# depth-3 sovereign / depth-4 sub-national contract so the
# Schleswig-Holstein bypass class cannot return silently.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Run extract-culture-slugs tests
run: python -m pytest tests/test_extract_culture_slugs.py -v
cultures-release-gating-tests:
name: cultures - Release-gating tests
# The release-gating engine: registry generator, gating check, and
# manifest projection. All three define the KAIHACKS download
# contract. Pre-#535 these test files lived on the test_validate_wiring
# exempt list (pre-existing backlog) -- promoted to live gates here
# so contract changes can't be made silently.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Python deps
run: pip install pyyaml pytest
- name: Run release-gating + manifest tests
run: |
python -m pytest \
tests/test_generate_country_entry.py \
tests/test_release_gating.py \
tests/test_generate_available_json.py \
-v
cultures-branch-scope-diff-check:
name: cultures - Branch scope diff check
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Check PR diff against branch scope rules
env:
BRANCH: ${{ github.head_ref }}
BASE_REF: ${{ github.base_ref }}
run: |
set -e
python3 - <<'PY'
import os, subprocess, sys
sys.path.insert(0, "tests")
from branch_scope import (
check_scope,
classify_branch,
diagnose_scope_failure,
render_scope_failure,
)
branch = os.environ["BRANCH"]
base_ref = os.environ["BASE_REF"]
base = f"origin/{base_ref}"
def git(*args):
return subprocess.run(
["git", *args], capture_output=True, text=True, check=True,
).stdout
kind = classify_branch(branch)
if kind == "main":
print("ERROR: PR head branch is 'main'; impossible state.")
sys.exit(1)
# --diff-filter=ACMRT skips deletions; three-dot range matches the
# merge-base semantics the scope check has always used.
files = [
line.strip()
for line in git(
"diff", "--name-only", "--diff-filter=ACMRT",
f"{base}...HEAD",
).splitlines()
if line.strip()
]
if not files:
print("No files changed; skipping scope check.")
sys.exit(0)
ok, unsafe = check_scope(kind, files, branch)
if ok:
print(
f"OK: branch '{branch}' (classified '{kind}') passes "
f"scope check ({len(files)} file(s))."
)
sys.exit(0)
# Scope failed. Attribute each offending file to its introducing
# commit(s) so the report can distinguish a mis-based branch
# (rebase) from a genuine out-of-scope edit (revert/move).
_on_main: dict[str, bool] = {}
def is_on_main(sha):
if sha not in _on_main:
_on_main[sha] = subprocess.run(
["git", "merge-base", "--is-ancestor", sha,
"origin/main"],
).returncode == 0
return _on_main[sha]
def commits_for_file(path):
out = git("log", "--format=%H", f"{base}..HEAD", "--", path)
return [line.strip() for line in out.splitlines() if line.strip()]
inherited, genuine = diagnose_scope_failure(
unsafe,
commits_for_file=commits_for_file,
is_on_main=is_on_main,
)
try:
base_behind = int(
git("rev-list", "--count", f"{base}..origin/main").strip()
)
except (subprocess.CalledProcessError, ValueError):
base_behind = 0
print(render_scope_failure(
branch, base_ref, inherited, genuine, base_behind,
))
sys.exit(1)
PY
cultures-culture-branch-base:
name: cultures - Culture branch base
# A culture/<country|region> branch must be cut from culture/release,
# the integration branch -- never from main. A branch cut from main
# carries main's lead, which pollutes the PR diff and (see PR #213)
# sends contributors -- LLMs especially -- chasing phantom scope
# violations. The branch-scope check above only catches this
# incidentally, when main's drift includes out-of-scope files; this
# job catches it directly, on commit ancestry, and fails fast.
# culture/release itself is exempt: it integrates upward into main.
if: github.event_name == 'pull_request' && startsWith(github.head_ref, 'culture/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Check culture branch was cut from culture/release
env:
BRANCH: ${{ github.head_ref }}
run: |
set -e
python3 - <<'PY'
import os, subprocess, sys
sys.path.insert(0, "tests")
from branch_scope import (
WORLD_SLUGS,
classify_branch,
misbased_commits,
render_misbased_branch,
)
branch = os.environ["BRANCH"]
if classify_branch(branch) != "culture":
print(f"SKIP: '{branch}' is not a culture branch.")
sys.exit(0)
if branch[len("culture/"):] in WORLD_SLUGS:
print(f"SKIP: '{branch}' is the integration branch.")
sys.exit(0)
integration = "origin/culture/release"
def git(*args):
return subprocess.run(
["git", *args], capture_output=True, text=True, check=True,
).stdout
# Commits on the branch but not on the integration branch. A
# tab separates the abbreviated SHA from the subject so subjects
# containing spaces survive the split.
commits = []
for line in git(
"log", "--format=%h%x09%s", f"{integration}..HEAD",
).splitlines():
if not line.strip():
continue
sha, _, subject = line.partition("\t")
commits.append((sha.strip(), subject.strip()))
_on_main: dict[str, bool] = {}
def is_on_main(sha):
if sha not in _on_main:
_on_main[sha] = subprocess.run(
["git", "merge-base", "--is-ancestor", sha,
"origin/main"],
).returncode == 0
return _on_main[sha]
offending = misbased_commits(commits, is_on_main=is_on_main)
if offending:
print(render_misbased_branch(branch, offending))
sys.exit(1)
print(
f"OK: '{branch}' was cut from culture/release "
f"({len(commits)} branch commit(s), none already on main)."
)
PY
cultures-pr-base-tests:
name: cultures - PR base tests
# Unit-test gate for tests/validate_pr_base.py (the PR base routing
# contract). Independent, always runs, no culture-file dependency.
# Catches routing regressions before pr-gate.yml enforces them in
# production. CLI file exercises the script pr-gate.yml actually calls.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Run PR base routing tests
run: python -m pytest tests/test_validate_pr_base.py tests/test_validate_pr_base_cli.py -v
cultures-brownfield-tests:
name: cultures - Brownfield migration validator tests
# Unit-test gate for tests/validate_pr_brownfield.py (the brownfield
# "if you touch, you migrate" rule). Independent, always runs, no
# culture-file dependency. Catches regressions in the gate's rule
# logic, exempt list, suffix regex, and frontmatter parsing before
# pr-gate.yml enforces them on every PR. Mirrors cultures-pr-base-tests.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest pyyaml
- name: Run brownfield validator tests
run: python -m pytest tests/test_validate_pr_brownfield.py -v
cultures-audit-readme-bands-tests:
name: cultures - Audit README bands tests
# Unit-test gate for scripts/audit_readme_bands.py (the canonical Hofstede
# band contract: 0-39 Low, 40-69 Moderate, 70-100 High). Mirrors the
# cultures-branch-scope-tests pattern: independent, always runs, exercises the
# contract on every PR. Closes the gap noted during the v2 stage 2
# sequence where this test had no CI runner.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Run audit_readme_bands tests
run: python -m pytest tests/test_audit_readme_bands.py -v
cultures-culture-traceability:
name: cultures - Culture-set traceability
# Gate for REFERENCES.md / README.md / persona-link traceability against
# the real culture file set (ChBrain/Cultures#301 rules R4-R6). Independent,
# always runs, no deps. The checks are xfail-soft during the corpus
# rollout, so this job stays green while per-country drift is corrected.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Run culture traceability tests
run: python -m pytest tests/test_culture_traceability.py -v
cultures-hofstede-readme-updater-tests:
name: cultures - Hofstede README updater tests
# Unit-test gate for scripts/update_hofstede_readme.py (deterministic
# rewriter for the Hofstede Cultural Dimensions + Alignment Status tables
# in country READMEs). Pins the EXCELLENT/PASS/WARN/FAIL thresholds, the
# canonical band-label rendering, and the table-replacement contract.
# Mirrors the cultures-audit-readme-bands-tests pattern: independent, always runs.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Python deps
# Mirrors cultures-marker-derived / cultures-hofstede-bag-loader-tests: tests/requirements.txt
# has lingua-language-detector, PyYAML, and pytest. Required because
# update_hofstede_readme imports data.hofstede_keywords transitively
# via hofstede_bag_loader, and data.hofstede_keywords imports lingua
# at module level (for detect_language). The unit tests don't call
# detect_language but the import has to succeed for the module to
# load.
run: pip install -r tests/requirements.txt
- name: Run update_hofstede_readme tests
run: python -m pytest tests/test_update_hofstede_readme.py -v
cultures-history-arc:
name: cultures - History arc
# Always-run gate for the history-as-defining-moments-arc methodology
# (khai-cultures-create skill v0.1.1+). Validates every
# culture_*_history_*.md file in regions/** has:
# - *khai: piece* declaration footer
# - Yearbook section with >= 12 dated entries
# - Date range spanning >= 5 centuries (broad arc, not single event)
# Catches the class of bug PR #105 surfaced: a history file shipping
# as a narrow-event essay (the original Beeldenstorm-only Netherlands
# history) rather than a broad-arc Yearbook. Mirrors the
# cultures-audit-readme-bands-tests pattern: independent, always runs.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest pyyaml
- name: Run history-arc tests
run: python -m pytest tests/test_history_arc.py -v
cultures-language-process-ladder:
name: cultures - Language-process ladder
# Always-run gate for the engine language-as-process ladder (issue #281).
# The 21 ladder files under engine/ are universal solution-grade culture
# content, but khai does not see engine files (--khai-files is regions/-
# scoped), so the ladder's structural contract -- completeness, the IDLE
# section set, Scope: Universal, the link graph -- is enforced here.
# Skips cleanly until the ladder lands (PR #283).
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest pyyaml
- name: Run language-process ladder tests
run: python -m pytest tests/test_language_process_ladder.py -v
cultures-persona-projection-ladder:
name: cultures - Persona projection ladder
# Always-run gate for the persona ## Projection ladder rule.
# Canonical persona files (<adj>_persona_<gender>_<name>.md) must
# link engine/process_<channel>_*.md inside ## Projection for all
# four channels: speaking, hearing, reading, writing. Cultures-
# specific gate; the khai chapter-list contract is enforced
# separately in khai_tests. Skips legacy and deprecated forms
# cleanly so the gate fires only on the canonical v0.2.0 file shape.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Run persona projection ladder tests
run: python -m pytest tests/test_persona_projection_ladder.py -v
cultures-persona-english-link:
name: cultures - Persona English link (L4h)
# Gate: every canonical persona's ## Projection must link a named English
# position file (anchor text OR target path contains 'english',
# case-insensitive). Known gaps listed in
# tests/persona_english_link_exceptions.txt are xfail while #425 is
# being worked down country-by-country. Hard block from day one --
# the exceptions file is the safety valve.
runs-on: ubuntu-latest
needs: [cultures-persona-projection-ladder]
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Run persona English link tests
run: python -m pytest tests/test_persona_english_link.py -v
cultures-zip-build:
name: cultures - Zip build
# Runs the real release zip build (scripts/build_zips.py) and validates
# every produced zip. build-zips.yml only fires on release/dispatch, so
# this is what proves the build is sound on a PR.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest pyyaml
- name: Run zip-build tests
run: python -m pytest tests/test_build_zips.py -v
cultures-pdf-build:
name: cultures - PDF build (smoke)
# Smoke-tests scripts/build_pdfs.py with pandoc mocked. The real
# release PDF build (build-pdfs.yml) only fires on release/dispatch
# and needs the apt-installed pandoc/weasyprint; this gates the
# enumeration + markdown-assembly + completeness-filter logic on
# every PR.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest pyyaml
- name: Run pdf-build smoke tests
run: python -m pytest tests/test_build_pdfs.py -v
cultures-completeness-alignment:
name: cultures - Completeness alignment (registry vs shipped)
# Cross-check that data/countries.json (the website-map registry)
# and the complete-cultures set (zips + PDFs) stay in lockstep.
# Catches the drift class where a culture lands in culture/release
# but isn't registered (the Mexico gap, #257), or where a registry
# entry references a country whose folder is not yet complete.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Run completeness-alignment tests
run: python -m pytest tests/test_culture_completeness_alignment.py -v
cultures-completeness-module-tests:
name: cultures - Completeness module tests
# Unit tests for scripts/culture_completeness.py itself --
# complete_countries (sovereign-only) and complete_cultures (both
# depths). Locks the depth contracts the auto-release bump diff
# and the alignment cross-check rely on; pre-#536 a sub-national
# could silently slip past both.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Run culture-completeness module tests
run: python -m pytest tests/test_culture_completeness.py -v
cultures-hofstede-score-home:
name: cultures - Hofstede score home
# Single-home gate for Hofstede scores: data/hofstede_scores.json is
# complete and authoritative, and no bag has drifted from it.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest pyyaml
- name: Run Hofstede score-home tests
run: python -m pytest tests/test_hofstede_score_home.py -v
cultures-validator-wiring:
name: cultures - Validator wiring
# Meta-gate: every tests/test_*.py is invoked by a validate.yml job.
# A validator is dead weight until a job runs it; this fails when a
# test file is neither wired nor explicitly exempted.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Run validator-wiring tests
run: python -m pytest tests/test_validate_wiring.py -v
cultures-metadata-format:
name: cultures - Metadata format
# Always-run guard for the footer -> YAML-frontmatter migration. Per
# country: no culture_*.md carries both a frontmatter block and a
# trailing footer, and frontmatter is all-or-nothing within a country
# (a country migrates as a unit, never half-and-half). Mirrors the
# cultures-history-arc pattern: independent, always runs.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Run metadata-format guard
run: python -m pytest tests/test_metadata_format.py -v
cultures-language-preflight:
name: cultures - Language pre-flight
# Always-run pre-flight: every country README's **Language(s):** slug
# must appear in data/language_policy.yaml, and every country's
# data/countries.json language must be unlocked via iso_map. Mirrors the
# cultures-audit-readme-bands-tests / cultures-history-arc pattern: independent,
# always runs, no setup-job dependency. Fast (no lingua, no whl
# install) -- under 30 seconds.
#
# Why a separate job: the same assertion runs late in the chain via
# cultures-language, which depends on khai-language, which depends
# on the components chain (encoding -> process/position/piece/place/
# persona -> sections). When the registry is missing an entry, that
# failure surfaces 5-10 minutes into CI after every other validator
# has run. This job surfaces it in ~30 seconds on every PR so a
# missing-language case fails before the rest of the chain spins up.
#
# Incident: PR #166 (culture/spain) cleared every other validator
# then failed on this assertion because `spanish` was not in
# data/language_policy.yaml. Emergency PRs #167 (governance) and
# #168 (sync) unblocked the release at the cost of two extra round
# trips. The check itself was correct; it just ran too late to be
# useful for prevention.
#
# LANGUAGES.md section "Pre-flight: check language enablement before
# starting a culture" documents the human-side procedure that this
# gate enforces.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest + pyyaml
run: pip install pytest pyyaml
- name: Run registry + unlock pre-flight
run: python -m pytest tests/test_language.py -k "registry or unlocked or iso_map" -v
setup:
name: Setup - Compute changed files
runs-on: ubuntu-latest
outputs:
any: ${{ steps.changed.outputs.any }}
files: ${{ steps.changed.outputs.files }}
# `data_changed`: true when the PR diff touches the Hofstede reference
# dataset (data/hofstede_scores.json). Drives cultures-hofstede-reference into audit mode (run
# across all countries) so a reference update doesn't silently widen
# divergence for cultures whose declared scores are now off-band.
data_changed: ${{ steps.changed.outputs.data_changed }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get changed regions/*.md files
id: changed
env:
HEAD_REF: ${{ github.head_ref }}
run: |
# Sync branches (sync/<name>) are pointer branches at main's HEAD.
# Content was already validated when it landed on main. The PR diff
# against culture/release would re-surface every file that has
# advanced on main since the last sync, which is pure waste --
# short-circuit here so all downstream content jobs gated on
# `any == 'true'` skip cleanly.
if [[ "$HEAD_REF" == sync/* ]]; then
echo "Sync branch detected ($HEAD_REF); skipping content-validator file detection."
echo "any=false" >> "$GITHUB_OUTPUT"
echo "files=" >> "$GITHUB_OUTPUT"
echo "data_changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Validate fires only on pull_request, so the diff range is always
# the merge base. (The previous push trigger required handling
# `github.event.before` and HEAD~1 fallback; dropped along with
# the push trigger -- post-merge re-validation added no value.)
RANGE="origin/${{ github.base_ref }}...HEAD"
# --diff-filter=ACMRT skips deletions; the validator must not be handed paths that no longer exist.
ALL_CHANGED=$(git diff --name-only --diff-filter=ACMRT "$RANGE")
# khai is path-independent: it classifies a file from its `khai:`
# declaration, not its location. engine/*.md is solution-grade
# culture content, so it is handed to the same validators (khai,
# link integrity) as regions/*.md content.
FILES=$(echo "$ALL_CHANGED" | { grep -E '^(regions|engine)/.*\.md$' || true; } | tr '\n' ' ' | sed 's/ *$//')
ANY=$(if [ -z "$FILES" ]; then echo "false"; else echo "true"; fi)
DATA_CHANGED=$(if echo "$ALL_CHANGED" | grep -qx 'data/hofstede_scores.json'; then echo "true"; else echo "false"; fi)
echo "any=$ANY" >> "$GITHUB_OUTPUT"
echo "files=$FILES" >> "$GITHUB_OUTPUT"
echo "data_changed=$DATA_CHANGED" >> "$GITHUB_OUTPUT"
cultures-stamp-check:
name: cultures - Validation stamp gate
needs: [setup]
if: needs.setup.outputs.any == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check validation stamp exists
run: |
if [ ! -f ".validation-stamp" ]; then
echo "❌ VALIDATION FAILED: .validation-stamp missing"
echo ""
echo "This file records that local validators passed before commit."
echo "Run locally to generate it:"
echo " git config core.hooksPath .githooks"
echo " scripts/setup-hooks.sh # or setup-hooks.bat on Windows"
echo " git commit --amend -m 'your message'"
exit 1
fi
echo "✔️ Validation stamp present"
- name: Check validation stamp shape
# Gate against malformed stamps (CRLF, NUL padding, BOM, wrong length,
# non-hex content). .githooks/pre-commit writes the stamp as exactly
# 40 hex chars + LF in UTF-8 (see the closing lines:
# tree_hash = run_git("write-tree")
# Path(".validation-stamp").write_text(tree_hash + "\n", encoding="utf-8")
# ). Any deviation means the stamp was written through a non-hook
# path -- typically an LLM agent's file write API (Claude Code in
# VS Code, Copilot, MCP create_or_update_file) on a Windows host
# that normalised line endings or added UTF-16 padding. The stamp
# value itself isn't checked against tree state (the hook owns
# that); this step only enforces shape so byte-exact three-way
# merges don't trip on encoding artifacts.
#
# Incident: PR #105 (culture/netherlands -> culture/release) was
# unmergeable for hours because culture/release's stamp had been
# written by Claude Code in VS Code with `\r\n\r\x00\n\x00` after
# the hash (46 bytes total, binary on disk). Both Copilot and a
# second Claude Code session tried to "just pick one" via clean
# 41-byte UTF-8 writes; git's three-way blob comparison rejected
# every attempt because the bytes never matched. Resolution
# required reverting the stamp to the merge-base blob -- a
# non-obvious path that no LLM-driven agent found on its own.
run: |
python3 - <<'PY'
import re
import sys
from pathlib import Path
raw = Path(".validation-stamp").read_bytes()
if len(raw) == 41 and re.fullmatch(rb"[0-9a-f]{40}\n", raw):
print("✔️ Validation stamp shape OK (40 hex chars + LF, 41 bytes)")
sys.exit(0)
print("❌ VALIDATION FAILED: .validation-stamp is malformed")
print(f" Expected: 41 bytes, [0-9a-f]{{40}} + LF (UTF-8, no BOM)")
print(f" Actual: {len(raw)} bytes")
print(f" Hex dump: {raw.hex()[:120]}" + ("..." if len(raw) > 60 else ""))
print()
print(" Most common cause: an LLM-driven file write (Claude Code in")
print(" VS Code, Copilot suggesting a fix, MCP create_or_update_file)")
print(" produced CRLF, UTF-16 padding, or BOM bytes instead of the")
print(" clean UTF-8 LF the pre-commit hook writes. See PR #105.")
print()
print(" Fix: re-run the pre-commit hook locally to rewrite cleanly.")
print(" touch a staged file (or git add --renormalize .)")
print(" git commit --amend --no-edit")
print(" git push --force-with-lease")
sys.exit(1)
PY
khai-language:
name: khai - Language rules
needs: [setup, cultures-sections]
if: needs.setup.outputs.any == 'true' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install khai-tests
uses: ./.github/actions/install-khai-tests
with:
token: ${{ secrets.KAIHACKS }}
- name: Install lingua + pyyaml
run: pip install lingua-language-detector pyyaml
- name: Run language detection tests
run: python -m pytest --pyargs khai_tests.test_khai_language --khai-files="${{ needs.setup.outputs.files }}" -v
cultures-language:
name: cultures - Language policy
needs: [setup, khai-language]
if: needs.setup.outputs.any == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest + pyyaml
run: pip install pytest pyyaml
- name: Run static language checks
run: python -m pytest tests/test_language.py -v
cultures-language-dispatch:
name: cultures - Language dispatch
# Always-run gate for the per-file frontmatter `language:` dispatcher
# (Nigeria mother-tongue arc -- Stage 2c). Pins the four routing
# outcomes -- absent/lingua-known/NLP-only/unknown -- so a regression
# in tests/validate_language.py's dispatch_route surfaces immediately.
# Independent, no culture-file dependency. Mirrors the
# cultures-history-arc pattern: always runs, fast (no lingua install).
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest + pyyaml
run: pip install pytest pyyaml
- name: Run language-dispatch tests
run: python -m pytest tests/test_validate_language_dispatch.py -v
khai-links:
name: khai - Link rules
needs: [setup, cultures-language]
if: needs.setup.outputs.any == 'true' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install khai-tests
uses: ./.github/actions/install-khai-tests
with:
token: ${{ secrets.KAIHACKS }}
- name: Run link tests
run: python -m pytest --pyargs khai_tests.test_khai_links --khai-files="${{ needs.setup.outputs.files }}" -v
cultures-links:
name: cultures - Link integrity
needs: [setup, khai-links]
if: needs.setup.outputs.any == 'true' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install khai-tests
uses: ./.github/actions/install-khai-tests
with:
token: ${{ secrets.KAIHACKS }}
- name: Run link tests
run: python -m pytest tests/test_links.py --khai-roots=engine --khai-files="${{ needs.setup.outputs.files }}" -v
cultures-completeness:
name: cultures - Culture completeness
needs: [setup, cultures-links]
if: needs.setup.outputs.any == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest pyyaml
- name: Validate culture completeness
run: python -m pytest tests/test_completeness.py -v
cultures-audit-readme:
name: cultures - Audit README status tables
needs: [setup, cultures-completeness]
if: needs.setup.outputs.any == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Validate audit README
run: python -m pytest tests/test_audit_readme.py -v
cultures-audit-consistency:
name: cultures - Audit table consistency
needs: [setup, cultures-audit-readme]
if: needs.setup.outputs.any == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Validate audit consistency
run: python -m pytest tests/test_audit_consistency.py -v
khai-plagiarism:
name: khai - Phrase denylist rules
needs: [setup, cultures-audit-consistency]
if: needs.setup.outputs.any == 'true' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install khai-tests
uses: ./.github/actions/install-khai-tests
with:
token: ${{ secrets.KAIHACKS }}
- name: Run phrase denylist tests
run: python -m pytest --pyargs khai_tests.test_khai_plagiarism --khai-files="${{ needs.setup.outputs.files }}" -v
cultures-plagiarism:
name: cultures - Plagiarism structure
needs: [setup, khai-plagiarism]
if: needs.setup.outputs.any == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Run plagiarism structural checks
run: python -m pytest tests/test_plagiarism.py -v
cultures-hofstede-structure:
name: cultures - Hofstede structure
needs: [setup, cultures-plagiarism]
if: needs.setup.outputs.any == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest pyyaml
- name: Validate Hofstede structure
run: python -m pytest tests/test_hofstede_structure.py -v
cultures-marker-derived:
name: cultures - Marker derived vs declared
needs: [setup, cultures-hofstede-structure]
if: needs.setup.outputs.any == 'true'
runs-on: ubuntu-latest
env:
PR_CHANGED_FILES: ${{ needs.setup.outputs.files }}
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Python deps
run: pip install -r tests/requirements.txt
- name: Validate Hofstede derived scores
run: python -m pytest tests/test_marker_derived.py -v
cultures-hofstede-reference:
name: cultures - Culture docs Hofstede-free
needs: [setup, cultures-marker-derived]
if: needs.setup.outputs.any == 'true' || needs.setup.outputs.data_changed == 'true'
runs-on: ubuntu-latest
env:
PR_CHANGED_FILES: ${{ needs.setup.outputs.files }}
PR_DATA_CHANGED: ${{ needs.setup.outputs.data_changed }}
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pytest
run: pip install pytest
- name: Run Hofstede reference tests
run: python -m pytest tests/test_hofstede_reference.py -v
cultures-hofstede-bag-loader-tests:
name: cultures - Hofstede bag loader tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Python deps
run: pip install -r tests/requirements.txt
- name: Run loader tests
run: python -m pytest tests/test_hofstede_bag_loader.py -v
cultures-hofstede-bag-integrity:
name: cultures - Hofstede bag integrity
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Python deps
run: pip install -r tests/requirements.txt
- name: Run bag integrity tests
run: |
python3 -m pytest tests/test_hofstede_bag_shape.py \
tests/test_hofstede_bag_quality.py \
tests/test_hofstede_bag_locked.py \
tests/test_hofstede_bag_completeness.py \
tests/test_hofstede_bag_fork.py \
-v
no-changes:
name: No regions/*.md files changed
needs: [setup]
if: needs.setup.outputs.any == 'false'
runs-on: ubuntu-latest
steps:
- run: echo "No regions/*.md files changed - skipping validation"
# Aggregator gate. Succeeds iff no validate.yml job failed (skipped jobs
# are fine -- they were not applicable to this PR). This is the single
# check to add to branch protection on main and culture/release, instead
# of every job above; new jobs are covered by adding them to `needs`.
validate-gate:
name: cultures - all checks passed
needs:
- khai-encoding
- khai-process
- khai-position
- khai-piece
- khai-place
- khai-persona
- cultures-sections
- cultures-branch-scope-tests
- cultures-branch-scope-diff-check
- cultures-extract-culture-slugs-tests
- cultures-release-gating-tests
- cultures-culture-branch-base
- cultures-pr-base-tests
- cultures-brownfield-tests
- cultures-audit-readme-bands-tests
- cultures-culture-traceability
- cultures-hofstede-readme-updater-tests
- cultures-history-arc
- cultures-language-process-ladder
- cultures-persona-projection-ladder
- cultures-zip-build
- cultures-pdf-build
- cultures-completeness-alignment
- cultures-completeness-module-tests
- cultures-hofstede-score-home
- cultures-validator-wiring
- cultures-metadata-format
- cultures-language-preflight
- setup
- cultures-stamp-check
- khai-language
- cultures-language
- cultures-language-dispatch
- khai-links
- cultures-links
- cultures-completeness
- cultures-audit-readme
- cultures-audit-consistency
- khai-plagiarism
- cultures-plagiarism
- cultures-hofstede-structure
- cultures-marker-derived
- cultures-hofstede-reference
- cultures-hofstede-bag-loader-tests
- cultures-hofstede-bag-integrity
- no-changes
if: always()
runs-on: ubuntu-latest
steps:
- name: Fail if any validate.yml job failed
if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
run: |
echo "::error::One or more validate.yml checks failed - see the jobs above."
exit 1
- name: Fail if a khai check skipped on a same-repo content PR
# khai jobs legitimately skip on fork PRs (a fork gets no KAIHACKS
# secret) and when no regions/*.md changed. But on a same-repo PR
# that DID change culture content, every khai job must actually
# run -- a skip there means the khai validation silently did not
# happen, and the step above (failure/cancelled only) would let it
# pass green. This converts that silent skip to red. Fork PRs are
# exempt by the head.repo condition, so they still skip cleanly.
if: >-
github.event.pull_request.head.repo.full_name == github.repository
&& needs.setup.outputs.any == 'true'
&& (needs.khai-encoding.result == 'skipped'
|| needs.khai-process.result == 'skipped'
|| needs.khai-position.result == 'skipped'
|| needs.khai-piece.result == 'skipped'
|| needs.khai-place.result == 'skipped'
|| needs.khai-persona.result == 'skipped'
|| needs.khai-language.result == 'skipped'
|| needs.khai-links.result == 'skipped'
|| needs.khai-plagiarism.result == 'skipped')
run: |
echo "::error::A khai validation job was skipped on a same-repo content PR -- khai must run on culture content. See the skipped khai-* jobs above."
exit 1
- name: All clear
run: echo "All validate.yml checks passed or were not applicable."