Files
glenn schrooyenandClaude Opus 5.5 550bd6fa52
checks / unit (pull_request) Failing after 3s
infra-pin / fresh (pull_request) Successful in 1s
TOOL-9: pin checks print their FAIL line under pipefail; job tokens via header
A missing or comment-only .gitea/infra-pin now ends on the workflow's own
FAIL line instead of git's `fatal: path ... does not exist` (still red). Every
x-access-token remote URL becomes an env-set Authorization header, so no
token lands in .git/config or argv; the pin workflows' temp dir is removed
by an EXIT trap, also on failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:07:34 +02:00

407 lines
20 KiB
Python

"""Runnable check for CI-02 - what a green from `checks / unit` is a claim about.
`python3 test_ci_gate.py`
test_bytecode.py grades the thing that grades the add-on. This file grades the
thing that decides whether anything was graded at all.
THE DEFECT, and the correction the measurement forced. CI-02 was filed saying
that a CONFLICTED pull request loses its merge ref, so that
git fetch -q --depth 1 origin "$GITHUB_REF" \
|| git fetch -q --depth 1 origin "$GITHUB_SHA"
falls through to the second half and quietly grades the bare branch head. PR #14
did report `checks / unit -> success` while `mergeable: false`, so the symptom
was real. The mechanism was not. Measured on a deliberately conflicted throwaway
pull request (#29, runs 558 and 559 - one question per STEP, because this
Gitea's Actions log endpoints 404 and a step's conclusion is the only channel
out of a job):
GITHUB_REF == refs/pull/<n>/head P01 passed
GITHUB_REF != refs/pull/<n>/merge P02 failed
GITHUB_SHA == the pull request head sha P03 passed
fetching GITHUB_REF succeeds P04 passed <- even conflicted
...and it lands on the bare head P05 passed
refs/pull/<n>/merge does not exist P06 failed
`git ls-remote origin 'refs/pull/*'` on this server advertises only `/head`, for
open and closed pull requests alike. THERE ARE NO MERGE REFS HERE. So the first
half of the `||` always succeeded, the fallback never fired once, and both halves
named the same commit anyway. The gate has never graded merged content on any
pull request in this repository's history. Green has meant "this branch passed
on its own" - which, on a base branch that has moved underneath it, is not the
content that lands. `block_on_outdated_branch` is off on release/1.0, so the
base usually has moved.
WHAT THIS FILE PINS.
Part 1 The context string branch protection requires is assembled from two
tokens in checks.yml. Renaming either makes every open and future
pull request unmergeable forever, silently, because Gitea waits on a
context it will now never receive. This part is the tripwire on that.
Part 2 The three absences in checks.yml that are load-bearing, each graded
on the command lines rather than on the prose around them.
Part 3 THE DEFECT, REPRODUCED. The old one-line checkout is run verbatim
against a real conflicted pull request built here, and is shown to
exit 0 having checked out content that cannot land.
Part 4 THE FIX, EXECUTED. The checkout and merge steps are EXTRACTED FROM
checks.yml AND RUN - the shipped commands, not a paraphrase - against
a pull request that merges. The tree they leave behind is the merge,
and is shown to differ from the head the old line would have graded.
Part 5 The same two steps against a pull request that does NOT merge: the
merge step exits non-zero, says nothing was tested, and never prints
a GRADED line.
Part 6 The gate announces which content it graded, on both paths, so that a
reader can tell merged content from a bare ref (CI-02 AC2).
⚠️ THIS FILE RUNS THE WORKFLOW'S OWN SHELL. That is the point: a check that
read those commands as a string and matched them against an expectation would
be exactly the failure this repository keeps finding - a reading that never
observed its subject. The extractor below is deliberately brittle about finding
the step it wants: if checks.yml is restructured so that the step cannot be
found, this file goes RED rather than quietly grading nothing.
⚠️ NEEDS `git`, which is why .gitea/ci/Dockerfile installs it. No network: the
fixtures are local repositories built in a temp directory; the workflow's
token header (TOOL-9) is ignored for a local path, which is what lets the
shipped lines be used verbatim.
⚠️ This file writes only under a temporary directory. It must stay that way:
CI mounts the checkout READ-ONLY.
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or use
`tools/mutation_run.sh`, which does. See MUTATION.md. (There is no module here
to mutate; the subject is checks.yml. Break a line of it and re-run this file
expecting red - if it stays green, it was never watching.)
"""
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
HERE = Path(__file__).resolve().parent
WF = HERE.parent / ".gitea" / "workflows" / "checks.yml"
fails = []
def check(name, cond):
if cond:
print(f" ok {name}")
else:
print(f" FAIL {name}")
fails.append(name)
# ---------------------------------------------------------------------------
# Reading checks.yml. There is no yaml module in the image the checks run in
# (alpine py3-aiohttp py3-paho-mqtt, and adding a parser to grade five lines
# would be a worse trade than a small parser that fails loudly), so the two
# things this file needs are pulled out by hand:
#
# * the workflow name and the job keys, for part 1
# * the `run:` block of a named step, verbatim, for parts 3 to 6
#
# COMMAND LINES ONLY WHERE IT MATTERS. test_bytecode.py's part 6 learned this
# the hard way: an `in y` search over a whole workflow passes on the PROSE that
# explains a flag, and stayed green after the flag itself was deleted. Every
# absence checked in part 2 is checked against the file with its comment lines
# stripped.
# ---------------------------------------------------------------------------
if not WF.is_file():
print(f"cannot find the workflow at {WF} - nothing to grade")
raise SystemExit(1)
YAML = WF.read_text()
CODE = "\n".join(l for l in YAML.splitlines() if not l.lstrip().startswith("#"))
def workflow_name():
for line in YAML.splitlines():
if line.startswith("name:"):
return line.split(":", 1)[1].strip()
return None
def job_keys():
out, seen = [], False
for line in YAML.splitlines():
if line.startswith("jobs:"):
seen = True
continue
if seen:
if line and not line[0].isspace():
break
if line.startswith(" ") and not line.startswith(" ") and line.rstrip().endswith(":"):
out.append(line.strip()[:-1])
return out
def step_script(name_prefix):
"""Return the `run: |` body of the step whose name starts with name_prefix."""
lines = YAML.splitlines()
i = 0
while i < len(lines):
if lines[i].strip().startswith("- name:") and name_prefix in lines[i]:
break
i += 1
else:
return None
if i >= len(lines):
return None
while i < len(lines) and lines[i].strip() != "run: |":
# stop at the next step so a missing block cannot borrow the next one's
if i > 0 and lines[i].strip().startswith("- name:") and name_prefix not in lines[i]:
return None
i += 1
if i >= len(lines):
return None
body, i = [], i + 1
while i < len(lines):
l = lines[i]
if l.strip() == "":
body.append("")
elif l.startswith(" " * 10):
body.append(l[10:])
else:
break
i += 1
return "\n".join(body).strip("\n")
# ---------------------------------------------------------------------------
# The fixture: a real bare repository with a base branch and a refs/pull/N/head,
# built to order. `conflict=True` makes the pull request touch the same lines
# the base moved, which is how PR #29 was made unmergeable on the server.
#
# The shapes matter. In BOTH cases the pull request adds `from_pr.txt` and the
# base adds `from_base.txt` AFTER the branch point, so the branch head is never
# the content that lands even when it merges perfectly - that difference is what
# part 4 measures, and it is the ordinary case, not the pathological one.
# ---------------------------------------------------------------------------
GIT = ["git", "-c", "user.email=fixture@localhost", "-c", "user.name=fixture",
"-c", "init.defaultBranch=release/1.0", "-c", "commit.gpgsign=false"]
PRNUM = "7"
BASEREF = "release/1.0"
def git(cwd, *args, check_rc=True):
r = subprocess.run(GIT + list(args), cwd=cwd, capture_output=True, text=True)
if check_rc and r.returncode != 0:
raise RuntimeError(f"git {' '.join(args)} failed in {cwd}:\n{r.stderr}")
return r.stdout.strip()
def build_origin(stack, conflict):
"""A bare repo carrying refs/heads/release/1.0 and refs/pull/7/head."""
root = Path(tempfile.mkdtemp(prefix="test_ci_gate."))
stack.append(str(root))
work, bare = root / "work", root / "origin.git"
work.mkdir()
git(work, "init", "-q", ".")
(work / "shared.txt").write_text("one\ntwo\nthree\n")
git(work, "add", "-A")
git(work, "commit", "-qm", "branch point")
fork = git(work, "rev-parse", "HEAD")
# the base moves on
(work / "from_base.txt").write_text("only the base has this\n")
if conflict:
(work / "shared.txt").write_text("one\nTWO FROM BASE\nthree\n")
git(work, "add", "-A")
git(work, "commit", "-qm", "the base branch moves on")
base_sha = git(work, "rev-parse", "HEAD")
# the pull request branches from the fork point
git(work, "checkout", "-q", "-b", "pr", fork)
(work / "from_pr.txt").write_text("only the pull request has this\n")
if conflict:
(work / "shared.txt").write_text("one\nTWO FROM THE PULL REQUEST\nthree\n")
git(work, "add", "-A")
git(work, "commit", "-qm", "the pull request")
pr_sha = git(work, "rev-parse", "HEAD")
git(root, "init", "-q", "--bare", str(bare))
git(work, "remote", "add", "origin", str(bare))
git(work, "push", "-q", "origin", f"{base_sha}:refs/heads/{BASEREF}")
git(work, "push", "-q", "origin", f"{pr_sha}:refs/pull/{PRNUM}/head")
return str(bare), base_sha, pr_sha, str(root)
def run_script(script, ws, origin, pr_sha, extra=None):
env = dict(os.environ)
env.pop("PYTHONDONTWRITEBYTECODE", None)
env.update(
WS=ws, PRNUM=PRNUM, BASEREF=BASEREF, URL=origin, TOKEN="",
GITHUB_REF=f"refs/pull/{PRNUM}/head", GITHUB_SHA=pr_sha,
GIT_TERMINAL_PROMPT="0",
)
if extra:
env.update(extra)
return subprocess.run(["sh", "-c", script], env=env, capture_output=True, text=True)
tmpdirs = []
print("the gate's own honesty (CI-02)")
# ---------------------------------------------------------------------------
print("\n1. the required context string, from the two tokens that build it")
# ---------------------------------------------------------------------------
# ⚠️ READ BEFORE RENAMING ANYTHING IN checks.yml. Branch protection on
# release/1.0 has `enable_status_check: true` and
# `status_check_contexts: ['checks / unit (pull_request)']`. Gitea assembles
# that string from `name:` at the top of the workflow and the job KEY under
# `jobs:`. Change either and Gitea starts posting a context nobody is waiting
# for, while the one branch protection requires never arrives - so it stays
# `pending` forever, on every open pull request and every future one, and
# admin override is blocked. There is no error message anywhere in that
# sequence. This check is the only thing between a tidy-up and that.
REQUIRED = "checks / unit (pull_request)"
name, jobs = workflow_name(), job_keys()
check("the workflow still has a name", name is not None)
check("the workflow declares exactly one job", len(jobs) == 1)
built = f"{name} / {jobs[0]} (pull_request)" if name and len(jobs) == 1 else "<unparseable>"
check(f"the context it builds is exactly {REQUIRED!r}", built == REQUIRED)
if built != REQUIRED:
print(f" it now builds {built!r} - branch protection will wait forever for {REQUIRED!r}")
check("the workflow still triggers on pull_request", "pull_request:" in CODE)
# ---------------------------------------------------------------------------
print("\n2. the three absences that are load-bearing")
# ---------------------------------------------------------------------------
# `paths:` is CI-01's: a workflow that learns to sit a pull request out leaves
# the required context pending forever, which is unmergeable forever.
check("no paths: filter", not any(l.strip().startswith("paths:") or l.strip().startswith("paths-ignore:")
for l in CODE.splitlines()))
# `concurrency:` is CI-02's, and it was MEASURED rather than reasoned about, on
# throwaway PR #29 with one run holding the single runner slot and two more
# pushes behind it:
# no block at all -> runs 561 and 562 both queued, both executed
# group + cancel-in-progress: false
# -> run 564 sat `Blocked by required conditions`
# and was cancelled at 0s the moment 565 existed
# A shared group cancels a run that has not started regardless of that flag, and
# a cancelled run posts `failure` on the required context ("Canceled after 0s"),
# which branch protection cannot tell from a real test failure. Runs 531 and 533
# on PR #14 died exactly this way, each at the same second the next run was
# created. Having no group is the only setting measured to leave a queued run
# alive.
check("no concurrency: group", not any(l.startswith("concurrency:") for l in CODE.splitlines()))
check("no cancel-in-progress anywhere in the commands", "cancel-in-progress" not in CODE)
# The fallback itself. Graded on the command lines: the prose above the step
# quotes the old line on purpose, and an `in YAML` search would match that.
check("the || $GITHUB_SHA fallback is gone from the commands",
not any("||" in l and "GITHUB_SHA" in l for l in CODE.splitlines()))
check("the checkout is no longer shallow (a merge needs the merge base)",
"--depth 1" not in CODE)
# ---------------------------------------------------------------------------
print("\n3. the defect, reproduced: the old line grades a conflicted head, happily")
# ---------------------------------------------------------------------------
# NON-VACUITY FOR EVERYTHING BELOW. If this part were already red - if the old
# line refused a conflicted pull request on its own - then parts 4 and 5 would
# be measuring nothing, and the ticket would have no subject.
origin, base_sha, pr_sha, _root = build_origin(tmpdirs, conflict=True)
ws = tempfile.mkdtemp(prefix="test_ci_gate.ws.")
tmpdirs.append(ws)
OLD = '''
set -eu
rm -rf "$WS" && mkdir -p "$WS/goodwe-addon" && cd "$WS/goodwe-addon"
git init -q .
git remote add origin "$(echo "$URL" | sed "s#://#://x-access-token:${TOKEN}@#")"
git fetch -q --depth 1 origin "$GITHUB_REF" || git fetch -q --depth 1 origin "$GITHUB_SHA"
git checkout -qf FETCH_HEAD
echo "checked out $(git log --oneline -1)"
'''
old = run_script(OLD, ws, origin, pr_sha)
check("the pull request genuinely does not merge (the fixture is not lying)",
subprocess.run(GIT + ["merge-tree", "--write-tree", base_sha, pr_sha],
cwd=origin, capture_output=True, text=True).returncode != 0)
check("...and the old checkout exits 0 on it anyway", old.returncode == 0)
tree = Path(ws) / "goodwe-addon"
check("...having checked out the bare head", (tree / "from_pr.txt").is_file())
check("...which is missing what the base branch added", not (tree / "from_base.txt").is_file())
check("...and it says nothing about which of the two refs it used",
"GRADED" not in old.stdout and "REFUSED" not in old.stdout)
if old.returncode != 0:
print(old.stdout[-800:], old.stderr[-800:])
# ---------------------------------------------------------------------------
print("\n4. the shipped steps, executed, on a pull request that MERGES")
# ---------------------------------------------------------------------------
CO = step_script("check out the base branch and the pull request head")
MERGE = step_script("grade the merge, not the branch")
check("the checkout step can be found in checks.yml", CO is not None)
check("the merge/refuse step can be found in checks.yml", MERGE is not None)
if CO is None or MERGE is None:
print(" checks.yml has been restructured; this file grades nothing until it is updated")
print()
print(f"{len(fails)} FAILED")
raise SystemExit(1)
origin2, base2, pr2, _r2 = build_origin(tmpdirs, conflict=False)
ws2 = tempfile.mkdtemp(prefix="test_ci_gate.ws2.")
tmpdirs.append(ws2)
co = run_script(CO, ws2, origin2, pr2)
mg = run_script(MERGE, ws2, origin2, pr2)
check("the checkout step succeeds", co.returncode == 0)
check("the merge step succeeds on a mergeable pull request", mg.returncode == 0)
if co.returncode or mg.returncode:
print(co.stdout[-600:], co.stderr[-600:], mg.stdout[-600:], mg.stderr[-600:])
t2 = Path(ws2) / "goodwe-addon"
check("what is left on disk carries the pull request's file", (t2 / "from_pr.txt").is_file())
check("...AND the file the base branch added after the branch point", (t2 / "from_base.txt").is_file())
# That second one is the whole difference. The old line graded a tree WITHOUT
# from_base.txt - green about content that is not what lands - and nothing in
# the run said so. This is the ordinary case: no conflict anywhere in it.
check("the graded tree is exactly the tree of a real merge",
git(t2, "rev-parse", "HEAD^{tree}") ==
subprocess.run(GIT + ["merge-tree", "--write-tree", base2, pr2],
cwd=origin2, capture_output=True, text=True).stdout.strip())
# ---------------------------------------------------------------------------
print("\n5. the same two steps on a pull request that does NOT merge")
# ---------------------------------------------------------------------------
ws3 = tempfile.mkdtemp(prefix="test_ci_gate.ws3.")
tmpdirs.append(ws3)
co3 = run_script(CO, ws3, origin, pr_sha)
mg3 = run_script(MERGE, ws3, origin, pr_sha)
check("the checkout step still succeeds - a conflict does not stop a fetch",
co3.returncode == 0)
# Stated as its own check because it is why the defect was invisible: nothing
# about a conflicted pull request makes any git fetch fail on this server.
check("the merge step REFUSES (non-zero)", mg3.returncode != 0)
check("...and says nothing was tested, not that a test failed",
"REFUSED" in mg3.stdout and "NOTHING HAS BEEN TESTED" in mg3.stdout)
check("...and never claims to have graded anything", "GRADED:" not in mg3.stdout)
check("...and leaves no half-merged tree behind for a later step to grade",
not (Path(ws3) / "goodwe-addon" / "from_pr.txt").is_file()
or git(Path(ws3) / "goodwe-addon", "rev-parse", "HEAD") == base_sha)
# ---------------------------------------------------------------------------
print("\n6. the run says which content it graded (CI-02 AC2)")
# ---------------------------------------------------------------------------
check("the mergeable run announces the merge it graded", "GRADED:" in mg.stdout)
check("...naming the base branch it merged into", BASEREF in mg.stdout)
check("...and the tree, which is the only unambiguous name for content",
"tree" in mg.stdout and git(t2, "rev-parse", "HEAD^{tree}")[:12] in mg.stdout)
# The no-pull-request path (workflow_dispatch) keeps a fallback, because a
# branch really can vanish between the dispatch and the fetch. It has to say so.
disp = run_script(CO, tempfile.mkdtemp(prefix="test_ci_gate.ws4."), origin2, pr2,
extra={"PRNUM": "", "GITHUB_REF": f"refs/heads/{BASEREF}"})
check("the no-pull-request path still checks something out", disp.returncode == 0)
check("...and announces that it is NOT merged content",
"GRADED:" in disp.stdout and "NOT merged content" in disp.stdout)
if disp.returncode != 0:
print(disp.stdout[-600:], disp.stderr[-600:])
for d in tmpdirs:
shutil.rmtree(d, ignore_errors=True)
print()
if fails:
print(f"{len(fails)} FAILED: " + "; ".join(fails))
sys.exit(1)
print("all checks passed")