Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 57 additions & 5 deletions .github/workflows/twin-index-collision-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,22 +65,45 @@ name: Twin index collision gate
# premiere fois, et le `GITHUB_TOKEN` d'une PR de fork est en **lecture
# seule**.
#
# 2. LA BASE LUE. La porte compare `pull_request.base.sha`, c'est-a-dire
# `main` **au moment du push**. Une collision introduite sur `main`
# **entretemps** par une autre PR n'est pas vue ici : la porte referme la
# course au push suivant, pas au merge. Le garde de merge reste donc
# 2. LA COURSE ENTRE DEUX PRs PARALLELES (fermee par la couche OPEN-PR).
# La porte compare `pull_request.base.sha` -- `main` **au moment du push**.
# Deux PRs qui prennent le meme ordinal pendant que ni l'une ni l'autre
# n'est mergee passent donc **toutes les deux** sur cette base : `main` ne
# porte encore rien, et le verdict est VERT des deux cotes.
# C'est exactement ce qui est arrive a #24010 : au push, sa base ne
# portait pas les ordinals `0024`-`0027` apportes par #23832, encore
# OUVERTE. Les deux ont ete mergees, et `main` a recu trois doublons
# (`0024`, `0025`, `0026`) que #24518 a dus renumeroter deux jours plus
# tard -- en rouge fleet-wide sur `Scripts Tests (CPU)`.
# La couche OPEN-PR ferme cette course **avant** le merge : l'ordinal
# revendique par une autre PR OUVERTE est refuse, avec le meme geste
# (`git mv` PUR) que les autres verdicts. Elle lit l'API des fichiers des
# PRs ouvertes -- l'ordinal est dans le NOM du fichier, aucun fetch n'est
# necessaire.
# Ce qui subsiste : une PR creee apres le passage de la porte d'une autre
# qui revendique le meme ordinal (la porte ne se rejoue pas sur un push
# qui ne touche pas le registre). La PR creee en DERNIER voit toujours
# l'autre -- une PR existe des son push -- : c'est donc toujours la
# plus recente qui est refusee, et l'ordinal est renumerote avant qu'une
# seule des deux ne merge. Le garde de merge reste necessaire pour le
# cas ou la porte n'a pas pu lire l'API.

on:
pull_request:
paths:
- 'scripts/notebook_tools/twin_pairs.d/**'
- 'scripts/notebook_tools/check_twin_index_collisions.py'
- 'scripts/notebook_tools/check_twin_parity.py'
- 'scripts/notebook_tools/twin_open_claims.py'
workflow_dispatch:

permissions:
contents: read
# Les ordinals revendiques par les PRs OUVERTES se lisent par l'API des
# fichiers d'une PR (`pull-requests: read`) : c'est la couche qui ferme la
# course entre deux PRs paralleles (cf. « La course que la base ne voit
# pas », plus bas).
pull-requests: read

concurrency:
group: twin-index-collision-gate-${{ github.ref }}
Expand All @@ -104,6 +127,30 @@ jobs:
with:
python-version: '3.11'

- name: Collect the ordinal claims of the open PRs
# La porte de base juge cette PR contre SA base au push : deux PRs
# paralleles qui prennent le meme ordinal passent donc toutes les deux,
# et la collision n'apparait qu'apres le merge -- sur `main`, en rouge
# fleet-wide (mesure #22299 : #24010 vs #23832, `0024`-`0026`).
# L'ordinal est dans le NOM du fichier : la liste des fichiers d'une
# PR suffit, aucun fetch n'est necessaire.
# Un API illisible ne BLOQUE pas la PR (la porte de base, elle, reste
# appliquee) : le motif est affiche par l'organe, pas masque.
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set +e
python scripts/notebook_tools/twin_open_claims.py --json \
> twin_open_claims.json 2>twin_open_claims_stderr.log
EXIT=$?
set -e
if [ "$EXIT" != "0" ]; then
echo "::notice title=Twin ordinal claims illisibles::L'API des PRs ouvertes n'a pas pu etre lue (rc=$EXIT). La porte d'index s'applique sans la couche OPEN-PR."
cat twin_open_claims_stderr.log || true
rm -f twin_open_claims.json
fi

- name: Gate the attestation index collisions
# `EVENT`/`PR_BASE`/`PR_HEAD` sont lus dans l'evenement, jamais deduits
# de la forme du checkout : le checkout d'un evenement `pull_request`
Expand All @@ -114,6 +161,7 @@ jobs:
EVENT: ${{ github.event_name }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
PR_HEAD: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set +e
BASE=origin/main
Expand All @@ -123,8 +171,12 @@ jobs:
HEAD_SHA="$PR_HEAD"
fi
echo "Twin index collision : base=$BASE head=$HEAD_SHA"
CLAIMS_ARGS=""
if [ -s twin_open_claims.json ]; then
CLAIMS_ARGS="--open-claims twin_open_claims.json --head-pr $PR_NUMBER"
fi
python scripts/notebook_tools/check_twin_index_collisions.py \
--base "$BASE" --head "$HEAD_SHA" --gate --json \
--base "$BASE" --head "$HEAD_SHA" --gate --json $CLAIMS_ARGS \
> twin_index_collision.json 2>twin_index_collision_stderr.log
EXIT=$?
set -e
Expand Down
1 change: 1 addition & 0 deletions scripts/ci/fast_lane_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -3286,6 +3286,7 @@ def _body_md_hierarchy(rc: int, payload: str, ctx: dict) -> str:
paths=[
"scripts/notebook_tools/twin_pairs.d/**",
"scripts/notebook_tools/check_twin_index_collisions.py",
"scripts/notebook_tools/twin_open_claims.py",
"scripts/ci/fast_lane.py",
"scripts/ci/fast_lane_registry.py",
],
Expand Down
104 changes: 94 additions & 10 deletions scripts/coordination/merge_ready.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
import re
import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
Expand Down Expand Up @@ -188,6 +189,7 @@
GATE_PATH = SCRIPTS_DIR / "check_adjoint_prevalidation.py"
NITS_PATH = SCRIPTS_DIR / "check_unaddressed_nits.py"
TWIN_PATH = SCRIPTS_DIR / "notebook_tools" / "check_twin_index_collisions.py"
TWIN_CLAIMS_PATH = SCRIPTS_DIR / "notebook_tools" / "twin_open_claims.py"
TWIN_REGISTRY_PREFIX = "scripts/notebook_tools/twin_pairs.d/"
# Issue #20774 : au merge, le slot-reservation-guard doit lire la source
# `open_prs` (mode en ligne) pour qu'une collision cross-PR rougisse avant
Expand Down Expand Up @@ -884,6 +886,15 @@ def twin_collision_reason(
mergee gagne, la seconde renumerote. Avertissement seul,
le merge continue (le gate CI bloque de toute facon si la
seconde arrive avant renumerotation).

Le jugement le plus tranchant est celui de l'ARBRE DE FUSION (`--merged`) :
il repond a la seule question qui compte au merge -- « `main` sera-t-il
rouge apres ce merge ? ». Le jugement base+tete compare deux revisions
prises separement, et la base lue est celle du MOMENT DE LA LECTURE ;
une collision introduite sur `main` par une autre PR entretemps n'y est
pas visible, alors qu'elle est dans l'arbre de fusion (mesure du
2026-11-03 : #23832 et #24010 ont alloue les memes ordinals 0024-0026
sur `search-05-geneticalgorithms`, et les deux PRs sont passees).
"""
touched = any(
str((row or {}).get("path") or "").startswith(TWIN_REGISTRY_PREFIX)
Expand All @@ -897,21 +908,94 @@ def twin_collision_reason(
)
if fetch.returncode != 0:
return "twin-collision-unreadable:fetch"
res = runner.run(
[sys.executable, str(TWIN_PATH), "--repo", str(REPO_ROOT),
"--base", "origin/main", "--head", head, "--json"]
# Les PRs OUVERTES : l'ordinal qu'une PR CONCURRENTE revendique deja. Ni
# le jugement base+tete ni l'arbre de fusion ne le voient -- les deux ne
# portent que des revisions MERGEES. L'API des PRs ouvertes, elle, sait.
# Mesure du 2026-11-04 : 5 collisions de ce type etaient VIVANTES entre PRs
# ouvertes (csp-1-fundamentals 0034, csp-6-hybridization 0024/0025,
# gametheory-15-cooperativegames 0044, search-13-limited-discrepancy-search
# 0013) -- aucune n'etait visible de l'arbre de fusion, puisque aucune des
# deux PRs n'etait mergee.
claims_res = runner.run(
[sys.executable, str(TWIN_CLAIMS_PATH), "--json"]
)
if claims_res.returncode != 0:
# « je n'ai pas pu lire » n'est pas « c'est propre » : skip dur, comme
# pour le fetch de l'arbre de fusion. La PR sera rejugee.
return "twin-collision-unreadable:open-claims"
try:
fd, claims_path = tempfile.mkstemp(prefix="twin-claims-",
suffix=".json")
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(claims_res.stdout)
except OSError:
return "twin-collision-unreadable:open-claims"
try:
res = runner.run(
[sys.executable, str(TWIN_PATH), "--repo", str(REPO_ROOT),
"--base", "origin/main", "--head", head, "--merged", "--json",
"--open-claims", claims_path, "--head-pr", str(pr)]
)
finally:
# L'organe a lu le fichier ; il ne sert plus.
try:
os.unlink(claims_path)
except OSError:
pass
# Le rapport JSON est lu MEME quand l'organe rend 0 : le mode `--merged`
# peut etre ILLISIBLE (merge en conflit) alors que le jugement classique
# ne bloque rien -- et « je n'ai pas pu lire » n'est pas « c'est propre ».
# Lecture JSON tolérante : si le champ `verdict` est absent (organe
# anterieur a #18683), on retombe sur le motif historique
# ``twin-index-collision`` pour rétro-compat.
try:
data = json.loads(res.stdout)
except (json.JSONDecodeError, ValueError):
data = None
if isinstance(data, dict):
# --- ARBRE DE FUSION (le jugement qui repond a la question du merge)
# Le jugement base+tete dit ce que la base et la tete portent
# separement. L'arbre de fusion dit ce que le MERGE rend visible --
# c'est le seul verdict qui repond a « `main` sera-t-il rouge apres
# ce merge ? ». Il est juge AVANT le classement ON-MAIN/MULTI-PR :
# un doublon que le merge INTRODUIT ne se classe pas (il n'existe
# dans aucune revision prise isolement), et le laisser passer parce
# que `cross_ref` est vide laisserait merger la PR qui casse `main`.
merged_reasons = data.get("merged_reasons") or []
if merged_reasons:
first = merged_reasons[0]
return (
"twin-collision-on-merge:%s/%s -- %s"
% (first.get("pair"), first.get("index"),
first.get("gist")
or "renumeroter le fichier que la tete ajoute vers le "
"premier index libre de la paire par `git mv` PUR")
)
if data.get("merged_unreadable"):
# « je n'ai pas pu lire » n'est pas « c'est propre » : l'arbre de
# fusion n'est pas definissable (merge en conflit). Skip dur --
# la PR sera rejugee apres rebasage.
return "twin-collision-unreadable:merged"
# --- PRs OUVERTES : l'ordinal revendique par une PR concurrente
# L'arbre de fusion repond a « que portera `main` apres ce merge ? ».
# Il ne voit pas une PR CONCURRENTE non encore mergee : les deux PRs
# revendiquent le meme ordinal, aucune n'est dans l'arbre de fusion, et
# la collision n'apparait qu'apres le second merge -- en rouge
# fleet-wide. Merger celle-ci la rendrait inevitable.
open_reasons = data.get("open_pr_reasons") or []
if open_reasons:
first = open_reasons[0]
return (
"twin-collision-open-pr:%s/%s -- %s"
% (first.get("pair"), first.get("index"),
first.get("gist")
or "renumeroter le fichier de la tete vers le premier "
"index libre de la paire par `git mv` PUR")
)
if res.returncode == 0:
return None
if res.returncode == 1:
# L'organe a detecte une collision ; on la classifie par verdict.
# Lecture JSON tolérante : si le champ `verdict` est absent (organe
# anterieur a #18683), on retombe sur le motif historique
# ``twin-index-collision`` pour rétro-compat.
try:
data = json.loads(res.stdout)
except (json.JSONDecodeError, ValueError):
return "twin-index-collision"
if not isinstance(data, dict):
return "twin-index-collision"
cross_ref = data.get("cross_ref") or []
Expand Down
Loading
Loading