Skip to content

fix(gametheory,#13468): REPAIR4 GT-13b Kuhn + pedagogie Safe Subgame reintégrée (vs #13501 contenu supprimé) #5090

fix(gametheory,#13468): REPAIR4 GT-13b Kuhn + pedagogie Safe Subgame reintégrée (vs #13501 contenu supprimé)

fix(gametheory,#13468): REPAIR4 GT-13b Kuhn + pedagogie Safe Subgame reintégrée (vs #13501 contenu supprimé) #5090

name: Translation Guard
# Purpose
# -------
# PREVENT agents/humans from hand-editing translation-owned files on feature
# branches. Translations are DERIVED artifacts (like the catalog), owned by:
# - translation-sync.yml (per-PR auto-commit bot, same-repo PRs)
# - the T1/T3/T4 toolchain (extract_cells_to_csv.py -> translate_csv.py
# -> render_notebook.py, scripts/translation/)
#
# The source of truth for multilingual alignment is the NOTEBOOK in its source
# language (FR) + the translation CSV (translations/<famille>/<serie>.csv). A
# PR that edits a *_<lang>.ipynb or a translations/** CSV by hand breaks the
# invariant: the next run of translation-sync.yml would regenerate (and
# silently overwrite) the hand-edit. This is the exact analog of the
# stale-catalog-silent-revert pattern (#2744/#2632), applied to translations.
#
# Owned files (FAIL if a non-bot author touches them):
# - translations/** (the sync CSVs, derived from notebooks)
# - MyIA.AI.Notebooks/**/*_{en,ru,pt,es,ar,fa,zh}.ipynb (rendered translations)
#
# Exceptions:
# - github-actions[bot] (translation-sync.yml auto-commits)
# - workflow_dispatch (emergency maintainer override)
#
# See #10038 (i18n umbrella), #10042 (grain C), #4957 (infra design).
on:
pull_request:
branches: [ main ]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: translation-guard-${{ github.ref }}
cancel-in-progress: true
jobs:
guard:
name: "No hand-edited translation files on feature branch"
runs-on: ubuntu-latest
steps:
# Tranche 1 de #12690 : ce workflow n'interroge QUE le graphe de commits
# et des NOMS de chemins (`git diff --name-only`, `git cat-file -e` pour
# verifier l'existence d'un blob sans le lire, `git fetch origin main`).
# Aucun blob historique n'est materialise -- `filter: blob:none` laisse
# le graphe complet disponible et rend les blobs paresseux (jamais
# transferes, jamais refetches). `sparse-checkout` limite le working
# tree materialise aux seuls chemins requis : `scripts/ci/` (le helper
# d'override) et `.github/` (le commentaire HTML invisible du bot). Le
# clone plein transferait ~1,82 Gio (cf en-tete stale-base-warning.yml
# pour la mesure de reference) ; ce checkout transfere <10 Mo.
# Pourquoi c'est safe ici :
# * `git diff --name-only BASE...HEAD -- <paths>` : graphe only.
# * `git cat-file -e "BASE:$f"` : existence check, pas
# lecture de contenu.
# * `python3 scripts/ci/translation_override_required.py` : helper
# sous scripts/ci/,
# materialise par le
# sparse-checkout.
# See #11843 (pattern), #12690 (goulot narrow structurel).
- name: "Checkout base (sparse-checkout + filter: blob:none, tranche 1 issue 12690)"
uses: actions/checkout@v4
with:
fetch-depth: 0
filter: blob:none
sparse-checkout: |
.github
scripts/ci
- name: Check for translation-owned file changes
id: check
env:
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
set -euo pipefail
# Allow the translation auto-sync bot to bypass this check entirely.
if [ "$PR_AUTHOR" = "github-actions[bot]" ]; then
echo "::notice title=Translation guard::Author is github-actions[bot] (translation-sync automation) — guard bypassed."
echo "bypass=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# For workflow_dispatch (no pull_request context), skip.
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "::notice title=Translation guard::Manual dispatch — guard bypassed."
echo "bypass=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# 3-dot diff: translation-owned files changed on the PR branch.
# translations/** = sync CSVs (derived). *_<lang>.ipynb = rendered
# translation notebooks (derived by render_notebook.py T4). Target
# langs match check_perimeter.py TARGET_LANGS (en/es/ar/fa/zh/ru/pt),
# the single source of truth (#10109).
#
# Hand-edit = modification d'un fichier dérivé déjà présent sur
# origin/<base>. L'ajout d'un nouveau fichier dérivé (jamais présent
# sur main, premier rendu d'un notebook source) est **légitime** : il
# ne peut pas avoir été hand-édité puisque pas encore commit. On le
# laisse passer ; translation-sync.yml le reprendra ensuite depuis
# le CSV comme tout autre _en.ipynb. Cf. #10307 (cycle c.190 --
# premier cas déclencheur : PR #10306 deep_research_optimization_en
# jamais rendu auparavant).
ALL_CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD -- \
'translations/**' \
'MyIA.AI.Notebooks/**/*_en.ipynb' \
'MyIA.AI.Notebooks/**/*_ru.ipynb' \
'MyIA.AI.Notebooks/**/*_pt.ipynb' \
'MyIA.AI.Notebooks/**/*_es.ipynb' \
'MyIA.AI.Notebooks/**/*_ar.ipynb' \
'MyIA.AI.Notebooks/**/*_fa.ipynb' \
'MyIA.AI.Notebooks/**/*_zh.ipynb' \
|| true)
# Reduce to files that ALREADY EXIST on the base. New files
# (additions) are produced by the legitimate rendering pipeline
# for a notebook that wasn't translated before — see comment above.
CHANGED=""
for f in $ALL_CHANGED; do
if git cat-file -e "origin/${{ github.base_ref }}:$f" 2>/dev/null; then
CHANGED="$CHANGED $f"
else
echo "::notice title=Translation guard::$f -- new file on feature branch (not on base), allowed."
fi
done
CHANGED="${CHANGED# }" # strip leading whitespace
if [ -z "$CHANGED" ]; then
echo "::notice title=Translation guard::No translation-owned files MODIFIED — clean."
echo "violated=false" >> "$GITHUB_OUTPUT"
else
echo "::error title=Translation guard FAILED::PR modifies translation-owned files that are generated by automation ($CHANGED). See #10042 and #10038. Edit the source notebook (FR) instead; the bot re-derives translations."
echo ""
echo "## Translation Guard Violation" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "This PR modifies translation-owned (derived) files:" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
for f in $CHANGED; do
echo "- \`$f\`" >> "$GITHUB_STEP_SUMMARY"
done
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### How to fix" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Translation CSVs (\`translations/**\`) and rendered notebooks (\`*_<lang>.ipynb\`) are **derived** from the source notebook + the T1/T3/T4 toolchain. Edit the **source notebook** (FR) instead; \`translation-sync.yml\` re-extracts, re-translates (changed cells only), and re-renders automatically." >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo '```bash' >> "$GITHUB_STEP_SUMMARY"
echo "git checkout origin/main -- $CHANGED" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "See [#10038](https://github.com/${{ github.repository }}/issues/10038) (i18n umbrella) and [#10042](https://github.com/${{ github.repository }}/issues/10042) (grain C)." >> "$GITHUB_STEP_SUMMARY"
echo "violated=true" >> "$GITHUB_OUTPUT"
fi
# Override path (#10332): when the violation path fires, the override
# gate consults the dual-key (label `translation-override` AND comment
# marker `[TRANSLATION-OVERRIDE] <motif>`). Both required -- the
# cliquet is not disarmed. The decision lives in
# `scripts/ci/translation_override_required.py` (testable, injectable
# fetchers -- unit tests never touch the network).
- name: Resolve translation-guard override (#10332)
id: override
if: steps.check.outputs.violated == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -uo pipefail
python3 scripts/ci/translation_override_required.py \
--pr-number "$PR_NUMBER" > /tmp/override.json
RC=$?
echo "--- override verdict ---"
cat /tmp/override.json
OVERRIDE_APPLIED=$(python3 -c "import json; print(json.load(open('/tmp/override.json')).get('override_applied', False))")
if [ "$OVERRIDE_APPLIED" = "True" ]; then
MOTIF=$(python3 -c "import json; print(json.load(open('/tmp/override.json')).get('motif',''))")
echo "override_applied=true" >> "$GITHUB_OUTPUT"
echo "motif=$MOTIF" >> "$GITHUB_OUTPUT"
else
echo "override_applied=false" >> "$GITHUB_OUTPUT"
fi
# Exit code of the python script decides: 0 = pass, 1 = block.
# We always proceed to the next step so we can print a structured
# log either way; the `Fail if violated (or unsatisfied override)`
# step below reads `override_applied`.
exit 0
- name: Fail if violated (or unsatisfied override)
if: steps.check.outputs.violated == 'true' && steps.override.outputs.override_applied != 'true'
run: |
REASON=$(python3 -c "import json; print(json.load(open('/tmp/override.json')).get('reason','unknown'))" 2>/dev/null || echo "unknown")
echo "::error title=Translation guard FAILED::$REASON"
gh pr comment "${{ github.event.pull_request.number }}" --body "$(cat <<EOF
<!-- translation-guard-violation -->
**Translation guard violation (#10332).**
$REASON
Three exits to clear this gate:
1. Restore the derived files (\`git checkout origin/main -- translations/ <lang-notebooks>\`) and edit the source notebook instead -- the bot re-derives.
2. Add the **\`translation-override\` label** AND a PR comment whose FIRST line is **\`[TRANSLATION-OVERRIDE] <motif>\`** (both required -- dual-key, see #10332).
3. Ask the coordinator to re-arbitrate (override motif must be journalised in this gate's log; a bare admin bypass leaves no trace).
See #10332 for the override protocol.
EOF
)" 2>/dev/null || true
exit 1
- name: Journalise accepted override (#10332)
if: steps.override.outputs.override_applied == 'true'
run: |
MOTIF="${{ steps.override.outputs.motif }}"
echo "::notice title=Translation guard::OVERRIDE accepted -- motif: $MOTIF. The override is auditable; see #10332."
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "## Translation Guard OVERRIDE accepted (#10332)" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Dual-key satisfied: label \`translation-override\` AND comment marker \`[TRANSLATION-OVERRIDE]\`." >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**Motif:** $MOTIF" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "See [#10332](https://github.com/${{ github.repository }}/issues/10332) for the override protocol." >> "$GITHUB_STEP_SUMMARY"