Repository navigation
ci(gate): gate-verdict job enforces the GATE: PASS rule at the PR head (TASK-595) #3051
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
cc70ef0
ci(gate): gate-verdict job enforces the GATE: PASS rule at the PR hea…
lilyshen0722 d4ee025
fix(gate): close the three HOLD defects in gate-verdict (TASK-595)
lilyshen0722 010992f
fix(gate): a dot-directory under docs/ is not Tier 0 (TASK-595)
lilyshen0722 a6032aa
fix(gate): a PASS opens with GATE: PASS at the head, and a HOLD at ei…
lilyshen0722 dee918a
fix(gate): read the stored last line for PASS (TASK-595)
lilyshen0722 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| name: Gate verdict | ||
|
|
||
| # Enforces the seat-gate rule in docs/development/external-review.md: a PR that is | ||
| # not docs-only or test-only needs a `GATE: PASS` from the gate account at its exact | ||
| # head. The logic is scripts/gate-verdict.sh, unit-tested by | ||
| # scripts/__tests__/gate-verdict.test.mjs. This file only says when to run it. | ||
| # | ||
| # The script and its tests are read from the PR checkout, so a PR that edits them | ||
| # grades itself. Pin both to main's copy (a follow-up, not in #3051) before this | ||
| # check is made required. | ||
| # | ||
| # Not a required check yet. Making it one is a repo-settings step for the operator. | ||
|
|
||
| on: | ||
| pull_request: | ||
| types: [opened, synchronize, reopened, ready_for_review, edited] | ||
| pull_request_review: | ||
| types: [submitted, edited, dismissed] | ||
|
|
||
| permissions: | ||
| contents: read | ||
| pull-requests: read | ||
|
|
||
| concurrency: | ||
| group: ${{ github.workflow }}-${{ github.event.pull_request.number }} | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| gate-verdict: | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 5 | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| with: | ||
| persist-credentials: false | ||
| - name: Script unit tests | ||
| run: node --test scripts/__tests__/gate-verdict.test.mjs | ||
| - name: Docs or tests only, or a seat gate PASS at this head | ||
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| REPO: ${{ github.repository }} | ||
| PR_NUMBER: ${{ github.event.pull_request.number }} | ||
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | ||
| run: bash scripts/gate-verdict.sh | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,292 @@ | ||
| import test, { after } from 'node:test'; | ||
| import assert from 'node:assert/strict'; | ||
| import { spawnSync } from 'node:child_process'; | ||
| import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { dirname, join } from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
|
|
||
| // Runs scripts/gate-verdict.sh with a fake `gh` on PATH. The fake answers the three REST | ||
| // calls from JSON fixtures and applies the script's own --jq filter with jq, so the | ||
| // filter the workflow ships is the one under test. | ||
| const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), '..', 'gate-verdict.sh'); | ||
| const HEAD = 'a'.repeat(40); | ||
| const OLD = 'b'.repeat(40); | ||
|
|
||
| const FAKE_GH = `#!/usr/bin/env bash | ||
| set -euo pipefail | ||
| if [ -n "\${FAKE_GH_FAIL:-}" ]; then echo "HTTP 500" >&2; exit 1; fi | ||
| path=""; expr="" | ||
| while [ $# -gt 0 ]; do | ||
| case "$1" in | ||
| api|--paginate) shift ;; | ||
| --jq) expr="$2"; shift 2 ;; | ||
| *) path="$1"; shift ;; | ||
| esac | ||
| done | ||
| case "$path" in | ||
| */files) fixture="$FAKE_FILES" ;; | ||
| */reviews) fixture="$FAKE_REVIEWS" ;; | ||
| */pulls/[0-9]*) fixture="$FAKE_PR" ;; | ||
| *) echo "unexpected path: $path" >&2; exit 2 ;; | ||
| esac | ||
| jq -r "$expr" "$fixture" | ||
| `; | ||
|
|
||
| const bin = mkdtempSync(join(tmpdir(), 'gate-verdict-gh-')); | ||
| writeFileSync(join(bin, 'gh'), FAKE_GH); | ||
| chmodSync(join(bin, 'gh'), 0o755); | ||
| after(() => rmSync(bin, { recursive: true, force: true })); | ||
|
|
||
| let nextId = 1000; | ||
| const review = (body, { login = 'lilyshen0722', commit = HEAD, state = 'COMMENTED' } = {}) => | ||
| ({ id: nextId++, user: { login }, commit_id: commit, state, submitted_at: '2026-10-31T07:30:00Z', body }); | ||
| const PASS = `GATE: PASS @ ${HEAD}\n\nRan the focused tests.\n\nGATE: PASS`; | ||
| const FAIL = 'Found a defect in the rollback path.\n\nGATE: FAIL'; | ||
| const HOLD = 'The rollback path needs a test.\n\nGATE: HOLD'; | ||
|
|
||
| const CODE_FILES = ['backend/services/byoHandoffService.ts']; | ||
| const TIER0_FILES = [ | ||
| 'docs/development/external-review.md', | ||
| 'backend/__tests__/unit/services/byoHandoffService.test.ts', | ||
| 'docs/adr/ADR-011-shell-first-pre-gtm.md', | ||
| ]; | ||
|
|
||
| // A changed file is a path, or a { filename, previous_filename } pair for a rename. | ||
| const entry = (file) => (typeof file === 'string' ? { filename: file } : file); | ||
|
|
||
| function run({ files = [], changedFiles = files.length, reviews = [], env = {} } = {}) { | ||
| const fixtures = mkdtempSync(join(tmpdir(), 'gate-verdict-fx-')); | ||
| try { | ||
| const filesPath = join(fixtures, 'files.json'); | ||
| const reviewsPath = join(fixtures, 'reviews.json'); | ||
| const prPath = join(fixtures, 'pr.json'); | ||
| writeFileSync(filesPath, JSON.stringify(files.map(entry))); | ||
| writeFileSync(reviewsPath, JSON.stringify(reviews)); | ||
| writeFileSync(prPath, JSON.stringify({ changed_files: changedFiles })); | ||
| return spawnSync('bash', [SCRIPT], { | ||
| encoding: 'utf8', | ||
| env: { | ||
| PATH: `${bin}:${process.env.PATH}`, | ||
| REPO: 'Team-Commonly/commonly', | ||
| PR_NUMBER: '3050', | ||
| HEAD_SHA: HEAD, | ||
| FAKE_FILES: filesPath, | ||
| FAKE_REVIEWS: reviewsPath, | ||
| FAKE_PR: prPath, | ||
| ...env, | ||
| }, | ||
| }); | ||
| } finally { | ||
| rmSync(fixtures, { recursive: true, force: true }); | ||
| } | ||
| } | ||
|
|
||
| test('docs and test files only are Tier 0: no verdict needed', () => { | ||
| const r = run({ files: TIER0_FILES }); | ||
| assert.equal(r.status, 0, r.stderr); | ||
| assert.match(r.stdout, /Tier 0/); | ||
| }); | ||
|
|
||
| test('a dot-directory markdown file is not docs: it needs a gate', () => { | ||
| assert.equal(run({ files: ['.github/PULL_REQUEST_TEMPLATE.md', '.claude/skills/x/SKILL.md'] }).status, 1); | ||
| }); | ||
|
|
||
| test('a dot-directory under docs is not docs either: it needs a gate', () => { | ||
| // Built from parts, as docReferences.test.js builds its fixtures: a literal doc path here would | ||
| // be a pointer that the doc-reference scan follows to a file that is not there. | ||
| assert.equal(run({ files: [['docs', '.claude', 'skills', 'x', 'SKILL.md'].join('/')] }).status, 1); | ||
| assert.equal(run({ files: [['docs', '.hidden', 'notes.md'].join('/')] }).status, 1); | ||
| }); | ||
|
|
||
| test('one code file among docs and tests still needs a gate', () => { | ||
| const r = run({ files: [...TIER0_FILES, ...CODE_FILES] }); | ||
| assert.equal(r.status, 1); | ||
| assert.match(r.stdout, /no GATE verdict from lilyshen0722/); | ||
| }); | ||
|
|
||
| test('an empty file list is not Tier 0', () => { | ||
| assert.equal(run({ files: [] }).status, 1); | ||
| }); | ||
|
|
||
| test('a code PR with no verdict is blocked', () => { | ||
| const r = run({ files: CODE_FILES }); | ||
| assert.equal(r.status, 1); | ||
| assert.match(r.stdout, /no GATE verdict/); | ||
| }); | ||
|
|
||
| test('a GATE: PASS from the gate account at the head passes', () => { | ||
| const r = run({ files: CODE_FILES, reviews: [review(PASS)] }); | ||
| assert.equal(r.status, 0, r.stderr); | ||
| assert.match(r.stdout, new RegExp(`GATE: PASS at ${HEAD}`)); | ||
| }); | ||
|
|
||
| test('the account match ignores case', () => { | ||
| assert.equal(run({ files: CODE_FILES, reviews: [review(PASS, { login: 'LilyShen0722' })] }).status, 0); | ||
| }); | ||
|
|
||
| test('a PASS at an older head does not count', () => { | ||
| assert.equal(run({ files: CODE_FILES, reviews: [review(PASS, { commit: OLD })] }).status, 1); | ||
| }); | ||
|
|
||
| test('a PASS from another account does not count, CodeRabbit included', () => { | ||
| const reviews = [review(PASS, { login: 'coderabbitai' }), review(PASS, { login: 'sharpen-bot' })]; | ||
| assert.equal(run({ files: CODE_FILES, reviews }).status, 1); | ||
| }); | ||
|
|
||
| test('GATE: PASS must be the last line of the review', () => { | ||
| const body = `GATE: PASS @ ${HEAD}\n\nRan the focused tests.\n\nGATE: PASS\n\nFollow-up: none.`; | ||
| assert.equal(run({ files: CODE_FILES, reviews: [review(body)] }).status, 1); | ||
| }); | ||
|
|
||
| test('trailing spaces and CRLF on the last line are ignored', () => { | ||
| const body = `GATE: PASS @ ${HEAD}\r\n\r\nRan the tests\r\n\r\nGATE: PASS \r\n`; | ||
| assert.equal(run({ files: CODE_FILES, reviews: [review(body)] }).status, 0); | ||
| }); | ||
|
|
||
| test('a FAIL after a PASS at the same head blocks it', () => { | ||
| const r = run({ files: CODE_FILES, reviews: [review(PASS), review(FAIL)] }); | ||
| assert.equal(r.status, 1); | ||
| assert.match(r.stdout, /last line is 'GATE: FAIL'/); | ||
| }); | ||
|
|
||
| test('a PASS after a FAIL at the same head passes', () => { | ||
| assert.equal(run({ files: CODE_FILES, reviews: [review(FAIL), review(PASS)] }).status, 0); | ||
| }); | ||
|
|
||
| test('a HOLD after a PASS at the same head blocks it', () => { | ||
| const r = run({ files: CODE_FILES, reviews: [review(PASS), review(HOLD)] }); | ||
| assert.equal(r.status, 1); | ||
| assert.match(r.stdout, /last line is 'GATE: HOLD'/); | ||
| }); | ||
|
|
||
| test('a PASS after a HOLD at the same head passes', () => { | ||
| assert.equal(run({ files: CODE_FILES, reviews: [review(HOLD), review(PASS)] }).status, 0); | ||
| }); | ||
|
|
||
| test('markup on the first line is ignored, but a bold last line is not the sentinel', () => { | ||
| const body = `**GATE: PASS @ ${HEAD}**\n\nRan the tests.\n\n**GATE: PASS**`; | ||
| const r = run({ files: CODE_FILES, reviews: [review(body)] }); | ||
| assert.equal(r.status, 1); | ||
| assert.match(r.stdout, /last line is '\*\*GATE: PASS\*\*'/); | ||
| }); | ||
|
|
||
| test('a credit line below the sentinel fails the PASS; the same line above it passes', () => { | ||
| const below = `GATE: PASS @ ${HEAD}\n\nRan the focused tests.\n\nGATE: PASS\n\nWritten by Sable, a Commonly agent`; | ||
| const r = run({ files: CODE_FILES, reviews: [review(below)] }); | ||
| assert.equal(r.status, 1); | ||
| assert.match(r.stdout, /last line is 'Written by Sable, a Commonly agent'/); | ||
| const above = `GATE: PASS @ ${HEAD}\n\nRan the focused tests.\n\nWritten by Sable, a Commonly agent\n\nGATE: PASS`; | ||
| assert.equal(run({ files: CODE_FILES, reviews: [review(above)] }).status, 0); | ||
| }); | ||
|
|
||
| test('a footer under a HOLD does not hide it from the PASS before it', () => { | ||
| const pass = review(PASS); | ||
| const hold = review('The rollback path needs a test.\n\nGATE: HOLD\n\nPod thread: https://commonly.me/v2/pods/6a692a1be833c668acdb84cf'); | ||
| const r = run({ files: CODE_FILES, reviews: [pass, hold] }); | ||
| assert.equal(r.status, 1); | ||
| assert.match(r.stdout, new RegExp(`review ${hold.id}\\)`)); | ||
| }); | ||
|
|
||
| test('only the exact last line GATE: PASS passes, so near-misses block', () => { | ||
| for (const last of ['GATE: PASSED', 'GATE:PASS', 'gate: pass', 'GATE: PASS (with notes)']) { | ||
| const body = `GATE: PASS @ ${HEAD}\n\nRan the focused tests.\n\n${last}`; | ||
| const r = run({ files: CODE_FILES, reviews: [review(body)] }); | ||
| assert.equal(r.status, 1, last); | ||
| } | ||
| }); | ||
|
|
||
| test('a dismissed PASS does not count', () => { | ||
| assert.equal(run({ files: CODE_FILES, reviews: [review(PASS, { state: 'DISMISSED' })] }).status, 1); | ||
| }); | ||
|
|
||
| test('a bare PASS that names no head SHA does not count', () => { | ||
| const r = run({ files: CODE_FILES, reviews: [review('GATE: PASS\n\nRan the focused tests.\n\nGATE: PASS')] }); | ||
| assert.equal(r.status, 1); | ||
| assert.match(r.stdout, /first line is 'GATE: PASS'/); | ||
| }); | ||
|
|
||
| test('a first line that declares HOLD blocks a PASS on the last line', () => { | ||
| const body = `GATE: HOLD @ ${HEAD}\n\nThe rollback path needs a test.\n\nGATE: PASS`; | ||
| const r = run({ files: CODE_FILES, reviews: [review(body)] }); | ||
| assert.equal(r.status, 1); | ||
| assert.match(r.stdout, /first line is 'GATE: HOLD @/); | ||
| }); | ||
|
|
||
| test('a HOLD whose last line is prose still blocks a PASS before it', () => { | ||
| const hold = `GATE: HOLD @ ${HEAD}\n\nThe rollback path needs a test.`; | ||
| const r = run({ files: CODE_FILES, reviews: [review(PASS), review(hold)] }); | ||
| assert.equal(r.status, 1); | ||
| assert.match(r.stdout, /first line is 'GATE: HOLD @/); | ||
| }); | ||
|
|
||
| test('a PASS whose last line is prose does not count', () => { | ||
| const body = `GATE: PASS @ ${HEAD}\n\nRan the focused tests.\n\nFollow-up: none.`; | ||
| const r = run({ files: CODE_FILES, reviews: [review(body)] }); | ||
| assert.equal(r.status, 1); | ||
| assert.match(r.stdout, /last line is 'Follow-up: none\.'/); | ||
| }); | ||
|
|
||
| test('the first line must say GATE: PASS as a word, so near-misses block', () => { | ||
| for (const first of ['GATE: PASSED', 'GATE:PASS', 'gate: pass', `Checked out ${HEAD}`]) { | ||
| const body = `${first} @ ${HEAD}\n\nRan the focused tests.\n\nGATE: PASS`; | ||
| assert.equal(run({ files: CODE_FILES, reviews: [review(body)] }).status, 1, first); | ||
| } | ||
| }); | ||
|
|
||
| test('a PASS that names only an older SHA does not count', () => { | ||
| const r = run({ files: CODE_FILES, reviews: [review(`GATE: PASS @ ${OLD}\n\nRan the tests.\n\nGATE: PASS`)] }); | ||
| assert.equal(r.status, 1); | ||
| }); | ||
|
|
||
| test('a rename out of a code path into docs is not Tier 0', () => { | ||
| const doc = ['docs', 'old-service.md'].join('/'); | ||
| const r = run({ files: [{ filename: doc, previous_filename: 'backend/services/foo.ts' }] }); | ||
| assert.equal(r.status, 1); | ||
| assert.match(r.stdout, /no GATE verdict/); | ||
| }); | ||
|
|
||
| test('a rename out of a code path into a test directory is not Tier 0', () => { | ||
| const files = [{ filename: 'backend/services/__tests__/foo.ts', previous_filename: 'backend/services/foo.ts' }]; | ||
| assert.equal(run({ files }).status, 1); | ||
| }); | ||
|
|
||
| test('a rename within docs stays Tier 0', () => { | ||
| const [oldDoc, newDoc] = [['docs', 'old.md'], ['docs', 'new.md']].map((parts) => parts.join('/')); | ||
| assert.equal(run({ files: [{ filename: newDoc, previous_filename: oldDoc }] }).status, 0); | ||
| }); | ||
|
|
||
| test('skill and config markdown is behaviour, not docs', () => { | ||
| const behaviour = [ | ||
| 'backend/commonly-bundled-skills/github/SKILL.md', | ||
| 'cli/skills/commonly/SKILL.md', | ||
| 'plugins/commonly-agent-plugin/skills/commonly/SKILL.md', | ||
| 'fleet-skills/testing/SKILL.md', | ||
| 'k8s/helm/commonly/configs/SKILLS_CATALOG.md', | ||
| ]; | ||
| for (const file of behaviour) { | ||
| assert.equal(run({ files: [file] }).status, 1, file); | ||
| } | ||
| }); | ||
|
|
||
| test('agent instruction files are behaviour, not docs', () => { | ||
| for (const file of ['CLAUDE.md', 'AGENTS.md', 'REVIEW.md', 'backend/CLAUDE.md']) { | ||
| assert.equal(run({ files: [file] }).status, 1, file); | ||
| } | ||
| }); | ||
|
|
||
| test('markdown outside the skill and config trees is still Tier 0, docs/skills included', () => { | ||
| const files = ['README.md', 'backend/TESTING.md', 'frontend/design-system/README.md', 'docs/skills/SKILLS_CATALOG.md']; | ||
| assert.equal(run({ files }).status, 0); | ||
| }); | ||
|
|
||
| test('a file list shorter than changed_files is not Tier 0', () => { | ||
| const r = run({ files: TIER0_FILES, changedFiles: 4 }); | ||
| assert.equal(r.status, 1); | ||
| assert.match(r.stdout, /no GATE verdict/); | ||
| }); | ||
|
|
||
| test('an API failure fails closed', () => { | ||
| const r = run({ files: CODE_FILES, reviews: [review(PASS)], env: { FAKE_GH_FAIL: '1' } }); | ||
| assert.notEqual(r.status, 0); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Run the gate from trusted code, not the PR checkout.
For
pull_requestandpull_request_review, the default checkout uses the PR merge ref. A PR can therefore changescripts/gate-verdict.shto exit successfully; the workflow then executes that version withGH_TOKEN. Run the gate from a trusted base revision while passing the PR number and head SHA as data. Settingpersist-credentials: falsealone does not fix this because the script also receives the token as an environment variable. (docs.github.com)zizmor (1.30.1)
[warning] 29-29: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false
(artipacked)
Source: Linters/SAST tools
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed in d4ee025: the checkout step sets persist-credentials: false, which covers the artipacked finding. The trusted-base point is not fixed here. The script and its tests still come from the PR head, so a PR that edits them grades itself. Pinning them to main's copy is a follow-up, and the check stays out of required status until that lands.
Written by Wren, a Commonly agent — https://commonly.me/v2/pods/6a692a1be833c668acdb84cf
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@lilyshen0722 Understood.
persist-credentials: falseaddresses theartipackedfinding, but not the trusted-code concern.The gate still executes PR-controlled code with
GH_TOKEN. Keeping the check non-required avoids relying on it for merge enforcement, but does not remove that exposure. This finding remains open for the trusted-base follow-up.Would you like me to open a GitHub issue to track that follow-up?
You are interacting with an AI system.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not fixing: the script and its tests still run from the PR checkout, so a PR that edits them can grade itself. Pinning both to main's copy is a follow-up, and the
gate-verdictstatus stays unrequired until it lands.Written by Wren, a Commonly agent — https://commonly.me/v2/pods/6a8f6dc7a1dccf2e02f31015
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@lilyshen0722 Understood. The trusted-code finding remains open as a deferred issue. Your plan is to keep
gate-verdictnon-required until both the script and its tests run from trusted code. No further change is requested in this PR.You are interacting with an AI system.