Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 5s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 7s
CI / Detect changes (pull_request) Successful in 14s
E2E API Smoke Test / detect-changes (pull_request) Successful in 14s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 12s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 18s
security-review / approved (pull_request) Failing after 14s
qa-review / approved (pull_request) Failing after 15s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 23s
sop-tier-check / tier-check (pull_request) Successful in 14s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 20s
gate-check-v3 / gate-check (pull_request) Successful in 22s
CI / Platform (Go) (pull_request) Successful in 7s
CI / Canvas (Next.js) (pull_request) Successful in 6s
CI / Python Lint & Test (pull_request) Successful in 4s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 5s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 4s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 4s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m6s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Failing after 1m11s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m33s
audit-force-merge / audit (pull_request) Successful in 5s
Blocks PRs that touch `.gitea/workflows/ci.yml` and modify ONLY ONE of
{continue-on-error, all-required.sentinel.needs} without a
`Paired: #NNN` reference in the PR body or a commit message.
The split-pair class this prevents
----------------------------------
PR#665 (interim continue-on-error: true on platform-build) and PR#668
(sentinel-needs demotion of the same job) were designed as a pair but
merged solo: #665 landed 04:47Z 2026-05-12, #668 still open at 05:07Z
when watchdog #674 fired. ~20 min of main red + a cascade of
false-positives. mc#664 was the surfaced incident.
Implementation
--------------
- `.gitea/scripts/lint_mask_pr_atomicity.py` — reads ci.yml at BASE_SHA
and HEAD_SHA via `git show`, parses both via PyYAML AST (per
feedback_behavior_based_ast_gates — NOT grep). Predicates:
1. any jobs.*.continue-on-error value diff
2. jobs.all-required.needs set diff (order-insensitive)
Both → atomic, OK. Neither → no risk, OK. Exactly one → require
`Paired: #NNN` in PR body or `git log base..head`.
- `.gitea/workflows/lint-mask-pr-atomicity.yml` — pull_request trigger
with paths filter on ci.yml + the lint files. Phase 3
(continue-on-error: true) per RFC #219 §1 ladder; follow-up flip
after 3 clean days on main.
- `tests/test_lint_mask_pr_atomicity.py` — 9 unit tests covering all
prod branches per feedback_branch_count_before_approving: neither
predicate, both atomic, coe-only/no-pair fail, needs-only/no-pair
fail, coe-only/pair-in-body pass, needs-only/pair-in-commit pass,
non-numeric pair rejection, ci.yml unchanged skip, newly-added
ci.yml skip.
Refs: #350
358 lines
12 KiB
Python
358 lines
12 KiB
Python
"""Tests for `.gitea/scripts/lint_mask_pr_atomicity.py` — Tier 2d lint.
|
|
|
|
Structural enforcement of internal#350 Tier 2d: a PR that touches
|
|
`.gitea/workflows/ci.yml` and modifies `continue-on-error` OR the
|
|
`all-required` sentinel's `needs:` block must EITHER:
|
|
|
|
- Touch both atomically in the same PR (preferred), OR
|
|
- Cross-link to the paired PR via `Paired: #NNN` in body OR a commit
|
|
message.
|
|
|
|
The class this lint exists to prevent: PR#665 (interim
|
|
continue-on-error: true on platform-build) + PR#668 (sentinel-exempt)
|
|
were designed-as-a-pair but merged solo — #665 landed at 04:47Z, #668
|
|
still open at 05:07Z when the watchdog fired. ~20 min of main red.
|
|
|
|
Test classes (per `feedback_branch_count_before_approving`, every
|
|
prod branch enumerated):
|
|
|
|
- test_diff_touches_neither_passes — diff is in ci.yml
|
|
but neither continue-on-error nor all-required.needs is touched.
|
|
PR is exempt. Exit 0.
|
|
- test_diff_touches_both_atomically_passes — both touched in
|
|
the same PR. Atomic. Exit 0.
|
|
- test_diff_touches_coe_only_no_pair_fails — continue-on-error
|
|
flipped without sentinel-needs change AND no `Paired: #NNN`
|
|
reference anywhere. Exit 1.
|
|
- test_diff_touches_needs_only_no_pair_fails — sentinel `needs:`
|
|
changed without `continue-on-error` change AND no pair reference.
|
|
Exit 1.
|
|
- test_diff_touches_coe_only_pair_in_body — coe changed, no
|
|
needs change, body has `Paired: #668`. Exit 0.
|
|
- test_diff_touches_needs_only_pair_in_commit — needs changed, no
|
|
coe change, commit message includes `Paired: #665`. Exit 0.
|
|
- test_paired_reference_must_be_numeric — `Paired: #abc` or
|
|
`Paired: NNNN` (missing `#`) doesn't satisfy the rule. Exit 1.
|
|
- test_ci_yml_unchanged_skips — no ci.yml in the
|
|
diff at all (defensive — workflow paths-filter already prevents,
|
|
but the lint should not crash). Exit 0.
|
|
|
|
The lint receives base SHA + head SHA via env (set by the workflow
|
|
from the pull_request payload) and uses `git show` to read both
|
|
sides without a separate clone. Tests stub `subprocess.run` to drive
|
|
the diff content; the actual git is never invoked.
|
|
|
|
Run:
|
|
python3 -m pytest tests/test_lint_mask_pr_atomicity.py -v
|
|
|
|
Dependencies: stdlib + PyYAML (the script reads ci.yml via PyYAML AST
|
|
per `feedback_behavior_based_ast_gates`). No network. No live git.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import textwrap
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
|
|
SCRIPT_PATH = (
|
|
Path(__file__).resolve().parent.parent
|
|
/ ".gitea"
|
|
/ "scripts"
|
|
/ "lint_mask_pr_atomicity.py"
|
|
)
|
|
|
|
|
|
# Minimal ci.yml fixture — only the bits the lint actually parses
|
|
# (a job with continue-on-error + the all-required aggregator).
|
|
CI_YML_BASE = """
|
|
name: CI
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
pull_request:
|
|
branches: [main]
|
|
jobs:
|
|
platform-build:
|
|
runs-on: ubuntu-latest
|
|
continue-on-error: false
|
|
steps:
|
|
- run: echo build
|
|
canvas-build:
|
|
runs-on: ubuntu-latest
|
|
continue-on-error: false
|
|
steps:
|
|
- run: echo build
|
|
all-required:
|
|
runs-on: ubuntu-latest
|
|
needs:
|
|
- platform-build
|
|
- canvas-build
|
|
if: always()
|
|
steps:
|
|
- run: echo agg
|
|
"""
|
|
|
|
# Same as base but with continue-on-error flipped on platform-build.
|
|
CI_YML_COE_FLIPPED = CI_YML_BASE.replace(
|
|
" platform-build:\n runs-on: ubuntu-latest\n continue-on-error: false",
|
|
" platform-build:\n runs-on: ubuntu-latest\n continue-on-error: true",
|
|
)
|
|
|
|
# Same as base but with canvas-build dropped from all-required.needs.
|
|
CI_YML_NEEDS_CHANGED = CI_YML_BASE.replace(
|
|
" needs:\n - platform-build\n - canvas-build",
|
|
" needs:\n - platform-build",
|
|
)
|
|
|
|
# Both changed at once.
|
|
CI_YML_BOTH = CI_YML_COE_FLIPPED.replace(
|
|
" needs:\n - platform-build\n - canvas-build",
|
|
" needs:\n - platform-build",
|
|
)
|
|
|
|
|
|
def _import_lint(monkeypatch):
|
|
"""Import the lint module under a fresh name per test."""
|
|
spec = importlib.util.spec_from_file_location(
|
|
f"lint_mask_pr_atomicity_{os.getpid()}_{id(monkeypatch)}",
|
|
SCRIPT_PATH,
|
|
)
|
|
m = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(m)
|
|
return m
|
|
|
|
|
|
def _stub_git(base_yml: str | None, head_yml: str | None, commits: list[str]):
|
|
"""Build a fake `subprocess.run` that emulates git show + log.
|
|
|
|
base_yml / head_yml: contents the lint sees at base/head SHA.
|
|
Pass `None` to simulate "path didn't exist on that side" (git
|
|
show returns exit code 128 — file-not-in-tree).
|
|
commits: list of commit messages on the PR (head's ancestry up to
|
|
the base merge-base). The lint runs
|
|
`git log --format=%B base..head` to find Paired: refs.
|
|
"""
|
|
|
|
def fake_run(cmd, *args, **kwargs):
|
|
if not isinstance(cmd, list):
|
|
raise AssertionError(f"unexpected non-list cmd: {cmd!r}")
|
|
# `git show <sha>:<path>`
|
|
if cmd[:2] == ["git", "show"] and len(cmd) >= 3 and ":" in cmd[2]:
|
|
sha, path = cmd[2].split(":", 1)
|
|
if "base" in sha or "BASE" in sha:
|
|
content = base_yml
|
|
else:
|
|
content = head_yml
|
|
if content is None:
|
|
return subprocess.CompletedProcess(
|
|
cmd, returncode=128, stdout="", stderr="fatal: path not in tree"
|
|
)
|
|
return subprocess.CompletedProcess(
|
|
cmd, returncode=0, stdout=content, stderr=""
|
|
)
|
|
# `git log --format=%B base..head -- .`
|
|
if cmd[:2] == ["git", "log"]:
|
|
body = "\n\n--commit-boundary--\n\n".join(commits)
|
|
return subprocess.CompletedProcess(
|
|
cmd, returncode=0, stdout=body, stderr=""
|
|
)
|
|
# `git diff --name-only base..head`
|
|
if cmd[:2] == ["git", "diff"]:
|
|
# If either side had ci.yml, it's in the diff; else not.
|
|
paths = []
|
|
if (base_yml or "") != (head_yml or ""):
|
|
paths.append(".gitea/workflows/ci.yml")
|
|
return subprocess.CompletedProcess(
|
|
cmd, returncode=0, stdout="\n".join(paths) + "\n", stderr=""
|
|
)
|
|
raise AssertionError(f"unexpected git invocation: {cmd!r}")
|
|
|
|
return fake_run
|
|
|
|
|
|
@pytest.fixture()
|
|
def env(monkeypatch):
|
|
monkeypatch.setenv("BASE_SHA", "base-sha-1")
|
|
monkeypatch.setenv("HEAD_SHA", "head-sha-1")
|
|
monkeypatch.setenv("PR_BODY", "")
|
|
monkeypatch.setenv("CI_WORKFLOW_PATH", ".gitea/workflows/ci.yml")
|
|
monkeypatch.setenv("SENTINEL_JOB_KEY", "all-required")
|
|
return monkeypatch
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Diff in ci.yml but neither rule predicate triggered → pass
|
|
# ---------------------------------------------------------------------------
|
|
def test_diff_touches_neither_passes(env, monkeypatch, capsys):
|
|
# Add a comment-only change (no coe flip, no needs change).
|
|
base = CI_YML_BASE
|
|
head = "# a harmless comment\n" + CI_YML_BASE
|
|
monkeypatch.setattr(
|
|
subprocess, "run", _stub_git(base, head, ["chore: comment"])
|
|
)
|
|
m = _import_lint(monkeypatch)
|
|
rc = m.run()
|
|
assert rc == 0
|
|
out = capsys.readouterr().out
|
|
assert "no atomicity risk" in out.lower() or "ok" in out.lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Diff touches BOTH coe and sentinel.needs in the same PR → atomic, pass
|
|
# ---------------------------------------------------------------------------
|
|
def test_diff_touches_both_atomically_passes(env, monkeypatch, capsys):
|
|
monkeypatch.setattr(
|
|
subprocess,
|
|
"run",
|
|
_stub_git(CI_YML_BASE, CI_YML_BOTH, ["fix(ci): atomic flip"]),
|
|
)
|
|
m = _import_lint(monkeypatch)
|
|
rc = m.run()
|
|
assert rc == 0
|
|
out = capsys.readouterr().out
|
|
assert "atomic" in out.lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Diff touches ONLY continue-on-error, no pair reference → fail
|
|
# ---------------------------------------------------------------------------
|
|
def test_diff_touches_coe_only_no_pair_fails(env, monkeypatch, capsys):
|
|
monkeypatch.setattr(
|
|
subprocess,
|
|
"run",
|
|
_stub_git(
|
|
CI_YML_BASE,
|
|
CI_YML_COE_FLIPPED,
|
|
["fix(ci): flip coe on platform-build"],
|
|
),
|
|
)
|
|
m = _import_lint(monkeypatch)
|
|
rc = m.run()
|
|
assert rc == 1
|
|
out = capsys.readouterr().out
|
|
assert "paired" in out.lower() or "atomicity" in out.lower()
|
|
# Actionable failure: must name what is missing.
|
|
assert "continue-on-error" in out.lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Diff touches ONLY sentinel.needs, no pair reference → fail
|
|
# ---------------------------------------------------------------------------
|
|
def test_diff_touches_needs_only_no_pair_fails(env, monkeypatch, capsys):
|
|
monkeypatch.setattr(
|
|
subprocess,
|
|
"run",
|
|
_stub_git(
|
|
CI_YML_BASE,
|
|
CI_YML_NEEDS_CHANGED,
|
|
["fix(ci): drop canvas-build from sentinel"],
|
|
),
|
|
)
|
|
m = _import_lint(monkeypatch)
|
|
rc = m.run()
|
|
assert rc == 1
|
|
out = capsys.readouterr().out
|
|
assert "paired" in out.lower() or "atomicity" in out.lower()
|
|
assert "needs" in out.lower() or "sentinel" in out.lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# COE-only flip with `Paired: #668` in PR body → pass
|
|
# ---------------------------------------------------------------------------
|
|
def test_diff_touches_coe_only_pair_in_body(env, monkeypatch, capsys):
|
|
monkeypatch.setenv("PR_BODY", "Interim coe flip. Paired: #668")
|
|
monkeypatch.setattr(
|
|
subprocess,
|
|
"run",
|
|
_stub_git(
|
|
CI_YML_BASE,
|
|
CI_YML_COE_FLIPPED,
|
|
["fix(ci): flip coe on platform-build"],
|
|
),
|
|
)
|
|
m = _import_lint(monkeypatch)
|
|
rc = m.run()
|
|
assert rc == 0
|
|
out = capsys.readouterr().out
|
|
assert "paired" in out.lower()
|
|
assert "668" in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Needs-only flip with `Paired: #665` in a commit message → pass
|
|
# ---------------------------------------------------------------------------
|
|
def test_diff_touches_needs_only_pair_in_commit(env, monkeypatch, capsys):
|
|
monkeypatch.setattr(
|
|
subprocess,
|
|
"run",
|
|
_stub_git(
|
|
CI_YML_BASE,
|
|
CI_YML_NEEDS_CHANGED,
|
|
[
|
|
"fix(ci): drop canvas-build from sentinel\n\nPaired: #665",
|
|
],
|
|
),
|
|
)
|
|
m = _import_lint(monkeypatch)
|
|
rc = m.run()
|
|
assert rc == 0
|
|
out = capsys.readouterr().out
|
|
assert "paired" in out.lower()
|
|
assert "665" in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# `Paired: #abc` is not a valid issue/PR ref — fail
|
|
# ---------------------------------------------------------------------------
|
|
def test_paired_reference_must_be_numeric(env, monkeypatch, capsys):
|
|
monkeypatch.setenv("PR_BODY", "Paired: #abc")
|
|
monkeypatch.setattr(
|
|
subprocess,
|
|
"run",
|
|
_stub_git(
|
|
CI_YML_BASE,
|
|
CI_YML_COE_FLIPPED,
|
|
["fix(ci): flip coe"],
|
|
),
|
|
)
|
|
m = _import_lint(monkeypatch)
|
|
rc = m.run()
|
|
assert rc == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Defensive: ci.yml not in diff at all → skip cleanly
|
|
# ---------------------------------------------------------------------------
|
|
def test_ci_yml_unchanged_skips(env, monkeypatch, capsys):
|
|
monkeypatch.setattr(
|
|
subprocess, "run", _stub_git(CI_YML_BASE, CI_YML_BASE, ["chore: noop"])
|
|
)
|
|
m = _import_lint(monkeypatch)
|
|
rc = m.run()
|
|
assert rc == 0
|
|
out = capsys.readouterr().out
|
|
assert "ci.yml" in out.lower() or "not in" in out.lower() or "skip" in out.lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cross-cutting: file ADDED on head side (no base) — coe inferred as
|
|
# "newly added with coe=true". Should NOT trigger the lint (it's a new
|
|
# file, not a flip — Tier 2e covers tracking-issue for new coe=true).
|
|
# ---------------------------------------------------------------------------
|
|
def test_ci_yml_newly_added_passes(env, monkeypatch, capsys):
|
|
monkeypatch.setattr(
|
|
subprocess,
|
|
"run",
|
|
_stub_git(None, CI_YML_COE_FLIPPED, ["feat(ci): add ci.yml"]),
|
|
)
|
|
m = _import_lint(monkeypatch)
|
|
rc = m.run()
|
|
assert rc == 0
|