Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/groom/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,24 @@ rather than buried in a runner script.
| Phase | Brief | Input | Output (JSON) |
|---|---|---|---|
| 1. Find | [`finder.md`](finder.md) | clean `origin/main` checkout + scan scope | `{repo, scope, findings:[{title, dimension, sites, evidence, proposed, value, risk, confidence, steelman}]}` at `{{FINDER_OUT}}` |
| 2. Verify | [`verifier.md`](verifier.md) | the finder's JSON + the code | `{repo, scope, summary, findings:[{title, verdict, security, signature, body}]}` at `{{VERIFIER_OUT}}` |
| 2. Verify | [`verifier.md`](verifier.md) | the finder's JSON + the code | `{repo, scope, summary, findings:[{title, verdict, security, sites, signature, body}]}` at `{{VERIFIER_OUT}}` |
| 3. Build (opt-in) | [`builder.md`](builder.md) | ONE verified finding `{title, body, signature}` at `{{FINDING_IN}}` + the code | edits in the checkout + a control file `{status: patched\|bail, summary}` at `{{BUILDER_OUT}}` |

- **`verdict`** is `CONFIRM` \| `DOWNGRADE` (real but narrow the scope) \|
`REJECT` (premature / overstated / not worth it).
- **`security: true`** marks any auth/permission/security-adjacent finding —
those are filed as investigations, **never** auto-implemented.
- **`sites`** is the `file:line` evidence the verdict actually rests on — the
NARROWED set on a `DOWNGRADE`. On a path-scoped run `scope.py verify` re-applies
the directory filter to it, because a downgrade may narrow a cross-boundary
finding onto its out-of-scope half.
- **`signature`** is a stable dedup key (`<repo-basename>:<scope>:<slug>`) whose
`<slug>` is derived **deterministically** from the finding's core subject, so it
stays identical across re-runs of the same finding and a consumer never re-files
a finding it has already seen.
a finding it has already seen. The `<scope>` component is the caller's own
`scope_label`, never the audited directory — and `scope.py verify` rewrites it
back to that value, so scope-independence does not depend on the model obeying
the brief.

## How a consumer uses these briefs

Expand Down
111 changes: 99 additions & 12 deletions .github/groom/interval.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
finder (its `Audit — finder` job ran, i.e. was not `skipped` by this very gate),
so the interval-skip ticks in between never reset the clock. Run history is
durable across the stateless CI runs and readable with only `actions: read`.
The clock is **per scope** (groom.yml's `path` input, BE-4757): a run counts only
against a tick auditing the SAME scope. A path-scoped run must not stamp "done"
over the whole-repo audit it never performed, and — symmetrically — a permanently
path-scoped caller must still get a working cadence for ITS directory rather than
re-billing an audit every tick. See `_SCOPED_MARKER_PREFIX`.

The gate is **fail-open**, matching the volume gate: any error deriving the last
run (API hiccup, unparseable timestamp, no history) RUNS the audit rather than
Expand All @@ -41,7 +46,8 @@

python3 .github/groom/interval.py \
--repo owner/name --workflow-file ci-groom.yml \
--current-run-id 123 --interval-days 7 --event-name schedule
--current-run-id 123 --interval-days 7 --event-name schedule \
--path '' # the scope this tick would audit ('' = whole repo)

Prints a `{should_run, reason, interval_days, days_since, last_run_at}` decision
JSON to stdout; the gate step reads `.should_run`. Always exits 0 (the decision
Expand All @@ -64,6 +70,44 @@
# `skipped`, so it never matches the audited conclusions below.
_FINDER_JOB_HINTS = ("finder", "audit_find")

# The subset of the above that proves we are reading a RENDERED DISPLAY name, and
# can therefore read the scope marker off it. The bare job-id form (`audit_find`)
# carries no marker — not because it is whole-repo, but because the name never
# went through groom.yml's `name:` expression at all — so it is treated as
# UNKNOWN scope and counts for nothing. See `run_audited`.
_DISPLAY_NAME_HINTS = ("finder",)

# The cadence clock is PER SCOPE (groom.yml's `path` input, BE-4757). The signal
# has to survive the runs API, which does NOT return a run's dispatch INPUTS — so
# groom.yml renames the finder job itself when `path` is set (`Audit — finder
# (scoped: services/api)`). Job names are the one per-run discriminator both
# sides can see.
#
# Both directions matter:
#
# * A scoped run must not reset the WHOLE-REPO clock. `workflow_dispatch`
# deliberately bypasses this gate, so without the marker a manual
# `path: services/api` run would reach the finder, become "the last real
# groom", and suppress the next scheduled whole-repo tick for a full
# GROOM_INTERVAL_DAYS — a PARTIAL audit stamping "done" over the full one.
# * A permanently scoped caller (an explicitly documented pattern: pin `path` in
# `with:` to groom one directory as its own unit) must still HAVE a clock. With
# a scope-blind exclusion every one of its finder jobs is invisible, the gate
# fails open on every tick, and the billed audit re-runs daily no matter what
# GROOM_INTERVAL_DAYS says — the cadence knob silently defeated for exactly the
# configuration the `path` input advertises.
#
# So the marker carries the path, and a tick counts only a prior run of its OWN
# scope. The path charset (`scope.py:_COMPONENT_RE` plus `/`) is deliberately
# narrow, so it embeds in a job name without escaping concerns.
_SCOPED_MARKER_PREFIX = "(scoped:"

# Collision direction is deliberate everywhere here: an unrecognised, truncated
# or ambiguous job name (a caller job id long enough to push the marker past
# GitHub's name rendering, say) makes a real run stop counting, i.e. groom runs
# MORE often. That is the same fail-open bias as every other branch in this
# module — never the silent under-run.

# A finder job that reached success OR failure spent the (billed) audit, so both
# count as a real run: counting a failure keeps a run that spent money but died
# at a later step (e.g. filing) from re-spending on the very next daily tick.
Expand Down Expand Up @@ -176,11 +220,50 @@ def days_since(then_iso: str, now: datetime) -> float:
return (now - then).total_seconds() / 86400.0


def run_audited(jobs) -> bool:
"""True if a run's jobs show the finder actually ran (not an interval-skip)."""
def scoped_job_marker(path: str) -> str:
"""The job-name marker groom.yml appends for a run scoped to `path`.

Kept next to the matcher that reads it, so the producing expression in
groom.yml and the consuming comparison cannot drift apart silently
(`test_interval.py` pins both halves against this).
"""
return f"(scoped: {path})"


def run_audited(jobs, scope_path: str = "") -> bool:
"""True if a run's jobs show a finder for `scope_path` actually ran.

`scope_path` is the scope of the tick being decided: "" for a whole-repo
audit, else the audited directory. A run counts only against its OWN scope —
a scoped run must leave the next scheduled whole-repo tick DUE, and a
whole-repo sweep is not a substitute for a scoped caller's own cadence.

Never counted: an interval-skip (its finder job is `skipped`), and a job
matched only by the bare job-id hint, whose name never carried the scope
marker and so cannot be ATTRIBUTED to a scope at all. Both fall through to
"no prior run", which fails open.
"""
want = scoped_job_marker(scope_path) if scope_path else ""
for job in jobs or []:
name = (job.get("name") or "").lower()
if any(hint in name for hint in _FINDER_JOB_HINTS) and job.get("conclusion") in _AUDITED_CONCLUSIONS:
raw_name = job.get("name") or ""
name = raw_name.lower()
if not any(hint in name for hint in _FINDER_JOB_HINTS):
continue
if job.get("conclusion") not in _AUDITED_CONCLUSIONS:
Comment thread
mattmillerai marked this conversation as resolved.
Comment thread
mattmillerai marked this conversation as resolved.
continue
if want:
# The MARKER is compared case-SENSITIVELY (against the raw job name)
# while the surrounding prose hints stay case-insensitive. Paths on
# the Linux runner are case-sensitive and `_COMPONENT_RE` admits both
# cases, so `services/api` and `services/API` are two distinct scopes
# with two distinct clocks; folding case would collapse them onto one
# and let a run of either silently suppress the other's due tick —
# the silent under-run this module refuses. A case MISMATCH now reads
# as "no prior run of this scope", i.e. fail-open, the same collision
# direction as every other branch here.
if want in raw_name:
return True
elif _SCOPED_MARKER_PREFIX not in name and any(h in name for h in _DISPLAY_NAME_HINTS):
return True
return False

Expand Down Expand Up @@ -264,12 +347,13 @@ def fetch_run_jobs(repo: str, run_id, run=subprocess.run):
return payload.get("jobs", []) if isinstance(payload, dict) else []


def find_last_audited_run_at(repo, workflow_file, current_run_id, run=subprocess.run):
def find_last_audited_run_at(repo, workflow_file, current_run_id, scope_path="", run=subprocess.run):
"""`run_started_at` of the most recent completed run that ran the finder.

Walks the caller workflow's runs newest-first, skips the current run and any
still-in-progress run, and returns the first whose finder job actually ran.
Returns None if none is found within the scanned window (-> fail-open run).
still-in-progress run, and returns the first whose finder job actually ran
FOR `scope_path`. Returns None if none is found within the scanned window
(-> fail-open run).
"""
current = str(current_run_id) if current_run_id is not None else None
for wf_run in fetch_workflow_runs(repo, workflow_file, run=run):
Expand All @@ -278,18 +362,19 @@ def find_last_audited_run_at(repo, workflow_file, current_run_id, run=subprocess
if wf_run.get("status") != "completed":
continue
jobs = fetch_run_jobs(repo, wf_run.get("id"), run=run)
if run_audited(jobs):
if run_audited(jobs, scope_path):
return wf_run.get("run_started_at")
return None


def evaluate(repo, workflow_file, current_run_id, interval_days, event_name, now, run=subprocess.run) -> dict:
def evaluate(repo, workflow_file, current_run_id, interval_days, event_name, now,
scope_path="", run=subprocess.run) -> dict:
"""Full gate decision, folding dispatch bypass + fail-open around the pure logic."""
if event_name == "workflow_dispatch":
return {"should_run": True, "reason": "workflow_dispatch — interval gate bypassed (manual override).",
"interval_days": interval_days, "days_since": None, "last_run_at": None}
try:
last_run_iso = find_last_audited_run_at(repo, workflow_file, current_run_id, run=run)
last_run_iso = find_last_audited_run_at(repo, workflow_file, current_run_id, scope_path, run=run)
except Exception as exc: # noqa: BLE001 — any failure to read history must fail OPEN, never skip a due groom.
return {"should_run": True, "reason": f"could not read run history ({exc}) — running (fail-open).",
"interval_days": interval_days, "days_since": None, "last_run_at": None}
Expand All @@ -316,6 +401,8 @@ def main(argv=None):
parser.add_argument("--current-run-id", required=True, help="this run's id, to exclude it from history")
parser.add_argument("--interval-days", default="", help="raw GROOM_INTERVAL_DAYS value (blank -> default 7)")
parser.add_argument("--event-name", default="schedule", help="github.event_name (workflow_dispatch bypasses)")
parser.add_argument("--path", default="",
help="scope this tick would audit ('' = whole repo); the clock is per-scope")
parser.add_argument("--now", default=None, help="override 'now' as an ISO-8601 UTC timestamp (for testing)")
parser.add_argument("--out", help="write the decision JSON here (also printed to stdout)")
args = parser.parse_args(argv)
Expand All @@ -326,7 +413,7 @@ def main(argv=None):
try:
decision = evaluate(
args.repo, args.workflow_file, args.current_run_id,
interval_days, args.event_name, now,
interval_days, args.event_name, now, (args.path or "").strip(),
)
except Exception as exc: # noqa: BLE001 — belt-and-suspenders: an unexpected bug fails OPEN.
decision = {"should_run": True, "reason": f"gate error ({exc}) — running (fail-open).",
Expand Down
Loading
Loading