molecule-core/tests/test_lint_mask_pr_atomicity.py
core-devops 75af96586d
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
feat(ci)(hard-gate): lint-mask-pr-atomicity (Tier 2d)
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
2026-05-11 23:06:18 -07:00

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