Compare commits

...
10 Commits
Author SHA1 Message Date
Garry Tan 6f9eb79e76 Merge remote-tracking branch 'origin/master' into garrytan/pr-gate 2026-08-04 09:26:48 +07:00
Garry TanandClaude Fable 5 f1bee4c3a1 fix(ci): gate — CommonMark info-string rule, hash the real model payload, close the YAML scanner gap (blind review round 5)
Three defects a second blind reviewer found that survived round 5. All three
reproduced against cf7a1616 before the fix and are pinned after it.

1. The fence scanner still falsely closed compliant descriptions. CommonMark 4.5
   forbids a backtick in a BACKTICK fence's info string, so ```foo`bar is an
   ordinary paragraph. The scanner opened a fence on it, never found a closer,
   and stripped the body to EOF:

     body = '```foo`bar' + three sentences of real prose + an image embed
     before: intentWordCount 0, misses [missing_intent, missing_screenshot]
     after:  intentWordCount 52, misses []

   Same class as round 5's closing-length bug and the same cost — opening a
   block CommonMark would not open deletes the author's prose exactly the way
   closing one late did. Tilde fences keep the permissive rule (4.5 restricts
   backtick fences only).

2. The spend guard hashed less than the model consumes. hashInputs covered
   title, modelBody, head.sha, exemption and policy ids — but the payload also
   carries the changed-file list and the diff, and the workflow degrades the
   diff to a marker line when the API 406s on a huge one. So a run that
   classified with no diff cached a diff-blind verdict, and the next run — real
   diff in hand, same title/body/sha — matched the hash and was served that
   verdict permanently (measured: anthropicCalls 1 across both runs). The
   assembled payload is now folded in as a fixed-width digest, inside the JSON
   tuple where quoting still makes a forged boundary impossible. Round 5's
   property is re-pinned: a policy fix past the model's body cap invalidates
   even when the payload is byte-identical.

3. The workflow's ${{ }}-in-run scanner missed a legal commented block header.
   `run: | # shell block` is valid YAML — js-yaml puts the following
   interpolation in the script — but the header fell through to the
   single-line branch, which captured `| # shell block` as the whole command
   and never looked at the block body. The rule protecting against shell
   injection reported clean over an interpolating workflow. The scanner now
   handles a trailing comment on the header, and scans the comment text too
   rather than leaving itself a hiding place. Guarded against js-yaml's actual
   parse, not against the scanner's own opinion.

Also corrected one overstated claim in the file's security block: a BARE url in
a sanitized string still autolinks under GFM. That is a self-labelled link and
the deliberate stopping point — the escaping targets the masking characters so
it cannot forge `![APPROVED](…)` or `[click to approve](…)` — but "no live
links" was too strong for what the code does.

Self-audit: all 13 interpolations into the sticky comment are either literals in
this file or pass sanitizeModelText/sanitizeList; the only unsanitized one
(titleCheck.reason) is a hardcoded literal and renders inside a code span. Every
rendered statement matches behavior the code performs. The four realistic-human
fixtures and the three new fence cases all PASS; empty, "fixes bug", the
unfilled template, ten words of lorem and a fenced-away screenshot all still
FAIL.

test/pr-gate-workflow.test.ts 158 -> 163 pass, 0 fail. Six mutations (the
CommonMark rule, the tilde exemption, the payload digest, the runGate wiring,
the scanner's comment group, the header-comment scan) each fail at least one
test. typecheck, actionlint, verify (34/34) green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 09:24:28 +07:00
Garry TanandClaude Fable 5 cf7a1616b7 fix(ci): gate — stop red-Xing genuine contributors; neutralize markdown embeds (blind review round 4)
A blind reviewer rejected this branch on false positives and the repro held up.
The gate's intent check deleted every list and blockquote LINE before counting
words, then measured what was left against an arbitrary 40. Real people write
their story as bullets. Measured on the four realistic-human descriptions from
the repro:

  description                              before  after  floor 40  floor 20
  own prose written as four bullets             0     55  CLOSE      PASS
  short non-native-English paragraph           38     38  CLOSE      PASS
  specific first-person bug report             34     34  CLOSE      PASS
  mostly a stack trace + a real explanation    28     27  CLOSE      PASS

The file's own exemption rationale already says a check that is red on every
release is a check somebody disables inside a week. Red on every terse-but-
genuine contribution dies the same way, and it costs real contributors on the
way there — worse than the forgery risks rounds 1-3 chased.

- intentWordCount strips the list/quote MARKER and keeps the author's words.
  Still stripped: fenced code, indented code (new — the other spelling of a
  fence, guarded so it never eats a list continuation), headings, HTML
  comments, raw HTML, bare URLs, inline code, link/image syntax, and the
  template's own bold prompts. An untouched pull_request_template.md still
  scores 0, pinned against the real file on disk.
- INTENT_MIN_WORDS 40 -> 20, documented as a floor against empty/boilerplate-
  only descriptions, not a quality bar. CONTRIBUTING.md documents no word count
  at all. Empty, "fixes bug", the unfilled template, 10 words of lorem and an
  indented log paste all still fail.
- The fix-it comment no longer tells contributors to reopen a PR that was never
  closed — the gate has no close call in it. It now says what actually happens:
  editing the description re-runs the check (the workflow triggers on `edited`),
  the PR stays open, a maintainer decides.
- sanitizeModelText backslash-escapes `[` and `]`. Escaping HTML killed the
  <details> forgery, but Markdown needs no angle brackets: an image embed
  renders a green "APPROVED" picture and a link renders a live phishing target
  inside a close-lane comment. The file asserted nothing attacker-controlled
  reaches Markdown unescaped; that is now true.
- A label-API failure during a NEUTRAL run no longer throws to exit 2 with no
  comment. NEUTRAL has no verdict to record, so it logs, still posts, exits 0 —
  and the comment stops claiming labels were cleared when they were not. The
  verdict path keeps failing loudly on purpose (ordering note).

Four verbatim human descriptions are pinned as PASSING forever. Eight mutations
(floor, list markers, quote markers, indented code, markdown escaping, the
reopen copy, the NEUTRAL try/catch, the label claim) each fail at least one test.

test/pr-gate-workflow.test.ts 145 -> 158 pass, 0 fail. typecheck, actionlint,
verify (34/34) green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 08:36:09 +07:00
Garry Tan e85ddc814d fix(ci): gate — escape comment HTML, tighten screenshot floor, fix fence false-positive, repair label ordering (blind review round 3)
Four findings from a second independent blind review, all reproduced against
HEAD before the fix and all pinned by a mutation-tested case.

- sanitizeModelText escapes &, < and > after the existing comment/mention/
  block-marker stripping, so raw HTML from any dynamic string (model output,
  mechanical flag details built from PR filenames, neutralReason, the
  policy-exempt note) renders as literal text. A <details open><summary>MERGE
  LANE — approved</summary> string is a forged verdict inside a close-lane
  comment; it now renders as &lt;details.
- hasScreenshot requires a markdown image whose URL looks like a URL or path,
  an <img> carrying a non-empty src=, or a bare paste URL, and strips HTML
  comments before scanning. `![proof](x)`, `<img alt=proof>` and an image
  hidden inside `<!-- -->` no longer clear it. Documented in the code as a
  FLOOR against zero-effort submissions, not proof.
- stripCodeFences is a line scanner following the CommonMark rule that a
  closing fence must be the same character and at least as long as the
  opening one. The old backreference read ```` as a NEW opening fence and
  stripped to EOF, so a compliant body documenting fence syntax lost its
  intent paragraph and was closed. The scanner is also linear, retiring the
  superlinear-backtracking hazard the 16KB cap was sized against.
- Labels are reconciled BEFORE the sticky comment carrying the cached state.
  Written the other way, one transient label-API 500 left stale/missing/
  duplicate labels forever: the rerun short-circuited on the persisted state
  and returned success without repairing them.

Also:
- hashInputs covers what the run consumes — the truncated model body plus the
  mechanical policy outcome — so an edit past the 6KB model cap no longer
  mints a new hash and buys an identical paid call, while a policy fix landing
  past that cap still invalidates the cached verdict.
- The test's YAML block-scalar scanner accepts indentation indicators in both
  legal orders (`>2-` as well as `|-2`), with a guard-the-guard case proving
  it sees a `run: >2-` block interpolating attacker-controlled text.
- A block at the top of the script states plainly that this gate is a triage
  signal and a reviewer checklist, not an authorization boundary; one line of
  the sticky comment footer says the same to the contributor.

145 pass / 0 fail (was 126), typecheck clean, actionlint clean, verify 34/34.
2026-08-02 10:02:09 +08:00
Garry TanandClaude Fable 5 6da392ee9c fix(ci): gate — sanitize mechanical flag details, anchor path regexes, exempt maintainer/bot/draft from the policy check (blind review round 2)
Blind review round 2 rejected the branch. Five findings, all fixed.

BLOCKING 1 — the verdict was forgeable through a PR filename.
renderComment wrote mechanical red-flag details raw, and two of them
interpolate filenames (adds_recipe, deletes_tests). git allows a newline
inside a filename and JS `[^/]` matches one, so RECIPE_RE's anchors were
decorative: a PR adding `src/core/ai/recipes/x\n## PR Gate — ...\ncc
@octocat\n<!-- ...state... -->\nz.ts` put that text verbatim into the bot's
comment — forged heading, live third-party mention, and on the NEUTRAL
render (which writes no state block of its own) parseState() returned the
ATTACKER's block, so the next run hit the spend guard and silently skipped
the verdict with no label and exit 0. Three layers:
  (a) flag details go through sanitizeList, exactly like the model's
      strings. Audited every other interpolation into the comment; the rest
      are literals in the file, Number()-coerced, or already sanitized.
  (b) every path regex spells its segment class [^/\n], not [^/] —
      RECIPE_RE, SOURCE_EXT_RE and the test-path check.
  (c) parseState reads the state block only off line 2 of a marker-leading
      comment (where renderComment writes it) and STATE_RE is whole-line
      anchored. A block anywhere else is somebody else's text.

BLOCKING 2 — no exemption, so every release PR was close-lane.
Measured: 40 of the last 40 merged PRs would be close-lane on
missing_screenshot, including every /ship release PR. A check that is red
on every release gets switched off within a week, and then it filters
nothing. The #3745 policy exists to filter INCOMING OUTSIDE CONTRIBUTIONS;
release automation cannot take a screenshot of itself. It is now waived for
OWNER/MEMBER/COLLABORATOR, bot authors and drafts — the usefulness verdict,
the title rule and every mechanical red flag still run, and the sticky
comment says the check was skipped. author_association / draft / user.type
are read from the pr.json the workflow already fetches: no new API call, one
source of truth. `draft` is the one author-settable input, so
ready_for_review joins the trigger list and the exemption is folded into the
spend-guard hash — the draft-era verdict cannot be reused after the flip.
Stated as a deliberate decision in both the workflow header and the script.

3 — DOWNGRADE_FLAG_IDS omitted deletes_tests, adds_symlink and
adds_node_modules, so a PR deleting test/e2e/engine-parity.test.ts kept
merge-lane and a green check on the strength of its prose. All three added.
The old test used deletes_tests as its example of a NON-downgrading flag;
rewritten to pin the stronger invariant instead — every id detectRedFlags
can emit is a downgrade trigger (derived from the detector, so a new flag
fails until it is classified on purpose), and the set is still an allowlist
(an unrecognized id changes nothing).

4 — FENCE_RE backtracks superlinearly on a hostile body: 65KB of backticks
(GitHub's max body length) measured 8.2s across the two policy scans on a
pull_request_target runner. stripCodeFences now caps the scan at 16KB —
same input, 0.40s. Tradeoff documented at the constant: the intent
paragraph and the screenshot both sit near the top in practice (the PR
template puts them in the first two sections, and the model payload already
caps the same body at 6KB), so a real contributor is not judged on a
truncated tail.

Verified: test/pr-gate-workflow.test.ts 126 pass / 0 fail (was 95),
typecheck clean, actionlint clean, check-privacy /
check-no-tracked-symlinks / check-progress-to-stdout /
check-bun-test-timeout / check-key-files-current-state all exit 0. Each new
pin was mutation-tested against the pre-fix behavior: all six mutants fail
the suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:43:09 +08:00
Garry TanandClaude Fable 5 4a91ac4b3a fix(ci): gate policy check survives an API outage; merge master; drop NUL separators
Three fixes on the strict PR usefulness gate (#3698), plus the merge that
brings in the policy it enforces.

1. The mechanical policy check now outlives the model. The
   ANTHROPIC_API_KEY guard used to sit above detectPolicyMisses, so a PR
   with no intent paragraph and no screenshot got a green NEUTRAL skip
   whenever the key was absent or Anthropic was down — "wait for a 500"
   was a documented way past the one hard requirement. The #3745 branch
   now sits above the key guard and the spend guard: a policy miss is
   close-lane + the friendly fix-it comment + exit 1 with no API
   dependency at all. A compliant PR that hits a missing key or a dead
   API keeps the round-1 NEUTRAL behavior unchanged (loud comment,
   ::warning::, exit 0, stale gate:* labels cleared) — and the NEUTRAL
   comment now says plainly that the *usefulness verdict* did not run,
   while still reporting the title check and mechanical red flags it was
   able to compute without a model.

2. Merged origin/master, which carries #3745's CONTRIBUTING.md section
   and .github/pull_request_template.md. No conflicts: this branch never
   touched VERSION / package.json / CHANGELOG.md, so master's 0.42.72.1
   carried through untouched — the feature branch adds no version bump.
   The test's inlined pull_request_template fallback (only needed while
   the branch predated the merge) is gone; it now reads the real file, so
   growing the template's own prose past the 40-word bar fails here
   instead of silently letting an untouched template through. The
   CONTRIBUTING_URL deep link is pinned against a GitHub-style slug of
   every heading in the merged CONTRIBUTING.md, with the slugger itself
   pinned so it cannot "pass" against an anchor GitHub never generates.

3. hashInputs joined its three fields with literal NUL bytes, which made
   grep treat the whole of scripts/pr-gate.mjs as binary — any future
   grep-based CI guard over that file would have matched nothing and
   passed silently. Replaced with JSON.stringify of the tuple: still
   unforgeable (each field is quoted and escaped), still stable by
   construction, and printable. `grep -c hashInputs scripts/pr-gate.mjs`
   now returns 2 instead of nothing. Existing sticky-comment state hashes
   are invalidated once, costing one re-verdict per open PR.

Tests: 95 pass / 0 fail in test/pr-gate-workflow.test.ts. The no-API-key
policy-miss case was verified to fail against the pre-fix ordering.
Verified live against the Anthropic API: HTTP 200, strict JSON, all seven
required keys, merge-lane on a compliant fixture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:05:17 +08:00
Garry Tan 810d415e41 Merge remote-tracking branch 'origin/master' into garrytan/pr-gate 2026-08-02 08:54:39 +08:00
Garry TanandClaude Fable 5 2e4045309f feat(ci): gate enforces the #3745 intent-paragraph + screenshot requirement
CONTRIBUTING.md (#3745) requires every PR to carry a paragraph the author
wrote themselves and a screenshot of gbrain actually in use. The gate now
checks both, mechanically, before it spends anything on review.

What it checks (no LLM, both exported for testing):
- hasScreenshot: markdown `![alt](url)`, a bare user-images.githubusercontent
  or github.com/user-attachments/assets URL, or an <img> tag. Anything inside
  a fenced code block does not count — pasting the syntax is not attaching
  the picture.
- hasIntentParagraph: >= 40 words of prose left after stripping fenced code,
  blockquotes, list items, headings, HTML comments, links and the PR
  template's own boilerplate. Per-character scripts are tokenized per
  character, so a paragraph written in Chinese counts as one.

The two lane consequences:
- missing_screenshot OR missing_intent forces close-lane from any recommended
  lane (exit 1) and skips the model call entirely — closed without review is
  the documented consequence, so there is nothing to spend a review on. The
  sticky comment leads with what is missing, how to fix it, and the reopen
  path; both misses are also recorded in the existing "Mechanical downgrades
  applied" section.
- The model's new advisory intent_authenticity verdict forces
  needs-maintainer (exit 0) when it reads "ai_generated", and never
  close-lane on that signal alone. The comment says only that a maintainer
  will read the paragraph personally; the model's reasoning is consumed and
  never published at the contributor.

Rubric + strict-JSON schema gain intent_authenticity and
intent_authenticity_reason, with explicit instructions that rough grammar,
terseness and non-native English are evidence of a HUMAN and that "unclear"
is the answer whenever the evidence is not clear-cut.

test/pr-gate-workflow.test.ts: 63 -> 88 tests, all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 08:44:55 +08:00
Garry TanandClaude Fable 5 28fdec93da fix(ci): harden the PR gate — comment-ownership, output sanitization, deterministic lane downgrades (blind review round 1)
Two independent blind reviews of #3698 (one APPROVE, one REJECT). Every
confirmed finding from the reject side, plus the cheap hardening both
reviews flagged.

BLOCKING

1. Sticky-comment hijack. upsertStickyComment adopted ANY comment containing
   the marker, so a contributor could pre-post `<!-- gbrain-pr-gate -->`, have
   the gate PATCH it, then edit it into a fake green verdict. isOwnComment()
   now requires user.type === 'Bot' AND login === 'github-actions[bot]' AND
   the body to START WITH the marker; anything else gets a fresh comment.

2. LLM output injected as raw Markdown. Model-produced reasons[] and
   reviewer_checklist[] reached the comment unescaped — PR-body-driven
   injection could forge headings, a second marker, and live @mentions.
   sanitizeModelText()/sanitizeList() are now the single choke point in
   renderComment(): HTML comments stripped, mentions zero-width-broken,
   leading block markers removed, newlines collapsed, 300 chars per string,
   8 entries per list, both caps self-marking.

3. Lane was purely model-decided. A well-written feature pitch could talk
   itself into merge-lane. The model now RECOMMENDS; applyMechanicalDowngrades
   forces merge-lane -> needs-maintainer on any of: workflow edits, a new
   package.json dependency, a new src/core/ai/recipes/ provider file, new
   KNOWN_CONFIG_KEYS entries, >40 changed files, >400 net source lines outside
   test/, or a src/ change with no test file touched (#3665). The sticky
   comment reports them under "Mechanical downgrades applied".

4. The test file did not pin what it claimed. Added: no gh pr checkout /
   git fetch / refs/pull / pull/*/head in any spelling; exact permissions
   key->value map plus a single-permissions-block assertion so no job-level
   grant re-widens contents; the ${{ }}-in-run scanner now covers folded
   (`run: >`) and chomped blocks, with a guard-the-guard test; and mocked
   end-to-end runGate() runs for close-lane exit 1, marker hijack, sanitizer,
   truncation, refusal routing, NEUTRAL label clearing, label swap, and the
   spend guard.

5. Version-first title regex rejected the documented suffix form.
   `v0.31.1.1-fixwave fix: ...` now passes. VERSION_AT_END_RE no longer
   false-positives on `chore: bump zod (3.25.76)`: it fires only on a
   v-prefixed or 4-segment trailing version, i.e. this project's own shape.

6. Refusal fail-open. stop_reason=refusal exhausted retries into a green
   NEUTRAL — a deterministic way to dodge the red X. Refusal and
   schema-invalid output now route to needs-maintainer with an explicit
   note; only transport failure stays NEUTRAL. Refusal also short-circuits
   the retry loop, since retrying a deterministic refusal only burns spend.

ALSO

7. persist-credentials: false on the checkout step.
8. Dropped pull-requests:write. Everything the script calls is the issues
   API (comments, label create, label add/remove), so issues:write is the
   only grant that is actually needed.
9. Spend guard for the edited/synchronize amplification: the LLM call is
   skipped when sha256(title+body+head_sha) matches the hash recorded in the
   previous sticky comment's state block, and the stored lane's exit code is
   reused.
10. NEUTRAL runs now clear every gate:* label instead of leaving a stale
    verdict behind.

Verified: bun test test/pr-gate-workflow.test.ts 63 pass / 0 fail,
bun run typecheck clean, actionlint clean, check-privacy /
check-no-tracked-symlinks / check-progress-to-stdout /
check-bun-test-timeout clean, plus a live Anthropic smoke of the exact
request shape (HTTP 200, valid strict JSON, injection attempt rejected).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 08:11:09 +08:00
Garry TanandClaude Fable 5 b6f9bc6c94 feat(ci): strict PR usefulness gate — merge-lane/close-lane verdict before any review effort (#3698)
Runs on pull_request_target against master. PR code is never checked out
or executed: metadata + a 120KB-capped diff come from the GitHub API only;
the base-repo checkout provides the rubric script. All interpolations are
env-bound; actions SHA-pinned; permissions limited to contents:read +
pull-requests:write + issues:write; per-PR concurrency with cancel.

scripts/pr-gate.mjs (no deps, fetch only) classifies via the strict
usefulness rubric (claude-sonnet-5, strict JSON schema, thinking disabled;
sampling params omitted — rejected on this model), posts one sticky
comment (<!-- gbrain-pr-gate -->), applies exactly one gate:* label, and
exits 1 only on close-lane. Missing ANTHROPIC_API_KEY or API failure after
2 retries NEUTRAL-skips loudly (comment + warning, exit 0). Mechanical
(no-LLM) checks: version-first title rule and diff red flags (>40 files,
node_modules, symlinks, workflow edits force needs-maintainer, new
package.json deps, deleted tests).

test/pr-gate-workflow.test.ts pins the security invariants and unit-tests
the exported title rule + red-flag detector (import side-effect guarded).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 07:35:39 +08:00
4 changed files with 3522 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
name: PR Gate
# Strict PR usefulness gate (#3698): classifies every PR to master into
# merge-lane / close-lane / needs-maintainer BEFORE any human review effort.
# Verdict + reviewer checklist land in one sticky comment; exactly one
# gate:* label is applied; close-lane exits 1 (red X = strong signal).
#
# SECURITY MODEL (pull_request_target on a 30k-star public repo):
# - PR code is NEVER checked out or executed. Metadata + diff come from the
# GitHub API only; the diff is capped at 120KB.
# - The checkout below is the BASE repo (master) — rubric/script only.
# NEVER add a `ref:` pointing at the PR head.
# - Attacker-controlled values (title/body/diff) never touch the shell:
# every ${{ }} is env-bound; run: scripts use plain env vars.
# - Only the issues API is used (comments + labels), so issues:write is the
# single write grant; the checkout drops its credentials.
# - The mechanical CONTRIBUTING.md #3745 check (intent paragraph + screenshot)
# runs BEFORE any API dependency, so a PR missing either still lands in
# close-lane during an Anthropic outage — an outage is not a way through.
# - If ANTHROPIC_API_KEY is missing or the API is unreachable on an otherwise
# compliant PR, the script NEUTRAL-skips loudly (sticky comment + warning
# annotation, exit 0) and CLEARS any stale gate:* label — never a silent
# green, never a red X for a missing secret, never a stale verdict. A model
# REFUSAL is not a skip: it routes to needs-maintainer so refusing is not a
# way to dodge the gate.
#
# #3745 EXEMPTION (deliberate — mirrors policyExemption() in
# scripts/pr-gate.mjs): the intent-paragraph + screenshot requirement filters
# INCOMING OUTSIDE CONTRIBUTIONS. It is waived for repo owners / members /
# collaborators, bot authors, and drafts.
# Release automation cannot take a screenshot of itself, and without the
# exemption every /ship release PR lands in close-lane
# (measured: 40 of the last 40 merged PRs) — a check that is red
# on every release gets switched off within a week, and then it filters
# nothing. Exempt PRs still get the FULL usefulness verdict, the title rule and
# every mechanical red flag; only the description requirement is skipped, and
# the sticky comment says so on its own line.
# author_association / draft / user.type are read from the pr.json fetched
# below — GitHub-computed, not author-settable (except `draft`), and already
# on disk, so nothing new is fetched and there is one source of truth.
# `ready_for_review` is in the trigger list precisely because `draft` IS
# author-settable: leaving draft re-runs the gate with the exemption gone, and
# the exemption is folded into the spend-guard hash so the draft-era verdict
# cannot be reused.
# Pinned by test/pr-gate-workflow.test.ts.
on:
pull_request_target:
types: [opened, edited, synchronize, reopened, ready_for_review]
branches: [master]
# issues:write is the ONLY write grant. Everything the script calls is the
# issues API (comments, label create, label add/remove on the PR's issue), so
# pull-requests:write would be a redundant second grant on the same objects.
permissions:
contents: read
issues: write
concurrency:
group: pr-gate-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
gate:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
# Base repo (master) only — provides scripts/pr-gate.mjs.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
# Nothing here needs git auth after the clone; don't leave a token
# in .git/config for the rest of the job.
persist-credentials: false
- name: Fetch PR metadata + diff (API only — PR code is never checked out)
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/pr-gate"
gh api "repos/${REPO}/pulls/${PR_NUMBER}" > "$RUNNER_TEMP/pr-gate/pr.json"
# First 100 files is enough: red flags key off pr.json's changed_files
# count, and >40 files already flags.
gh api "repos/${REPO}/pulls/${PR_NUMBER}/files?per_page=100" \
> "$RUNNER_TEMP/pr-gate/files.json"
# Diff via the .diff media type; GitHub can 406 on huge diffs —
# degrade to a marker instead of failing the gate.
gh api "repos/${REPO}/pulls/${PR_NUMBER}" \
-H "Accept: application/vnd.github.diff" \
> "$RUNNER_TEMP/pr-gate/pr.diff.full" \
|| printf '[diff unavailable from the GitHub API — too large or unfetchable]\n' \
> "$RUNNER_TEMP/pr-gate/pr.diff.full"
MAX=122880 # 120KB cap
if [ "$(wc -c < "$RUNNER_TEMP/pr-gate/pr.diff.full")" -gt "$MAX" ]; then
head -c "$MAX" "$RUNNER_TEMP/pr-gate/pr.diff.full" > "$RUNNER_TEMP/pr-gate/pr.diff"
printf '\n\n[TRUNCATED: diff capped at 120KB]\n' >> "$RUNNER_TEMP/pr-gate/pr.diff"
else
mv "$RUNNER_TEMP/pr-gate/pr.diff.full" "$RUNNER_TEMP/pr-gate/pr.diff"
fi
rm -f "$RUNNER_TEMP/pr-gate/pr.diff.full"
- name: Gate verdict (sticky comment + label; exit 1 only on close-lane)
env:
GITHUB_TOKEN: ${{ github.token }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: node scripts/pr-gate.mjs "$RUNNER_TEMP/pr-gate"
+94
View File
@@ -0,0 +1,94 @@
/** Type surface of scripts/pr-gate.mjs for test/pr-gate-workflow.test.ts (tsc-only). */
export interface TitleCheck {
ok: boolean;
reason?: string;
}
export declare function checkTitle(title: string): TitleCheck;
export interface ChangedFile {
filename: string;
status: string;
patch?: string;
additions?: number;
deletions?: number;
}
export interface RedFlag {
id: string;
detail: string;
}
export declare function detectRedFlags(input: {
changedFiles: number;
files: ChangedFile[];
diff: string;
}): RedFlag[];
export declare const RUBRIC: string;
export declare const MAX_STRING: number;
export declare const MAX_ITEMS: number;
export declare const NET_SOURCE_LINE_LIMIT: number;
export declare const DOWNGRADE_FLAG_IDS: string[];
/** CONTRIBUTING.md #3745: human-written intent paragraph + screenshot of gbrain in use. */
export declare const CONTRIBUTING_URL: string;
export declare const INTENT_MIN_WORDS: number;
export declare const POLICY_FLAG_IDS: string[];
export declare const POLICY_SCAN_MAX: number;
export declare const POLICY_EXEMPT_ASSOCIATIONS: string[];
export declare const AI_INTENT_DOWNGRADE: string;
export declare function stripCodeFences(body: unknown): string;
export declare const MODEL_BODY_MAX: number;
export declare function modelBody(pr: { body?: string } | null | undefined): string;
export declare function hasScreenshot(body: unknown): boolean;
export declare function intentWordCount(body: unknown): number;
export declare function hasIntentParagraph(body: unknown): boolean;
export declare function detectPolicyMisses(body: unknown): RedFlag[];
export declare function sanitizeModelText(value: unknown, max?: number): string;
export declare function sanitizeList(value: unknown, maxItems?: number, maxString?: number): string[];
export declare function applyMechanicalDowngrades(
lane: string,
flags: RedFlag[],
intentAuthenticity?: string,
): { lane: string; downgrades: string[] };
/** The pr.json fields the #3745 exemption reads (all GitHub-computed). */
export interface PrIdentity {
author_association?: string;
draft?: boolean;
user?: { type?: string; login?: string };
}
export declare function policyExemption(pr: PrIdentity | null | undefined): string | null;
export interface GhComment {
id?: number;
body?: unknown;
user?: { type?: string; login?: string };
}
export declare function isOwnComment(comment: GhComment | null | undefined): boolean;
export declare function hashInputs(
pr: PrIdentity & { title?: string; body?: string; head?: { sha?: string } },
/** The assembled model payload (changed files + diff). runGate always passes it. */
payload?: string,
): string;
export declare function parseState(body: unknown): { hash: string; lane?: string } | null;
export declare function renderComment(input: {
lane?: string;
verdict?: { confidence?: number; reasons?: unknown; reviewer_checklist?: unknown };
titleCheck: TitleCheck;
flags: RedFlag[];
neutralReason?: string;
downgrades?: string[];
policyMisses?: RedFlag[];
policyExempt?: string | null;
labelsCleared?: boolean;
state?: { hash: string; lane: string };
}): string;
export declare function runGate(
dir: string,
env?: Record<string, string | undefined>,
fetchImpl?: typeof fetch,
): Promise<number>;
+1190
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff