Merge pull request 'feat(ci): status-reaper compensate Gitea 1.22.6 hardcoded-(push)-suffix on schedule-triggered workflow failures' (#589) from infra/option-b-status-reaper into main
Some checks failed
Block internal-flavored paths / Block forbidden paths (push) Successful in 16s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 19s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 18s
CI / Detect changes (push) Successful in 1m20s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m18s
E2E API Smoke Test / detect-changes (push) Successful in 1m21s
Handlers Postgres Integration / detect-changes (push) Successful in 1m20s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 1m24s
CI / Platform (Go) (push) Successful in 8s
CI / Canvas (Next.js) (push) Successful in 8s
CI / Shellcheck (E2E scripts) (push) Successful in 5s
CI / Python Lint & Test (push) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 10s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 11s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 9s
CI / Canvas Deploy Reminder (push) Has been skipped
CI / all-required (push) Successful in 13s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 26s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Failing after 25s
status-reaper / reap (push) Successful in 1m31s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Failing after 4m41s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Failing after 4m42s
Some checks failed
Block internal-flavored paths / Block forbidden paths (push) Successful in 16s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (push) Successful in 19s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 18s
CI / Detect changes (push) Successful in 1m20s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 1m18s
E2E API Smoke Test / detect-changes (push) Successful in 1m21s
Handlers Postgres Integration / detect-changes (push) Successful in 1m20s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 1m24s
CI / Platform (Go) (push) Successful in 8s
CI / Canvas (Next.js) (push) Successful in 8s
CI / Shellcheck (E2E scripts) (push) Successful in 5s
CI / Python Lint & Test (push) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 10s
Handlers Postgres Integration / Handlers Postgres Integration (push) Successful in 7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 11s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Successful in 9s
CI / Canvas Deploy Reminder (push) Has been skipped
CI / all-required (push) Successful in 13s
Sweep stale e2e-* orgs (staging) / Sweep e2e orgs (push) Successful in 26s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Failing after 25s
status-reaper / reap (push) Successful in 1m31s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Failing after 4m41s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Failing after 4m42s
This commit is contained in:
commit
6e6abdd940
514
.gitea/scripts/status-reaper.py
Normal file
514
.gitea/scripts/status-reaper.py
Normal file
@ -0,0 +1,514 @@
|
||||
#!/usr/bin/env python3
|
||||
"""status-reaper — Option B compensating-status POST for Gitea 1.22.6's
|
||||
hardcoded `(push)` suffix on default-branch commit statuses.
|
||||
|
||||
Tracking: this PR (workflow + script + tests + audit issue). Sibling
|
||||
bots: internal#327 (publish-runtime-bot), internal#328 (mc-drift-bot).
|
||||
Upstream RFC: internal#80. Persona provisioned by sub-agent aefaac1b
|
||||
(2026-05-11 21:39Z; Gitea uid 94, scope=write:repository).
|
||||
|
||||
What this script does, per `.gitea/workflows/status-reaper.yml` invocation:
|
||||
|
||||
1. Walk `.gitea/workflows/*.yml`. For each file, build the workflow_id
|
||||
using this resolution (per hongming-pc 22:08Z review):
|
||||
- If YAML has top-level `name:` → use that.
|
||||
- Else → use filename stem (basename minus `.yml`).
|
||||
Fail-LOUD on:
|
||||
- Two workflows resolving to the SAME identifier (collision).
|
||||
- Any identifier containing `/` (it would break context parsing
|
||||
downstream — Gitea uses ` / ` as the workflow/job separator).
|
||||
Classify each by whether `on:` contains a `push:` trigger.
|
||||
|
||||
2. GET combined status for HEAD of WATCH_BRANCH.
|
||||
|
||||
3. For each per-context status entry where:
|
||||
state == "failure" AND context.endswith(" (push)")
|
||||
Parse context as `<workflow_name> / <job_name> (push)`. Look up
|
||||
workflow_name in the trigger map:
|
||||
- missing → log ::notice:: and skip (conservative).
|
||||
- has_push_trigger=True → preserve (would mask real signal).
|
||||
- has_push_trigger=False → POST a compensating
|
||||
`state=success` status to /statuses/{sha} with the same
|
||||
context (Gitea de-dups by context) and a description that
|
||||
documents the workaround + this script's path.
|
||||
|
||||
4. Exit 0. Re-running is idempotent — Gitea's commit-status table
|
||||
stores the LATEST state-per-context, so the success POST sticks
|
||||
even if another tick happens before the runner finishes.
|
||||
|
||||
What it does NOT do:
|
||||
- Touch any context NOT ending in ` (push)`. The required-checks on
|
||||
main (verified 2026-05-11) all have ` (pull_request)` suffixes;
|
||||
they CANNOT be reached by this code path.
|
||||
- Compensate `error`/`pending` states. Only `failure` — the only one
|
||||
Gitea emits for the hardcoded-suffix bug.
|
||||
- Write to non-default branches. WATCH_BRANCH is sourced from
|
||||
`github.event.repository.default_branch` in the workflow.
|
||||
- Mutate workflows or runs. The Actions UI still shows the
|
||||
underlying schedule-triggered run as failed; this script edits
|
||||
the commit-status surface only.
|
||||
|
||||
Halt conditions (script-level — orchestrator-level halts are in the
|
||||
workflow comments):
|
||||
- PyYAML missing → fail-loud at import (no fallback parse).
|
||||
- Workflow `name:` collision → exit 1 with ::error:: message.
|
||||
- Workflow `name:` containing `/` → exit 1 with ::error:: message.
|
||||
- Ambiguous `on:` shape (e.g. neither str/list/dict) → treat as
|
||||
"has_push_trigger=True" and log ::notice:: (preserve, never
|
||||
compensate the unknown).
|
||||
- api() non-2xx → raise ApiError, fail the workflow run loudly so
|
||||
a subsequent tick retries (per
|
||||
`feedback_api_helper_must_raise_not_return_dict`).
|
||||
|
||||
Local dry-run (no network):
|
||||
GITEA_TOKEN=... GITEA_HOST=git.moleculesai.app REPO=owner/repo \\
|
||||
WATCH_BRANCH=main WORKFLOWS_DIR=.gitea/workflows \\
|
||||
python3 .gitea/scripts/status-reaper.py --dry-run
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml # PyYAML 6.0.2 — installed by the workflow before this runs.
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Environment
|
||||
# --------------------------------------------------------------------------
|
||||
def _env(key: str, *, default: str = "") -> str:
|
||||
"""Read an env var with a default. Module-import-safe — tests can
|
||||
import this script without setting the full env contract."""
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
GITEA_TOKEN = _env("GITEA_TOKEN")
|
||||
GITEA_HOST = _env("GITEA_HOST")
|
||||
REPO = _env("REPO")
|
||||
WATCH_BRANCH = _env("WATCH_BRANCH", default="main")
|
||||
WORKFLOWS_DIR = _env("WORKFLOWS_DIR", default=".gitea/workflows")
|
||||
|
||||
OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "")
|
||||
API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else ""
|
||||
|
||||
# Compensating-status description prefix. Used as the marker so a human
|
||||
# auditing commit statuses can tell at a glance that the green was
|
||||
# synthetic, not a real CI pass. Kept stable; downstream tooling
|
||||
# (e.g. main-red-watchdog visual diff) MAY key on it.
|
||||
COMPENSATION_DESCRIPTION = (
|
||||
"Compensated by status-reaper (workflow has no push: trigger; "
|
||||
"Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)"
|
||||
)
|
||||
|
||||
# Context suffix the reaper acts on. Gitea hardcodes this for ALL
|
||||
# default-branch workflow runs.
|
||||
PUSH_SUFFIX = " (push)"
|
||||
|
||||
|
||||
def _require_runtime_env() -> None:
|
||||
"""Enforce env contract — called from `main()` only.
|
||||
|
||||
Tests import individual functions without setting the full env
|
||||
contract. Mirrors `main-red-watchdog.py`/`ci-required-drift.py`.
|
||||
"""
|
||||
for key in ("GITEA_TOKEN", "GITEA_HOST", "REPO", "WATCH_BRANCH", "WORKFLOWS_DIR"):
|
||||
if not os.environ.get(key):
|
||||
sys.stderr.write(f"::error::missing required env var: {key}\n")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Tiny HTTP helper — raises on non-2xx + on JSON-decode-of-expected-JSON.
|
||||
# --------------------------------------------------------------------------
|
||||
class ApiError(RuntimeError):
|
||||
"""Raised when a Gitea API call cannot be trusted to have succeeded.
|
||||
|
||||
Per `feedback_api_helper_must_raise_not_return_dict`: soft-failure is
|
||||
opt-in via `expect_json=False`, never the default. A pre-fix
|
||||
implementation that returned `{}` on non-2xx would skip the
|
||||
compensating POST on a transient outage AND silently lose the
|
||||
failed-status enumeration, painting main green via omission.
|
||||
"""
|
||||
|
||||
|
||||
def api(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
body: dict | None = None,
|
||||
query: dict[str, str] | None = None,
|
||||
expect_json: bool = True,
|
||||
) -> tuple[int, Any]:
|
||||
"""Tiny HTTP helper around urllib. Same contract as
|
||||
`main-red-watchdog.py` and `ci-required-drift.py` so behaviour
|
||||
is cross-checkable."""
|
||||
url = f"{API}{path}"
|
||||
if query:
|
||||
url = f"{url}?{urllib.parse.urlencode(query)}"
|
||||
data = None
|
||||
headers = {
|
||||
"Authorization": f"token {GITEA_TOKEN}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, method=method, data=data, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
raw = resp.read()
|
||||
status = resp.status
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
status = e.code
|
||||
|
||||
if not (200 <= status < 300):
|
||||
snippet = raw[:500].decode("utf-8", errors="replace") if raw else ""
|
||||
raise ApiError(f"{method} {path} -> HTTP {status}: {snippet}")
|
||||
|
||||
if not raw:
|
||||
return status, None
|
||||
try:
|
||||
return status, json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
if expect_json:
|
||||
raise ApiError(
|
||||
f"{method} {path} -> HTTP {status} but body is not JSON: {e}"
|
||||
) from e
|
||||
return status, {"_raw": raw.decode("utf-8", errors="replace")}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Workflow scan + classification
|
||||
# --------------------------------------------------------------------------
|
||||
def _on_block(doc: dict) -> Any:
|
||||
"""Extract the `on:` block from a parsed YAML doc.
|
||||
|
||||
PyYAML parses bareword `on:` as Python `True` (YAML 1.1 boolean
|
||||
spec — `on/off/yes/no` are booleans). The actual key in the dict
|
||||
is therefore `True`, NOT the string `"on"`. We accept both for
|
||||
forward-compat with YAML 1.2 loaders (which keep it as `"on"`).
|
||||
"""
|
||||
if True in doc:
|
||||
return doc[True]
|
||||
return doc.get("on")
|
||||
|
||||
|
||||
def _has_push_trigger(on_block: Any, workflow_id: str) -> bool:
|
||||
"""Return True if `on:` block declares a `push` trigger.
|
||||
|
||||
Accepts the three common shapes:
|
||||
- str: `on: push` → True only if == "push"
|
||||
- list: `on: [push, pull_request]` → True if "push" in list
|
||||
- dict: `on: { push: {...}, schedule: ... }` → True if "push" key
|
||||
|
||||
Defensive: for anything else (including None/empty), return True
|
||||
so we preserve rather than over-compensate. Logged via ::notice::.
|
||||
"""
|
||||
if isinstance(on_block, str):
|
||||
return on_block == "push"
|
||||
if isinstance(on_block, list):
|
||||
return "push" in on_block
|
||||
if isinstance(on_block, dict):
|
||||
return "push" in on_block
|
||||
# None or unexpected shape — preserve, log.
|
||||
print(
|
||||
f"::notice::ambiguous on: for {workflow_id}; preserving "
|
||||
f"(value={on_block!r}, type={type(on_block).__name__})"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def scan_workflows(workflows_dir: str) -> dict[str, bool]:
|
||||
"""Walk `workflows_dir` and return `{workflow_id: has_push_trigger}`.
|
||||
|
||||
Workflow ID resolution (per hongming-pc 22:08Z review):
|
||||
- Top-level `name:` if present.
|
||||
- Else filename stem (basename minus `.yml`).
|
||||
|
||||
Fail-LOUD on:
|
||||
- Two workflows resolving to the same ID (collision).
|
||||
- Any ID containing `/` (would break ` / `-separated context
|
||||
parsing on the downstream side).
|
||||
|
||||
Returns a dict for O(1) lookup in the per-status loop.
|
||||
"""
|
||||
path = Path(workflows_dir)
|
||||
if not path.is_dir():
|
||||
# Workflow dir missing → no workflows to classify. Empty map is
|
||||
# safe: per-status loop will hit "unknown workflow; skip" for
|
||||
# every entry, which is correct (we cannot tell if a push
|
||||
# trigger exists, so we preserve).
|
||||
print(f"::warning::workflows dir not found: {workflows_dir}")
|
||||
return {}
|
||||
|
||||
out: dict[str, bool] = {}
|
||||
sources: dict[str, str] = {} # workflow_id -> source file (for collision msg)
|
||||
|
||||
for yml in sorted(path.glob("*.yml")):
|
||||
try:
|
||||
with yml.open() as f:
|
||||
doc = yaml.safe_load(f)
|
||||
except yaml.YAMLError as e:
|
||||
# A malformed YAML in the workflows dir is a real defect
|
||||
# (the workflow wouldn't load on Gitea either). Surface it
|
||||
# and keep going — the reaper's job is to compensate the
|
||||
# OTHER workflows even if one is broken.
|
||||
print(f"::warning::yaml parse failed for {yml.name}: {e}; skip")
|
||||
continue
|
||||
if not isinstance(doc, dict):
|
||||
print(f"::warning::workflow {yml.name} not a dict; skip")
|
||||
continue
|
||||
|
||||
# Resolve workflow_id.
|
||||
name_field = doc.get("name")
|
||||
if isinstance(name_field, str) and name_field.strip():
|
||||
workflow_id = name_field.strip()
|
||||
else:
|
||||
workflow_id = yml.stem # basename minus .yml
|
||||
|
||||
# Halt-loud: `/` in workflow_id breaks ` / ` context parsing.
|
||||
if "/" in workflow_id:
|
||||
sys.stderr.write(
|
||||
f"::error::workflow name contains '/' which breaks "
|
||||
f"context parsing: {workflow_id} (file={yml.name})\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Halt-loud: ID collision.
|
||||
if workflow_id in out:
|
||||
sys.stderr.write(
|
||||
f"::error::workflow name collision detected: {workflow_id} "
|
||||
f"(files: {sources[workflow_id]} + {yml.name})\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
on_block = _on_block(doc)
|
||||
out[workflow_id] = _has_push_trigger(on_block, workflow_id)
|
||||
sources[workflow_id] = yml.name
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Gitea reads
|
||||
# --------------------------------------------------------------------------
|
||||
def get_head_sha(branch: str) -> str:
|
||||
"""HEAD SHA of `branch`. Raises ApiError on non-2xx."""
|
||||
_, body = api("GET", f"/repos/{OWNER}/{NAME}/branches/{branch}")
|
||||
if not isinstance(body, dict):
|
||||
raise ApiError(f"branch {branch} response not a JSON object")
|
||||
commit = body.get("commit")
|
||||
if not isinstance(commit, dict):
|
||||
raise ApiError(f"branch {branch} response missing `commit` object")
|
||||
sha = commit.get("id") or commit.get("sha")
|
||||
if not isinstance(sha, str) or len(sha) < 7:
|
||||
raise ApiError(f"branch {branch} response has no usable commit SHA")
|
||||
return sha
|
||||
|
||||
|
||||
def get_combined_status(sha: str) -> dict:
|
||||
"""Combined commit status for `sha`. Gitea returns:
|
||||
{
|
||||
"state": "success" | "failure" | "pending" | "error",
|
||||
"statuses": [
|
||||
{"context": "...", "state": "...", "target_url": "...",
|
||||
"description": "..."},
|
||||
...
|
||||
],
|
||||
...
|
||||
}
|
||||
Raises ApiError on non-2xx.
|
||||
"""
|
||||
_, body = api("GET", f"/repos/{OWNER}/{NAME}/commits/{sha}/status")
|
||||
if not isinstance(body, dict):
|
||||
raise ApiError(f"status for {sha} response not a JSON object")
|
||||
return body
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Context parsing
|
||||
# --------------------------------------------------------------------------
|
||||
def parse_push_context(context: str) -> tuple[str, str] | None:
|
||||
"""Parse `<workflow_name> / <job_name> (push)` into
|
||||
(workflow_name, job_name).
|
||||
|
||||
Returns None if the context doesn't match the shape (caller skips).
|
||||
Strict: requires the trailing ` (push)` and at least one ` / `
|
||||
separator. Anything else is left alone.
|
||||
"""
|
||||
if not context.endswith(PUSH_SUFFIX):
|
||||
return None
|
||||
head = context[: -len(PUSH_SUFFIX)] # strip " (push)"
|
||||
if " / " not in head:
|
||||
# No workflow/job separator — not the bug shape we compensate.
|
||||
return None
|
||||
workflow_name, job_name = head.split(" / ", 1)
|
||||
return workflow_name, job_name
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Compensating POST
|
||||
# --------------------------------------------------------------------------
|
||||
def post_compensating_status(
|
||||
sha: str,
|
||||
context: str,
|
||||
target_url: str | None,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
"""POST a `state=success` to /repos/{o}/{r}/statuses/{sha} with the
|
||||
given context. Gitea de-dups by context (latest write wins).
|
||||
|
||||
Description references this script so the compensation is
|
||||
self-documenting on the commit's status view.
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"context": context,
|
||||
"state": "success",
|
||||
"description": COMPENSATION_DESCRIPTION,
|
||||
}
|
||||
# Echo the original target_url when present so a human auditing
|
||||
# the (now-green) compensated status can still reach the run logs
|
||||
# that produced the original red.
|
||||
if target_url:
|
||||
payload["target_url"] = target_url
|
||||
|
||||
if dry_run:
|
||||
print(
|
||||
f"::notice::[dry-run] would compensate {context!r} on {sha[:10]} "
|
||||
f"with state=success"
|
||||
)
|
||||
return
|
||||
|
||||
api("POST", f"/repos/{OWNER}/{NAME}/statuses/{sha}", body=payload)
|
||||
print(f"::notice::compensated {context!r} on {sha[:10]} (state=success)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Main reap loop
|
||||
# --------------------------------------------------------------------------
|
||||
def reap(
|
||||
workflow_trigger_map: dict[str, bool],
|
||||
combined: dict,
|
||||
sha: str,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, int]:
|
||||
"""Walk `combined.statuses[]` and compensate where appropriate.
|
||||
|
||||
Returns counters for observability:
|
||||
{compensated, preserved_real_push, preserved_unknown,
|
||||
preserved_non_failure, preserved_non_push_suffix,
|
||||
preserved_unparseable}
|
||||
"""
|
||||
counters = {
|
||||
"compensated": 0,
|
||||
"preserved_real_push": 0,
|
||||
"preserved_unknown": 0,
|
||||
"preserved_non_failure": 0,
|
||||
"preserved_non_push_suffix": 0,
|
||||
"preserved_unparseable": 0,
|
||||
}
|
||||
|
||||
statuses = combined.get("statuses") or []
|
||||
for s in statuses:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
context = s.get("context") or ""
|
||||
state = s.get("state") or ""
|
||||
|
||||
# Only `failure` is the bug shape. `error`/`pending`/`success`
|
||||
# left alone — they have other meanings.
|
||||
if state != "failure":
|
||||
counters["preserved_non_failure"] += 1
|
||||
continue
|
||||
|
||||
# Only `(push)`-suffix contexts hit the hardcoded-suffix bug.
|
||||
# Branch-protection required checks (e.g. `Secret scan / Scan
|
||||
# diff (pull_request)`) are NOT reachable from this path.
|
||||
if not context.endswith(PUSH_SUFFIX):
|
||||
counters["preserved_non_push_suffix"] += 1
|
||||
continue
|
||||
|
||||
parsed = parse_push_context(context)
|
||||
if parsed is None:
|
||||
# Has ` (push)` suffix but missing ` / ` separator — not
|
||||
# the bug shape. Preserve.
|
||||
counters["preserved_unparseable"] += 1
|
||||
continue
|
||||
workflow_name, _job_name = parsed
|
||||
|
||||
if workflow_name not in workflow_trigger_map:
|
||||
# Real workflow but renamed/deleted/external — we can't
|
||||
# tell if it has push trigger. Conservative: preserve.
|
||||
print(f"::notice::unknown workflow {workflow_name!r}; skip")
|
||||
counters["preserved_unknown"] += 1
|
||||
continue
|
||||
|
||||
if workflow_trigger_map[workflow_name]:
|
||||
# Real push trigger → real defect signal. Preserve.
|
||||
counters["preserved_real_push"] += 1
|
||||
continue
|
||||
|
||||
# Class-O: schedule/dispatch/etc.-only workflow with a fake
|
||||
# (push) status from Gitea's hardcoded-suffix bug. Compensate.
|
||||
post_compensating_status(
|
||||
sha, context, s.get("target_url"), dry_run=dry_run
|
||||
)
|
||||
counters["compensated"] += 1
|
||||
|
||||
return counters
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Skip the compensating POST; print what would be done.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
_require_runtime_env()
|
||||
|
||||
workflow_trigger_map = scan_workflows(WORKFLOWS_DIR)
|
||||
print(
|
||||
f"::notice::scanned {len(workflow_trigger_map)} workflows; "
|
||||
f"push-triggered={sum(1 for v in workflow_trigger_map.values() if v)}, "
|
||||
f"class-O candidates={sum(1 for v in workflow_trigger_map.values() if not v)}"
|
||||
)
|
||||
|
||||
sha = get_head_sha(WATCH_BRANCH)
|
||||
combined = get_combined_status(sha)
|
||||
|
||||
counters = reap(
|
||||
workflow_trigger_map, combined, sha, dry_run=args.dry_run
|
||||
)
|
||||
|
||||
# Observability: print one JSON line summarising the tick. Loki
|
||||
# ingestion via the runner's stdout (`source="gitea-actions"`).
|
||||
print(
|
||||
"status-reaper summary: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"sha": sha,
|
||||
"branch": WATCH_BRANCH,
|
||||
"dry_run": args.dry_run,
|
||||
**counters,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
114
.gitea/workflows/status-reaper.yml
Normal file
114
.gitea/workflows/status-reaper.yml
Normal file
@ -0,0 +1,114 @@
|
||||
# status-reaper — Option B (compensating-status POST) for Gitea 1.22.6's
|
||||
# hardcoded `(push)` suffix on default-branch commit statuses.
|
||||
#
|
||||
# Tracking: molecule-core#? (this PR), internal#327 (sibling publish-runtime-bot),
|
||||
# internal#328 (sibling mc-drift-bot), internal#80 (upstream RFC). Sister
|
||||
# bots already deployed under the same per-persona-identity contract
|
||||
# (`feedback_per_agent_gitea_identity_default`).
|
||||
#
|
||||
# Root cause:
|
||||
# Gitea 1.22.6 emits commit-status context as
|
||||
# `<workflow_name> / <job_name> (push)`
|
||||
# for ANY workflow run on the default branch's HEAD commit, REGARDLESS
|
||||
# of the trigger event. Schedule- and workflow_dispatch-triggered runs
|
||||
# on `main` therefore appear as `(push)` failures on the latest main
|
||||
# commit, painting main red via a fake-push status. Verified on runs
|
||||
# 14525 + 14526 via Phase 1 evidence (3 sub-agents). No upstream fix
|
||||
# in 1.23-1.26.1 (sibling a6f20db1 research).
|
||||
#
|
||||
# Why a cron-driven reaper, not workflow_run:
|
||||
# Gitea 1.22.6 does NOT support `on: workflow_run` (verified via
|
||||
# modules/actions/workflows.go enumeration; sister a6f20db1). The
|
||||
# only event-shaped option that fires is cron. 5min is chosen to
|
||||
# sit BETWEEN ci-required-drift (`:17` hourly) and main-red-watchdog
|
||||
# (`:05` hourly) so the reaper sweeps red before the watchdog files
|
||||
# a `[main-red]` issue (would-be false-positive).
|
||||
#
|
||||
# What the reaper does each tick:
|
||||
# 1. Parse `.gitea/workflows/*.yml`, classify each by whether `on:`
|
||||
# contains a `push:` trigger (see script for workflow_id resolution
|
||||
# including `name:` collision and `/`-in-name fail-loud lints).
|
||||
# 2. GET combined status for main HEAD.
|
||||
# 3. For each `failure` status whose context ends ` (push)`:
|
||||
# - if workflow has push trigger: PRESERVE (real defect signal).
|
||||
# - if workflow has no push trigger: POST a compensating
|
||||
# `state=success` with the same context and a description that
|
||||
# documents the workaround.
|
||||
#
|
||||
# What it does NOT do:
|
||||
# - Mutate non-`(push)`-suffix statuses (e.g. `(pull_request)` from
|
||||
# branch_protections required-checks — verified safe 2026-05-11).
|
||||
# - Auto-revert. Same reasoning as main-red-watchdog.
|
||||
# - Cancel runs. The runs themselves stay visible in Actions UI; the
|
||||
# fix is at the commit-status surface only.
|
||||
#
|
||||
# Removal path: drop this workflow when Gitea ≥ 1.24 ships with a
|
||||
# real fix for the hardcoded-suffix bug. Audit issue (filed post-merge)
|
||||
# tracks the deletion as a follow-up sweep.
|
||||
|
||||
name: status-reaper
|
||||
|
||||
# IMPORTANT — Gitea 1.22.6 parser quirk per
|
||||
# `feedback_gitea_workflow_dispatch_inputs_unsupported`: do NOT add an
|
||||
# `inputs:` block here. Gitea 1.22.6 rejects the whole workflow as
|
||||
# "unknown on type" when `workflow_dispatch.inputs.X` is present.
|
||||
on:
|
||||
schedule:
|
||||
# Every 5 minutes. Off-zero alignment with sibling cron workflows:
|
||||
# ci-required-drift (`:17`), main-red-watchdog (`:05`),
|
||||
# railway-pin-audit (`:23`). 5-min cadence gives a tight enough
|
||||
# close on schedule-triggered false-reds that main-red-watchdog
|
||||
# (hourly :05) almost never files an issue on the false case.
|
||||
- cron: '*/5 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
# Compensating-status POST needs write on repo statuses; no other
|
||||
# write surface is touched. checkout still needs `contents: read`.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Single-flight: two reaper ticks racing would POST duplicate
|
||||
# compensations. Idempotent at the API (Gitea overwrites by context
|
||||
# on POST /statuses/{sha}) but cleaner to serialise.
|
||||
concurrency:
|
||||
group: status-reaper
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
reap:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- name: Check out repo at default-branch HEAD
|
||||
# BASE checkout per `feedback_pull_request_target_workflow_from_base`.
|
||||
# The script reads .gitea/workflows/*.yml from the working tree to
|
||||
# classify trigger sets; we must read main's CURRENT state, not
|
||||
# the SHA a stale schedule fired against.
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Set up Python (PyYAML for workflow `on:` parse)
|
||||
# Pinned to 3.12 to match sibling watchdog / ci-required-drift.
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install PyYAML
|
||||
# PyYAML is needed because shell-grep on `on:` misses list/string
|
||||
# forms and nested `push: { paths: ... }`. Same install pattern
|
||||
# as ci-required-drift.yml (sub-2s install, no wheel cache).
|
||||
run: python -m pip install --quiet 'PyYAML==6.0.2'
|
||||
|
||||
- name: Compensate operational push-suffix failures on main
|
||||
env:
|
||||
# claude-status-reaper persona token; provisioned by sibling
|
||||
# aefaac1b 2026-05-11. Owns write:repository scope to POST
|
||||
# /statuses/{sha} but NOTHING ELSE
|
||||
# (`feedback_per_agent_gitea_identity_default`).
|
||||
GITEA_TOKEN: ${{ secrets.STATUS_REAPER_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
WATCH_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
WORKFLOWS_DIR: .gitea/workflows
|
||||
run: python3 .gitea/scripts/status-reaper.py
|
||||
603
tests/test_status_reaper.py
Normal file
603
tests/test_status_reaper.py
Normal file
@ -0,0 +1,603 @@
|
||||
"""Tests for `.gitea/scripts/status-reaper.py` — Option B compensating
|
||||
status POST for Gitea 1.22.6's hardcoded `(push)` suffix bug.
|
||||
|
||||
Coverage (per hongming-pc 22:08Z review + brief):
|
||||
1. test_workflow_with_name_field
|
||||
2. test_workflow_without_name_field (filename stem fallback)
|
||||
3. test_workflow_name_collision_fails_loud
|
||||
4. test_workflow_name_with_slash_fails_loud
|
||||
5. test_has_push_trigger_true (dict shape, list shape, str shape)
|
||||
6. test_has_push_trigger_false (schedule-only, dispatch-only,
|
||||
pull_request-only, workflow_run-only)
|
||||
7. test_publish_workspace_server_image_preserved (explicit case)
|
||||
8. test_compensating_post_payload (POST body shape verification)
|
||||
|
||||
Plus regression coverage:
|
||||
- parse_push_context strictness (only ` (push)` suffix with ` / `
|
||||
separator triggers compensation).
|
||||
- Class-O detection via end-to-end reap() with a stubbed api().
|
||||
- ApiError propagation on non-2xx (mirror of main-red-watchdog's
|
||||
`feedback_api_helper_must_raise_not_return_dict` test).
|
||||
- Unknown-workflow conservatism: ::notice:: + skip, never POST.
|
||||
- Non-`(push)`-suffix contexts (the `(pull_request)` required-checks
|
||||
on main) are NEVER touched — verified safe 2026-05-11.
|
||||
|
||||
Hostile self-review proof:
|
||||
- test_required_check_pull_request_suffix_never_touched exercises
|
||||
the safety contract: a pre-fix that compensated any failing
|
||||
context would mask the Secret scan required-check. Verified by
|
||||
stashing the `endswith(PUSH_SUFFIX)` guard and re-running: test
|
||||
FAILS as required.
|
||||
- test_workflow_name_collision_fails_loud asserts exit code 1; a
|
||||
pre-fix that "first write wins" would silently misclassify a
|
||||
renamed workflow.
|
||||
|
||||
Run:
|
||||
python3 -m pytest tests/test_status_reaper.py -v
|
||||
|
||||
Dependencies: stdlib + pytest + PyYAML. No network.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Module-import fixture
|
||||
# --------------------------------------------------------------------------
|
||||
SCRIPT_PATH = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ ".gitea"
|
||||
/ "scripts"
|
||||
/ "status-reaper.py"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def sr_module():
|
||||
"""Import the script as a module under a known env."""
|
||||
env = {
|
||||
"GITEA_TOKEN": "test-token",
|
||||
"GITEA_HOST": "git.example.test",
|
||||
"REPO": "owner/repo",
|
||||
"WATCH_BRANCH": "main",
|
||||
"WORKFLOWS_DIR": ".gitea/workflows",
|
||||
}
|
||||
with mock.patch.dict(os.environ, env, clear=False):
|
||||
spec = importlib.util.spec_from_file_location("status_reaper", SCRIPT_PATH)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
m.GITEA_TOKEN = env["GITEA_TOKEN"]
|
||||
m.GITEA_HOST = env["GITEA_HOST"]
|
||||
m.REPO = env["REPO"]
|
||||
m.WATCH_BRANCH = env["WATCH_BRANCH"]
|
||||
m.WORKFLOWS_DIR = env["WORKFLOWS_DIR"]
|
||||
m.OWNER, m.NAME = "owner", "repo"
|
||||
m.API = f"https://{env['GITEA_HOST']}/api/v1"
|
||||
yield m
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Workflow scan tests — workflow_id resolution
|
||||
# --------------------------------------------------------------------------
|
||||
def _write_workflow(tmp_path: Path, filename: str, content: str) -> Path:
|
||||
"""Write a workflow YAML to a temp dir and return its path."""
|
||||
d = tmp_path / "workflows"
|
||||
d.mkdir(exist_ok=True)
|
||||
p = d / filename
|
||||
p.write_text(content)
|
||||
return p
|
||||
|
||||
|
||||
def test_workflow_with_name_field(sr_module, tmp_path):
|
||||
"""`name:` field beats filename stem."""
|
||||
_write_workflow(
|
||||
tmp_path,
|
||||
"publish-runtime.yml",
|
||||
"name: publish-runtime\non:\n push:\n branches: [main]\n",
|
||||
)
|
||||
out = sr_module.scan_workflows(str(tmp_path / "workflows"))
|
||||
assert "publish-runtime" in out
|
||||
assert out["publish-runtime"] is True
|
||||
|
||||
|
||||
def test_workflow_without_name_field(sr_module, tmp_path):
|
||||
"""No `name:` → filename stem (basename minus `.yml`)."""
|
||||
_write_workflow(
|
||||
tmp_path,
|
||||
"no-name-workflow.yml",
|
||||
"on:\n schedule:\n - cron: '*/5 * * * *'\n",
|
||||
)
|
||||
out = sr_module.scan_workflows(str(tmp_path / "workflows"))
|
||||
assert "no-name-workflow" in out
|
||||
assert out["no-name-workflow"] is False # schedule-only → class-O
|
||||
|
||||
|
||||
def test_workflow_name_collision_fails_loud(sr_module, tmp_path, capsys):
|
||||
"""Two workflows resolving to the same name → exit 1 with ::error::."""
|
||||
_write_workflow(
|
||||
tmp_path,
|
||||
"a.yml",
|
||||
"name: same-name\non:\n push: {}\n",
|
||||
)
|
||||
_write_workflow(
|
||||
tmp_path,
|
||||
"b.yml",
|
||||
"name: same-name\non:\n schedule:\n - cron: '0 * * * *'\n",
|
||||
)
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
sr_module.scan_workflows(str(tmp_path / "workflows"))
|
||||
assert excinfo.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "::error::workflow name collision detected: same-name" in captured.err
|
||||
|
||||
|
||||
def test_workflow_name_with_slash_fails_loud(sr_module, tmp_path, capsys):
|
||||
"""`name:` containing `/` → exit 1 with ::error:: (breaks context parse)."""
|
||||
_write_workflow(
|
||||
tmp_path,
|
||||
"weird.yml",
|
||||
"name: my/weird/name\non:\n push: {}\n",
|
||||
)
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
sr_module.scan_workflows(str(tmp_path / "workflows"))
|
||||
assert excinfo.value.code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "::error::workflow name contains '/'" in captured.err
|
||||
assert "my/weird/name" in captured.err
|
||||
|
||||
|
||||
def test_workflow_name_with_slash_via_filename_stem_fails_loud(sr_module, tmp_path, capsys):
|
||||
"""Even if filename stem contains `/` (path-flavoured stem) we trip the
|
||||
same guard. Defensive — Path.stem strips `/` so this can't happen via
|
||||
real filesystems, but the guard catches it if someone synthesises a
|
||||
map from a non-filesystem source in future."""
|
||||
# Force the filename-stem path by writing a no-name workflow whose
|
||||
# PARENT path has a `/` — but Path.stem only takes the basename, so
|
||||
# we instead mock _on_block / iterate manually. Easier: assert the
|
||||
# in-code check directly.
|
||||
# The `/` guard runs on `workflow_id`. Test it via an explicit name
|
||||
# field workflow (already covered) — this test is left as a
|
||||
# docstring-only marker that the filename-stem path can't ever
|
||||
# produce a `/` (Path.stem strips it).
|
||||
assert True # No-op: Path.stem strips `/`; documented invariant.
|
||||
|
||||
|
||||
def test_workflow_empty_name_falls_back_to_stem(sr_module, tmp_path):
|
||||
"""Empty `name:` (just whitespace) should fall back to filename stem."""
|
||||
_write_workflow(
|
||||
tmp_path,
|
||||
"stem-fallback.yml",
|
||||
"name: ' '\non:\n push: {}\n",
|
||||
)
|
||||
out = sr_module.scan_workflows(str(tmp_path / "workflows"))
|
||||
assert "stem-fallback" in out # filename stem used
|
||||
assert out["stem-fallback"] is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# has_push_trigger tests
|
||||
# --------------------------------------------------------------------------
|
||||
def test_has_push_trigger_true_dict(sr_module):
|
||||
assert sr_module._has_push_trigger({"push": {}, "schedule": []}, "w") is True
|
||||
|
||||
|
||||
def test_has_push_trigger_true_dict_with_paths(sr_module):
|
||||
"""`on: { push: { paths: ['workspace/**'] } }` → still push-triggered."""
|
||||
assert (
|
||||
sr_module._has_push_trigger(
|
||||
{"push": {"paths": ["workspace/**"]}}, "w"
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_has_push_trigger_true_list(sr_module):
|
||||
assert sr_module._has_push_trigger(["push", "pull_request"], "w") is True
|
||||
|
||||
|
||||
def test_has_push_trigger_true_str(sr_module):
|
||||
assert sr_module._has_push_trigger("push", "w") is True
|
||||
|
||||
|
||||
def test_has_push_trigger_false_schedule_only(sr_module):
|
||||
"""Schedule-only workflow (class-O canonical)."""
|
||||
assert (
|
||||
sr_module._has_push_trigger(
|
||||
{"schedule": [{"cron": "0 * * * *"}]}, "w"
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_has_push_trigger_false_dispatch_only(sr_module):
|
||||
assert sr_module._has_push_trigger({"workflow_dispatch": {}}, "w") is False
|
||||
|
||||
|
||||
def test_has_push_trigger_false_pull_request_only(sr_module):
|
||||
"""`on: { pull_request: {...} }` only → no push trigger."""
|
||||
assert sr_module._has_push_trigger({"pull_request": {}}, "w") is False
|
||||
|
||||
|
||||
def test_has_push_trigger_false_workflow_run_only(sr_module):
|
||||
"""`on: { workflow_run: {...} }` → no push trigger.
|
||||
(Even though Gitea 1.22.6 doesn't fire workflow_run, the classifier
|
||||
must handle YAML that declares it — for forward-compat.)"""
|
||||
assert sr_module._has_push_trigger({"workflow_run": {}}, "w") is False
|
||||
|
||||
|
||||
def test_has_push_trigger_false_list_no_push(sr_module):
|
||||
assert (
|
||||
sr_module._has_push_trigger(["pull_request", "schedule"], "w") is False
|
||||
)
|
||||
|
||||
|
||||
def test_has_push_trigger_ambiguous_preserves(sr_module, capsys):
|
||||
"""Unknown shape → True (preserve, never compensate) + log ::notice::."""
|
||||
assert sr_module._has_push_trigger(42, "weird-workflow") is True
|
||||
captured = capsys.readouterr()
|
||||
assert "::notice::ambiguous on: for weird-workflow" in captured.out
|
||||
|
||||
|
||||
def test_has_push_trigger_none_preserves(sr_module, capsys):
|
||||
"""None `on:` block → True (preserve)."""
|
||||
assert sr_module._has_push_trigger(None, "no-on") is True
|
||||
captured = capsys.readouterr()
|
||||
assert "::notice::ambiguous on:" in captured.out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Real-world fixture: publish-workspace-server-image preserved
|
||||
# --------------------------------------------------------------------------
|
||||
def test_publish_workspace_server_image_preserved(sr_module, tmp_path):
|
||||
"""Explicit case per brief: real `push` trigger → preserve, even
|
||||
when failing. Protects mc#576 (currently red on docker-socket issue).
|
||||
"""
|
||||
_write_workflow(
|
||||
tmp_path,
|
||||
"publish-workspace-server-image.yml",
|
||||
"name: publish-workspace-server-image\n"
|
||||
"on:\n"
|
||||
" push:\n"
|
||||
" branches: [main]\n"
|
||||
" paths: ['workspace/**']\n"
|
||||
" workflow_dispatch:\n",
|
||||
)
|
||||
out = sr_module.scan_workflows(str(tmp_path / "workflows"))
|
||||
assert out["publish-workspace-server-image"] is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Context parsing
|
||||
# --------------------------------------------------------------------------
|
||||
def test_parse_push_context_canonical(sr_module):
|
||||
"""`<workflow_name> / <job_name> (push)` → (workflow_name, job_name)."""
|
||||
parsed = sr_module.parse_push_context("staging-smoke / smoke (push)")
|
||||
assert parsed == ("staging-smoke", "smoke")
|
||||
|
||||
|
||||
def test_parse_push_context_workflow_name_with_spaces(sr_module):
|
||||
"""Workflow name with spaces — common (`Continuous synthetic E2E`)."""
|
||||
parsed = sr_module.parse_push_context(
|
||||
"Continuous synthetic E2E (staging) / e2e (push)"
|
||||
)
|
||||
assert parsed == ("Continuous synthetic E2E (staging)", "e2e")
|
||||
|
||||
|
||||
def test_parse_push_context_non_push_suffix_returns_none(sr_module):
|
||||
"""`(pull_request)` suffix → None (not the bug shape; required-checks)."""
|
||||
assert (
|
||||
sr_module.parse_push_context("Secret scan / Scan diff (pull_request)")
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_parse_push_context_no_separator_returns_none(sr_module):
|
||||
"""`(push)` suffix but no ` / ` → None (not the bug shape)."""
|
||||
assert sr_module.parse_push_context("just-a-context (push)") is None
|
||||
|
||||
|
||||
def test_parse_push_context_no_suffix_returns_none(sr_module):
|
||||
assert sr_module.parse_push_context("workflow / job") is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Compensating POST payload shape
|
||||
# --------------------------------------------------------------------------
|
||||
def test_compensating_post_payload(sr_module, monkeypatch):
|
||||
"""POST /statuses/{sha} body: state=success, context preserved,
|
||||
description = COMPENSATION_DESCRIPTION, target_url echoed if present.
|
||||
"""
|
||||
calls = []
|
||||
|
||||
def fake_api(method, path, *, body=None, query=None, expect_json=True):
|
||||
calls.append((method, path, body, query))
|
||||
return (201, {})
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
|
||||
sr_module.post_compensating_status(
|
||||
"deadbeefcafe1234567890abcdef000011112222",
|
||||
"staging-smoke / smoke (push)",
|
||||
"https://git.example.test/owner/repo/actions/runs/14525",
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
assert len(calls) == 1
|
||||
method, path, body, _query = calls[0]
|
||||
assert method == "POST"
|
||||
assert path == "/repos/owner/repo/statuses/deadbeefcafe1234567890abcdef000011112222"
|
||||
assert body == {
|
||||
"context": "staging-smoke / smoke (push)",
|
||||
"state": "success",
|
||||
"description": sr_module.COMPENSATION_DESCRIPTION,
|
||||
"target_url": "https://git.example.test/owner/repo/actions/runs/14525",
|
||||
}
|
||||
|
||||
|
||||
def test_compensating_post_payload_no_target_url(sr_module, monkeypatch):
|
||||
"""target_url is optional — omitted when the original status had none."""
|
||||
calls = []
|
||||
|
||||
def fake_api(method, path, *, body=None, query=None, expect_json=True):
|
||||
calls.append((method, path, body, query))
|
||||
return (201, {})
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
sr_module.post_compensating_status(
|
||||
"abc1234567",
|
||||
"x / y (push)",
|
||||
None,
|
||||
dry_run=False,
|
||||
)
|
||||
assert calls[0][2] == {
|
||||
"context": "x / y (push)",
|
||||
"state": "success",
|
||||
"description": sr_module.COMPENSATION_DESCRIPTION,
|
||||
}
|
||||
|
||||
|
||||
def test_compensating_post_dry_run_no_api_call(sr_module, monkeypatch, capsys):
|
||||
"""--dry-run must NOT POST."""
|
||||
def fake_api(*args, **kwargs):
|
||||
raise AssertionError("api() should not be called in dry_run")
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
sr_module.post_compensating_status(
|
||||
"deadbeefcafe1234567890abcdef000011112222",
|
||||
"ci/test (push)",
|
||||
None,
|
||||
dry_run=True,
|
||||
)
|
||||
captured = capsys.readouterr()
|
||||
assert "::notice::[dry-run] would compensate" in captured.out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# End-to-end reap() — class-O detection
|
||||
# --------------------------------------------------------------------------
|
||||
SHA = "deadbeefcafe1234567890abcdef000011112222"
|
||||
|
||||
|
||||
def test_reap_compensates_class_o(sr_module, monkeypatch):
|
||||
"""schedule-only workflow with failing `(push)` status → compensate."""
|
||||
calls = []
|
||||
|
||||
def fake_api(method, path, *, body=None, query=None, expect_json=True):
|
||||
calls.append((method, path, body))
|
||||
return (201, {})
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
|
||||
workflow_map = {"staging-smoke": False} # no push trigger
|
||||
combined = {
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{
|
||||
"context": "staging-smoke / smoke (push)",
|
||||
"state": "failure",
|
||||
"target_url": "https://example.test/run/1",
|
||||
"description": "smoke job failed",
|
||||
}
|
||||
],
|
||||
}
|
||||
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
|
||||
assert counters["compensated"] == 1
|
||||
assert counters["preserved_real_push"] == 0
|
||||
assert len(calls) == 1
|
||||
assert calls[0][0] == "POST"
|
||||
assert calls[0][1] == f"/repos/owner/repo/statuses/{SHA}"
|
||||
|
||||
|
||||
def test_reap_preserves_real_push(sr_module, monkeypatch):
|
||||
"""publish-workspace-server-image (has push trigger) → preserve."""
|
||||
calls = []
|
||||
|
||||
def fake_api(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return (201, {})
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
|
||||
workflow_map = {"publish-workspace-server-image": True}
|
||||
combined = {
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{
|
||||
"context": "publish-workspace-server-image / build (push)",
|
||||
"state": "failure",
|
||||
}
|
||||
],
|
||||
}
|
||||
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
|
||||
assert counters["compensated"] == 0
|
||||
assert counters["preserved_real_push"] == 1
|
||||
assert calls == [] # NO POST
|
||||
|
||||
|
||||
def test_reap_preserves_unknown_workflow(sr_module, monkeypatch, capsys):
|
||||
"""Workflow not in map → ::notice:: + skip (conservative)."""
|
||||
monkeypatch.setattr(
|
||||
sr_module, "api",
|
||||
lambda *a, **kw: (_ for _ in ()).throw(
|
||||
AssertionError("api should not be called")
|
||||
),
|
||||
)
|
||||
|
||||
workflow_map = {} # empty map
|
||||
combined = {
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{
|
||||
"context": "deleted-workflow / job (push)",
|
||||
"state": "failure",
|
||||
}
|
||||
],
|
||||
}
|
||||
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
|
||||
assert counters["compensated"] == 0
|
||||
assert counters["preserved_unknown"] == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "::notice::unknown workflow 'deleted-workflow'" in captured.out
|
||||
|
||||
|
||||
def test_reap_required_check_pull_request_suffix_never_touched(sr_module, monkeypatch):
|
||||
"""SAFETY CONTRACT: `(pull_request)` suffix contexts (the actual
|
||||
required-checks on main) are NEVER touched. A pre-fix that
|
||||
compensated any failure would mask Secret scan.
|
||||
"""
|
||||
calls = []
|
||||
|
||||
def fake_api(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return (201, {})
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
|
||||
# Even with the workflow mapped as no-push-trigger (which would
|
||||
# normally compensate), the suffix guard prevents the POST.
|
||||
workflow_map = {"Secret scan": False}
|
||||
combined = {
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{
|
||||
"context": "Secret scan / Scan diff for credential-shaped strings (pull_request)",
|
||||
"state": "failure",
|
||||
}
|
||||
],
|
||||
}
|
||||
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
|
||||
assert counters["compensated"] == 0
|
||||
assert counters["preserved_non_push_suffix"] == 1
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_reap_ignores_non_failure_states(sr_module, monkeypatch):
|
||||
"""Only `failure` is compensated. `pending` / `success` / `error`
|
||||
left alone — they have legitimate semantics."""
|
||||
monkeypatch.setattr(
|
||||
sr_module, "api",
|
||||
lambda *a, **kw: (_ for _ in ()).throw(
|
||||
AssertionError("api should not be called")
|
||||
),
|
||||
)
|
||||
|
||||
workflow_map = {"sweep-cf-tunnels": False}
|
||||
combined = {
|
||||
"state": "pending",
|
||||
"statuses": [
|
||||
{"context": "sweep-cf-tunnels / sweep (push)", "state": "pending"},
|
||||
{"context": "sweep-cf-tunnels / sweep (push)", "state": "success"},
|
||||
{"context": "sweep-cf-tunnels / sweep (push)", "state": "error"},
|
||||
],
|
||||
}
|
||||
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
|
||||
assert counters["compensated"] == 0
|
||||
assert counters["preserved_non_failure"] == 3
|
||||
|
||||
|
||||
def test_reap_unparseable_push_context_preserved(sr_module, monkeypatch):
|
||||
"""`(push)` suffix but no ` / ` separator → not the bug shape, preserve."""
|
||||
monkeypatch.setattr(
|
||||
sr_module, "api",
|
||||
lambda *a, **kw: (_ for _ in ()).throw(
|
||||
AssertionError("api should not be called")
|
||||
),
|
||||
)
|
||||
|
||||
workflow_map = {"x": False}
|
||||
combined = {
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{"context": "no-slash-here (push)", "state": "failure"},
|
||||
],
|
||||
}
|
||||
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
|
||||
assert counters["compensated"] == 0
|
||||
assert counters["preserved_unparseable"] == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# ApiError propagation
|
||||
# --------------------------------------------------------------------------
|
||||
def test_get_head_sha_raises_on_non_2xx(sr_module, monkeypatch):
|
||||
"""ApiError on transient outage propagates per
|
||||
`feedback_api_helper_must_raise_not_return_dict`."""
|
||||
def fake_api(method, path, **kwargs):
|
||||
raise sr_module.ApiError("GET /branches/main -> HTTP 500: nope")
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
with pytest.raises(sr_module.ApiError):
|
||||
sr_module.get_head_sha("main")
|
||||
|
||||
|
||||
def test_get_combined_status_raises_on_non_2xx(sr_module, monkeypatch):
|
||||
def fake_api(method, path, **kwargs):
|
||||
raise sr_module.ApiError("GET /status -> HTTP 500: nope")
|
||||
|
||||
monkeypatch.setattr(sr_module, "api", fake_api)
|
||||
with pytest.raises(sr_module.ApiError):
|
||||
sr_module.get_combined_status("deadbeef")
|
||||
|
||||
|
||||
def test_get_head_sha_missing_commit_raises(sr_module, monkeypatch):
|
||||
"""A malformed 200 response (no `commit` field) raises ApiError."""
|
||||
monkeypatch.setattr(
|
||||
sr_module, "api", lambda m, p, **kw: (200, {"name": "main"})
|
||||
)
|
||||
with pytest.raises(sr_module.ApiError):
|
||||
sr_module.get_head_sha("main")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# scan_workflows on real repo (smoke)
|
||||
# --------------------------------------------------------------------------
|
||||
def test_scan_workflows_on_real_repo_no_collision(sr_module):
|
||||
"""Smoke: scan the actual .gitea/workflows/ in this repo. Asserts
|
||||
no real-world collision/`/`-in-name lurks. If this fails, a real
|
||||
workflow file must be fixed before reaper can ship."""
|
||||
real_dir = str(SCRIPT_PATH.parent.parent / "workflows")
|
||||
# Should NOT raise SystemExit — collision/slash guards must pass.
|
||||
out = sr_module.scan_workflows(real_dir)
|
||||
assert len(out) > 0
|
||||
# publish-workspace-server-image is the canonical preserved case.
|
||||
assert out.get("publish-workspace-server-image") is True
|
||||
# main-red-watchdog is the canonical class-O case.
|
||||
assert out.get("main-red-watchdog") is False
|
||||
# ci is the canonical required-check (push+pull_request).
|
||||
assert out.get("CI") is True or out.get("ci") is True
|
||||
|
||||
|
||||
def test_scan_workflows_missing_dir_returns_empty(sr_module, tmp_path, capsys):
|
||||
"""Missing workflows dir → empty map + ::warning::."""
|
||||
out = sr_module.scan_workflows(str(tmp_path / "nope"))
|
||||
assert out == {}
|
||||
captured = capsys.readouterr()
|
||||
assert "::warning::workflows dir not found" in captured.out
|
||||
Loading…
Reference in New Issue
Block a user