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
86 changes: 75 additions & 11 deletions .github/workflows/pr-gate-stale-sweep.yml
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,17 @@ jobs:
OMIT_SHA='aucun'
OMIT_CODE='aucun code HTTP'
OMIT_DETAIL='aucun message de gh'
# #22215 -- WFMAP subissait le defaut que #22277 reparait sur le
# listing : un echec d'appel laissait `2>/dev/null` jeter le stderr,
# la carte retombait vide, et le pliage perdait sa clef workflow_id
# SANS AUCUN signal -- indistinguable d'un SHA sans runs. Meme
# contrat que OMIT : compter, garder la DERNIERE defaillance, et le
# resume apres la boucle nomme l'endpoint et le code.
WF_ERR=$(mktemp)
WFMAP_FAIL=0
WFMAP_SHA='aucun'
WFMAP_CODE='aucun code HTTP'
WFMAP_DETAIL='aucun message de gh'
while read -r NUM SHA FORK MSTATE; do
[ -z "${NUM:-}" ] && continue
LISTED=$((LISTED+1))
Expand All @@ -418,7 +429,7 @@ jobs:
OMITTED=$((OMITTED+1))
OMIT_SHA=$SHA
OMIT_CODE=$(grep -oE 'HTTP [0-9]+' "$OMIT_ERR" | tail -1 || true); [ -n "$OMIT_CODE" ] || OMIT_CODE='aucun code HTTP'
OMIT_DETAIL=$(tr '\n' ' ' < "$OMIT_ERR" | cut -c1-200); [ -n "$OMIT_DETAIL" ] || OMIT_DETAIL='aucun message de gh'
OMIT_DETAIL=$(tr '\n' ' ' < "$OMIT_ERR" | cut -c1-300); [ -n "$OMIT_DETAIL" ] || OMIT_DETAIL='aucun message de gh'
continue
fi
INSPECTED=$((INSPECTED+1))
Expand Down Expand Up @@ -463,14 +474,27 @@ jobs:
# (2) les tests utilisent un champ separe `run_conclusions`
# pour exprimer le verdict par run, ce qui laisse `workflows`
# -- la clef de pliage -- inchange.
WFMAP=$(gh api "repos/$REPO/actions/runs?head_sha=$SHA&per_page=100" --jq '[.workflow_runs[] | {key:(.id|tostring), value:{wf:.workflow_id, c:.conclusion}}] | from_entries' 2>/dev/null) || WFMAP=''
WFMAP=$(gh api "repos/$REPO/actions/runs?head_sha=$SHA&per_page=100" --jq '[.workflow_runs[] | {key:(.id|tostring), value:{wf:.workflow_id, c:.conclusion}}] | from_entries' 2>"$WF_ERR") || {
WFMAP=''
WFMAP_FAIL=$((WFMAP_FAIL+1))
WFMAP_SHA=$SHA
WFMAP_CODE=$(grep -oE 'HTTP [0-9]+' "$WF_ERR" | tail -1 || true); [ -n "$WFMAP_CODE" ] || WFMAP_CODE='aucun code HTTP'
WFMAP_DETAIL=$(tr '\n' ' ' < "$WF_ERR" | cut -c1-300); [ -n "$WFMAP_DETAIL" ] || WFMAP_DETAIL='aucun message de gh'
}
else
WFMAP=''
fi
if [ -z "${WFMAP:-}" ]; then WFMAP='{}'; fi
printf '{"number":%s,"sha":"%s","fork":%s,"merge_state":"%s","checks":%s,"workflows":%s}\n' "$NUM" "$SHA" "${FORK:-false}" "${MSTATE:-}" "$BODY" "$WFMAP" >> /tmp/runs.jsonl
done < /tmp/openprs.txt
echo "[stale-sweep] open PRs listed=$LISTED inspected=$INSPECTED omitted=$OMITTED"
# #22215 -- resume WFMAP : la carte vide n'est pas une panne du sweep
# (le pliage replie par run_id), mais c'est un signal de sante de
# l'endpoint, et il etait jete. Le repli detail= et code= est nomme,
# pas devine.
if [ "$WFMAP_FAIL" -gt 0 ]; then
echo "::warning::[stale-sweep] WFMAP empty: $WFMAP_FAIL calls failed -- last endpoint=GET repos/$REPO/actions/runs?head_sha=$WFMAP_SHA&per_page=100 code=$WFMAP_CODE detail=$WFMAP_DETAIL (fold falls back to run_id, the narrower historical behavior)"
fi
# #17566 -- garde NO-OP : si on a LISTED > 0 PRs et que TOUTES les
# inspections REST ont echoue, le run ne peut pas conclure success --
# sinon le `pr-gate-sweep-health-advisory` voit un run vert a vide et
Expand Down Expand Up @@ -509,9 +533,22 @@ jobs:
# le marqueur -- pour un appel. Echec de l'appel -> carte vide -> le
# 2e argument retombe sur "" : le comportement anterieur (plus
# etroit), jamais plus permissif.
gh api "repos/$REPO/actions/workflows?per_page=100" \
--jq '[.workflows[] | {key:(.id|tostring), value:.name}] | from_entries' \
> /tmp/wfnames.json 2>/dev/null || echo '{}' > /tmp/wfnames.json
# #22215 -- l'echec de cet appel UNIQUE n'etait pas muet en
# consequence (repli carte vide = exemption retrecie au nom du check
# seul) mais muet en CAUSE : le stderr etait jete, code et message
# perdus. Capture + avertissement nomme, replis poses avant l'appel
# (`set -u`).
WFNAME_ERR=$(mktemp)
WFNAME_CODE='aucun code HTTP'
WFNAME_DETAIL='aucun message de gh'
if ! gh api "repos/$REPO/actions/workflows?per_page=100" \
--jq '[.workflows[] | {key:(.id|tostring), value:.name}] | from_entries' \
> /tmp/wfnames.json 2>"$WFNAME_ERR"; then
WFNAME_CODE=$(grep -oE 'HTTP [0-9]+' "$WFNAME_ERR" | tail -1 || true); [ -n "$WFNAME_CODE" ] || WFNAME_CODE='aucun code HTTP'
WFNAME_DETAIL=$(tr '\n' ' ' < "$WFNAME_ERR" | cut -c1-300); [ -n "$WFNAME_DETAIL" ] || WFNAME_DETAIL='aucun message de gh'
echo "::warning::[stale-sweep] wfnames empty: endpoint=GET repos/$REPO/actions/workflows?per_page=100 code=$WFNAME_CODE detail=$WFNAME_DETAIL -- advisory exemption falls back to check-name only (the narrower historical behavior)"
echo '{}' > /tmp/wfnames.json
fi

python3 - <<'PY' > /tmp/candidates.txt
import json, os, re, sys
Expand Down Expand Up @@ -1049,6 +1086,11 @@ jobs:
echo "[stale-sweep] mute cap reached ($MAX_MUTE) -- the rest next sweep"
break
fi
# silence justifie #22215 : l'echec de cet appel n'est PAS muet en
# aval -- LINE vide a des sorties nommees (titre de defaut #15825
# en mode reel, « log du run illisible -- leg mute gardee » en
# garde ci-dessous) ; extraire le stderr n'y ajouterait rien, le
# geste est le meme (garder la leg pour le prochain sweep).
LINE=$(gh run view "$RUN_ID" --repo "$REPO" --log 2>/dev/null \
| grep '\[pr-gate\]' | tail -1 | sed 's/.*\[pr-gate\] //') || true
if [ -z "${LINE:-}" ] && [ "${DRY_RUN:-false}" != "true" ]; then
Expand Down Expand Up @@ -1091,11 +1133,24 @@ jobs:
# et ne justifie pas une nouvelle tete. Un echec de l'appel laisse la
# variable vide -> le verdict retombe sur l'age SEUL (jamais plus
# permissif que le comportement anterieur).
# #22278 -- le stderr etait jete : un appel casse rendait la
# profondeur vide sans que rien ne le dise. Meme idiome que
# CAND_ERR ci-dessous : capturer, puis nommer.
QUEUE_ERR=$(mktemp)
QUEUE_DEPTH=$(gh api "repos/$REPO/actions/runs?per_page=1&status=queued" \
--jq '.total_count' 2>/dev/null) || QUEUE_DEPTH=""
--jq '.total_count' 2>"$QUEUE_ERR") || QUEUE_DEPTH=""
if [ -s "$QUEUE_ERR" ]; then
echo "::warning::profondeur de file illisible ($(head -1 "$QUEUE_ERR")) -- le verdict retombe sur l'age seul"
fi

m=0
i=0
# #22215 -- le message de report du RUN etait nomme mais ampute : le
# code HTTP restait dans un stderr jete, donc « transient » etait une
# supposition, pas une mesure. Capture + extraction, repli pose
# avant la boucle (`set -u`).
CAND_ERR=$(mktemp)
CAND_CODE='aucun code HTTP'
# #19050 -- refus STRUCTURELS de relance (run termine, non
# relancable) : comptes a part des `served`, parce qu'ils ne sont
# PAS repares et qu'un rouge compte comme repare est un mensonge.
Expand Down Expand Up @@ -1162,8 +1217,11 @@ jobs:
# sweep no-oped. Falling through to RUN_ID-empty would mislabel
# the case "NO workflow run", so skip the candidate honestly.
RUN=$(gh api "repos/$REPO/actions/runs?head_sha=$SHA&event=pull_request&per_page=100" \
--jq '[.workflow_runs[] | select(.name == "PR gate")] | sort_by(.created_at) | last' 2>/dev/null) \
|| { echo "[stale-sweep] #$NUM ($SHA): actions/runs API call failed (transient) -- deferring candidate to next sweep"; continue; }
--jq '[.workflow_runs[] | select(.name == "PR gate")] | sort_by(.created_at) | last' 2>"$CAND_ERR") \
|| { CAND_CODE=$(grep -oE 'HTTP [0-9]+' "$CAND_ERR" | tail -1 || true); [ -n "$CAND_CODE" ] || CAND_CODE='aucun code HTTP'; echo "[stale-sweep] #$NUM ($SHA): actions/runs API call failed (transient, endpoint=GET repos/$REPO/actions/runs?head_sha=$SHA&event=pull_request&per_page=100 code=$CAND_CODE) -- deferring candidate to next sweep"; continue; }
# silence justifie #22215 : jq local sur des donnees deja en memoire
# (RUN, fetch reussi ci-dessus) -- un echec ou un vide rend RUN_ID
# vide, et la sortie nommee juste en dessous prend le relais.
RUN_ID=$(printf '%s' "${RUN:-}" | jq -r '.id // empty' 2>/dev/null)

if [ -z "${RUN_ID:-}" ]; then
Expand Down Expand Up @@ -1197,6 +1255,9 @@ jobs:
# depot ne consigne pas pour la cause #17680 (une correspondance
# de texte serait devinee, donc fausse un jour sans prevenir).
if ! gh run rerun "$RUN_ID" --repo "$REPO"; then
# silence justifie #22215 : jq local sur RUN (deja en memoire,
# fetch reussi plus haut) -- un vide tombe dans la branche
# structurelle nommee ci-dessous, aucun signal n'est perdu.
RUN_STATUS=$(printf '%s' "${RUN:-}" | jq -r '.status // empty' 2>/dev/null)
if [ "${RUN_STATUS:-}" = "completed" ]; then
# Un run TERMINE qui refuse la relance ne l'accordera jamais :
Expand Down Expand Up @@ -1375,17 +1436,20 @@ jobs:
TIM_DETAIL='aucun message de gh'
RUN=$(gh api "repos/$REPO/actions/runs/$RUN_ID" 2>"$TIM_ERR") || {
TIM_CODE=$(grep -oE 'HTTP [0-9]+' "$TIM_ERR" | tail -1 || true); [ -n "$TIM_CODE" ] || TIM_CODE='aucun code HTTP'
TIM_DETAIL=$(tr '\n' ' ' < "$TIM_ERR" | cut -c1-200); [ -n "$TIM_DETAIL" ] || TIM_DETAIL='aucun message de gh'
TIM_DETAIL=$(tr '\n' ' ' < "$TIM_ERR" | cut -c1-300); [ -n "$TIM_DETAIL" ] || TIM_DETAIL='aucun message de gh'
echo "::warning::stale-sweep timing unavailable: endpoint=GET repos/$REPO/actions/runs/$RUN_ID code=$TIM_CODE detail=$TIM_DETAIL"
exit 0
}
# The endpoint defaults to filter=latest. Keep it that way: filter=all
# includes prior attempts after a re-run and makes the current job name
# ambiguous, exactly when this workflow is measuring repeated service.
# #22278 -- l'avertissement nomme l'appel REEL : `&per_page=100` fait
# partie de l'URL interpellee, un lecteur qui la recopie pour rejouer
# l'echec doit obtenir le meme appel, pas un autre.
JOBS=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?filter=latest&per_page=100" 2>"$TIM_ERR") || {
TIM_CODE=$(grep -oE 'HTTP [0-9]+' "$TIM_ERR" | tail -1 || true); [ -n "$TIM_CODE" ] || TIM_CODE='aucun code HTTP'
TIM_DETAIL=$(tr '\n' ' ' < "$TIM_ERR" | cut -c1-200); [ -n "$TIM_DETAIL" ] || TIM_DETAIL='aucun message de gh'
echo "::warning::stale-sweep timing unavailable: endpoint=GET repos/$REPO/actions/runs/$RUN_ID/jobs?filter=latest code=$TIM_CODE detail=$TIM_DETAIL"
TIM_DETAIL=$(tr '\n' ' ' < "$TIM_ERR" | cut -c1-300); [ -n "$TIM_DETAIL" ] || TIM_DETAIL='aucun message de gh'
echo "::warning::stale-sweep timing unavailable: endpoint=GET repos/$REPO/actions/runs/$RUN_ID/jobs?filter=latest&per_page=100 code=$TIM_CODE detail=$TIM_DETAIL"
exit 0
}

Expand Down
114 changes: 108 additions & 6 deletions scripts/tests/test_stale_sweep_noop_verdict.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,30 @@ def test_les_replis_du_listing_sont_poses_avant_l_appel():
)


def _exige_omitter_sha_dans_sa_branche(texte: str) -> None:
"""OMIT_SHA=$SHA doit vivre DANS la branche de no-op des check-runs.

La branche couvre du `; then` de l'appel check-runs au `continue` qui la
referme. Hors de cette fenetre, l'affectation s'executerait pour TOUTES
les PRs lues (reussies ou omises) : le no-op « last endpoint
.../$OMIT_SHA/... » nommerait alors un SHA arbitraire -- celui de la
derniere PR inspectee, pas celui du dernier echec (#22278).
"""
appel = re.search(
r'if ! BODY=\$\(gh api "repos/\$REPO/commits/\$SHA/check-runs',
texte,
)
assert appel, "appel check-runs par PR introuvable : la forme de l'appel a change"
ouverture = texte.index("; then", appel.end())
sortie = texte.index("continue", ouverture)
affectation = texte.index("OMIT_SHA=$SHA")
assert ouverture < affectation < sortie, (
f"OMIT_SHA=$SHA vit hors de sa branche (position {affectation}, "
f"branche [{ouverture}, {sortie}]) : le no-op des check-runs ne "
"nommerait plus le SHA du DERNIER echec"
)


def test_le_no_op_des_check_runs_nomme_le_dernier_endpoint_et_le_code():
# Le no-op « check-runs unreadable for all N PRs » couvre une boucle : la
# cause utile est celle du DERNIER echec, d'ou le SHA de la PR concernee.
Expand All @@ -146,12 +170,37 @@ def test_le_no_op_des_check_runs_nomme_le_dernier_endpoint_et_le_code():
r"repos/\$REPO/commits/\$OMIT_SHA/check-runs code=\$OMIT_CODE",
texte,
), "le no-op des check-runs ne nomme plus le dernier endpoint et son code"
assert "OMIT_SHA=$SHA" in texte, (
"sans le SHA, « last endpoint=.../commits//check-runs » ne designe rien"
)
# Positionnel, pas juste present : l'affectation doit s'executer DANS la
# branche d'echec, sinon elle ecrase le SHA pour chaque PR lue.
_exige_omitter_sha_dans_sa_branche(texte)
assert "code=$OMIT_CODE" in texte and "detail=$OMIT_DETAIL" in texte


def test_temoin_deplacer_omitter_sha_hors_de_sa_branche_fait_rougir():
# Preuve que l'assertion positionnelle a des dents (#22278) : on deplace
# l'affectation juste APRES le `fi` qui referme sa branche -- elle
# s'executerait alors sur chaque PR lue -- et on exige que la verification
# la refuse. Sans ce temoin, l'assertion pourrait passer par accident.
texte = SWEEP.read_text(encoding="utf-8")
ancre = re.search(r"^[ \t]*OMIT_SHA=\$SHA$", texte, re.M)
assert ancre, "affectation OMIT_SHA introuvable"
ligne = ancre.group(0)
assert texte.count(ligne) == 1, "ancre du temoin dupliquee : ambigu"
avant, apres = texte.split(ligne, 1)
# le `fi` qui referme SA branche suit immediatement son `continue` (le
# corps de la branche ne porte aucun if imbrique)
fin_branche = re.search(r"continue\n([ \t]*)fi\n", apres)
assert fin_branche, "structure continue/fi introuvable apres la branche"
indentation = fin_branche.group(1)
sorti = (
avant + apres[: fin_branche.end()]
+ indentation + "OMIT_SHA=$SHA\n"
+ apres[fin_branche.end():]
)
with pytest.raises(AssertionError, match="hors de sa branche"):
_exige_omitter_sha_dans_sa_branche(sorti)


def test_l_appel_par_pr_ne_jette_plus_le_stderr():
# `2>/dev/null` etait la cause mecanique du mutisme : le message de gh
# (donc le code HTTP) etait jete a la source. La capture doit le garder.
Expand All @@ -170,7 +219,9 @@ def test_l_appel_par_pr_ne_jette_plus_le_stderr():
def test_les_avertissements_de_timing_nomment_endpoint_et_code():
# Les deux avertissements de mesure (run, jobs) sont *advisory* : ils ne
# font pas rougir le run, mais une duree absente est indistinguable d'un
# balayage instantane si la cause n'est pas nommee.
# balayage instantane si la cause n'est pas nommee. #22278 : l'endpoint
# nomme est l'appel REEL -- `&per_page=100` compris, sinon un lecteur qui
# recopie l'URL pour rejouer l'echec interpelle un autre endpoint.
texte = SWEEP.read_text(encoding="utf-8")
assert re.search(
r"timing unavailable: endpoint=GET repos/\$REPO/actions/runs/\$RUN_ID "
Expand All @@ -179,15 +230,66 @@ def test_les_avertissements_de_timing_nomment_endpoint_et_code():
), "l'avertissement de timing ne nomme plus l'endpoint du run"
assert re.search(
r"timing unavailable: endpoint=GET repos/\$REPO/actions/runs/\$RUN_ID/jobs\?"
r"filter=latest code=\$TIM_CODE",
r"filter=latest&per_page=100 code=\$TIM_CODE",
texte,
), "l'avertissement de timing ne nomme plus l'endpoint des jobs"
), "l'avertissement de timing ne nomme plus l'endpoint REEL des jobs (avec per_page)"
assert texte.count("grep -oE 'HTTP [0-9]+'") >= 3, (
"chaque famille d'echec (listing, check-runs, timing) doit extraire le "
"code du stderr, pas le deduire"
)


# --- Muet ou nomme : l'inventaire des 2>/dev/null (#22278) --------------------


def test_chaque_redirection_dev_null_restante_est_justifiee():
# Critere #22278 : chaque `2>/dev/null` actif restant du sweep emet un
# signal nomme (stderr capture, code extrait) OU porte son commentaire
# « silence justifie ». Les appels qui signalent ne redirigent plus vers
# /dev/null du tout (ils capturent), donc ce garde ne voit QUE les muets :
# un muet nouveau ou non justifie le fait rougir -- et un inventaire vide
# aussi, parce que les commentaires de justification seraient devenus du
# code mort a retirer.
texte = SWEEP.read_text(encoding="utf-8")
lignes = texte.splitlines()
muets = [
(i, ligne)
for i, ligne in enumerate(lignes)
if "2>/dev/null" in ligne and not ligne.lstrip().startswith("#")
]
assert muets, (
"plus aucun 2>/dev/null actif : les commentaires « silence justifie » "
"sont devenus du code mort, a retirer avec ce garde"
)
for i, ligne in muets:
# le bloc de commentaires contigu juste au-dessus du site porte la
# justification (le marker peut y etre en premiere ligne d'un bloc
# de plusieurs)
bloc = []
j = i - 1
while j >= 0 and lignes[j].lstrip().startswith("#"):
bloc.append(lignes[j])
j -= 1
contexte = " ".join(bloc)
assert "# silence justifie" in ligne or "# silence justifie" in contexte, (
f"2>/dev/null muet et non justifie ligne {i + 1} : {ligne.strip()!r} "
"(#22278 : capturer le stderr et nommer le code, ou justifier le silence)"
)


def test_les_details_gh_sont_tronques_a_une_meme_largeur():
# #22278 : le listing coupait son detail a 300, les familles OMIT et TIM
# a 200 -- deux `detail=` lus cote a cote ne peuvent pas dire si un ecart
# de longueur est un vrai contenu ou un budget different. Une seule
# largeur pour tous les details extraits du stderr de gh.
texte = SWEEP.read_text(encoding="utf-8")
largeurs = {int(m) for m in re.findall(r"cut -c1-(\d+)", texte)}
assert largeurs == {300}, (
f"largeurs de troncature divergentes : {sorted(largeurs)} -- unifier "
"(300, le budget historique du listing) ou justifier l'ecart"
)


# --- Execution sous le shell REEL de GitHub Actions --------------------------
#
# Les tests ci-dessus lisent le YAML, et une lecture statique ne voit pas
Expand Down
Loading