fix(ci): make the vouch check speak, unblock list updates, widen the docs exemption (#6974)

This commit is contained in:
Kartik
2026-08-20 23:13:42 +05:30
committed by GitHub
parent 3599aa75ed
commit 4fa4839077
10 changed files with 474 additions and 24 deletions
+54 -5
View File
@@ -24,9 +24,12 @@ Package workflows keep their own push-to-main and manual triggers. Their `pull_r
| n8n Node | `n8n-nodes-mem0-checks.yml` | Push to main (`integrations/n8n-nodes-mem0/`), manual | ESLint + tsc build on Node 20 | | n8n Node | `n8n-nodes-mem0-checks.yml` | Push to main (`integrations/n8n-nodes-mem0/`), manual | ESLint + tsc build on Node 20 |
| Zapier App | `zapier-mem0-checks.yml` | Push to main (`integrations/zapier-mem0/`), manual | tsc + `zapier validate` + offline unit tests on Node 22 | | Zapier App | `zapier-mem0-checks.yml` | Push to main (`integrations/zapier-mem0/`), manual | tsc + `zapier validate` + offline unit tests on Node 22 |
| docs llms.txt | `docs-llms-txt-check.yml` | Manual | `docs/llms.txt` coverage | | docs llms.txt | `docs-llms-txt-check.yml` | Manual | `docs/llms.txt` coverage |
| GitHub Scripts | inline in `ci-gate.yml` | none | `node` over every `.github/scripts/*.test.js` |
Adding a package CI workflow: give it `workflow_call` plus `push` / `workflow_dispatch` as needed but **no `pull_request` trigger**, then register it in `ci-gate.yml` with a path filter under the `changes` job, a call job, and an entry in the gate job's `needs` list. Adding a package CI workflow: give it `workflow_call` plus `push` / `workflow_dispatch` as needed but **no `pull_request` trigger**, then register it in `ci-gate.yml` with a path filter under the `changes` job, a call job, and an entry in the gate job's `needs` list.
`GitHub Scripts` is the one row that is a plain job inside `ci-gate.yml` rather than a called workflow, because a reusable workflow wrapping two `node` invocations would be more file than test. It runs on the `github_scripts` filter, which covers `.github/scripts/**` plus every file those tests read: `pr-gate.yml`, `vouch-check-pr.yml`, `issue-labeler.yml`, and `VOUCHED.td`. Add a new `.github/scripts/*.test.js` and it is picked up with no wiring; make a test read a new file and that file belongs in the filter.
## Branch protection on `main` ## Branch protection on `main`
A repository ruleset named `Main Branch Rule`, id `11813754`. It enforces squash-only merges, linear history, no deletion, no force-push, and one approving review. Two status checks belong in its `required_status_checks` rule: A repository ruleset named `Main Branch Rule`, id `11813754`. It enforces squash-only merges, linear history, no deletion, no force-push, and one approving review. Two status checks belong in its `required_status_checks` rule:
@@ -69,9 +72,9 @@ Requiring `CI Gate` also means fork PRs from first-time contributors cannot merg
| Workflow | File | Purpose | | Workflow | File | Purpose |
|----------|------|---------| |----------|------|---------|
| PR Gate | `pr-gate.yml` | Closes PRs that do not link an issue labeled `accepted`, with a reopen path. Exempts members, bots, drafts, and docs-only changes. Never checks out PR code. | | PR Gate | `pr-gate.yml` | Closes PRs that do not link an issue labeled `accepted`, and reopens them when that label arrives. Exempts members, bots, drafts, and docs-only changes. Never checks out PR code. |
| Vouch (check PR) | `vouch-check-pr.yml` | Comments on PRs from authors absent from `VOUCHED.td`. Comment-only mode (`auto-close: false`). | | Vouch (check PR) | `vouch-check-pr.yml` | Closes PRs from authors denounced in `VOUCHED.td`. Comments once on PRs from authors merely absent from it, and blocks nothing in that case. |
| Vouch (manage list) | `vouch-manage-by-issue.yml` | Maintainers edit the trust list by commenting `!vouch @user`, `!denounce @user`, or `!unvouch @user` on any issue. Commits back to `VOUCHED.td` through a GitHub App token. | | Vouch (manage list) | `vouch-manage-by-issue.yml` | Maintainers edit the trust list by commenting `!vouch @user`, `!denounce @user`, or `!unvouch @user` on any issue. Opens a PR against `VOUCHED.td` through a GitHub App token, for a maintainer to merge. |
| Issue Labeler | `issue-labeler.yml` | Labels issues from the `component` field in the issue forms | | Issue Labeler | `issue-labeler.yml` | Labels issues from the `component` field in the issue forms |
| PR Labeler | `pr-labeler.yml` | Path-based labels, plus propagating labels from linked issues | | PR Labeler | `pr-labeler.yml` | Path-based labels, plus propagating labels from linked issues |
| Stale Bot | `stale.yml` | Marks stale issues and PRs | | Stale Bot | `stale.yml` | Marks stale issues and PRs |
@@ -79,7 +82,53 @@ Requiring `CI Gate` also means fork PRs from first-time contributors cannot merg
`pr-gate.yml` and `vouch-check-pr.yml` use `pull_request_target`, which is required to label and close fork PRs. Neither checks out PR code and neither has a `run:` step, so there is no pwn-request or script-injection surface. Keep it that way: any future `run:` step in these files must never interpolate `github.event.*` text. `pr-gate.yml` and `vouch-check-pr.yml` use `pull_request_target`, which is required to label and close fork PRs. Neither checks out PR code and neither has a `run:` step, so there is no pwn-request or script-injection surface. Keep it that way: any future `run:` step in these files must never interpolate `github.event.*` text.
`GATE_EFFECTIVE_FROM` in `pr-gate.yml` is a `created_at` cutoff. `edited`, `reopened`, and `ready_for_review` fire on PRs opened long before the gate existed, so without the cutoff the whole open backlog would be closed by a rule that did not exist when those PRs were filed. Set it to the actual merge date in UTC. Both workflows exempt maintainers twice, and the second guard is the one that holds. `author_association` is rendered for the viewer, and a webhook payload has no privileged viewer: `MEMBER` needs the author's org membership to be **public**, `COLLABORATOR` needs a **direct** repository invite. An org member with private membership whose `maintain` comes through a team matches neither and arrives as `CONTRIBUTOR`, which is how PR #6948 was closed by its own author's gate. So the guard also skips any PR whose head branch lives in this repository (`head.repo.full_name == github.repository`). Pushing a branch here already requires write access and outside contributors always arrive from a fork, so that test means the same thing without depending on who is looking. Keep both: the `author_association` arm still covers members who work from their own fork.
`pr-gate.yml` carries two jobs whose `if:` conditions are deliberately disjoint. `gate` closes, and only ever runs on `opened`, `reopened`, and `ready_for_review`. `reopen` reopens, and only ever runs on `edited` or on `issues: labeled` with the `accepted` label. Nothing can both close and reopen on the same event, which is the property to preserve when editing either guard.
That split exists because the two halves of a gated PR's recovery arrive in either order. A maintainer usually labels the issue `accepted` at triage, before the author has linked it; sometimes the link lands first and the label follows. So `reopen` handles both directions. From `issues: labeled` it walks `closedByPullRequestsReferences` back to the pull requests that link the issue. From `edited` it takes the edited pull request directly. Both paths then apply the same four tests: the author is not denounced in `VOUCHED.td`, the PR is `CLOSED`, it links an issue labeled `accepted`, and it carries the `<!-- pr-gate -->` marker comment. Without the label path, a maintainer's label is inert. Without the `edited` path, an author who links the issue after it was labeled is stuck, since no other event fires.
The denounce test is what keeps the two gates from cancelling each other out. A denounced author whose PR also lacked an accepted issue was closed by both workflows, so it carries the `<!-- pr-gate -->` marker, and labeling the linked issue would otherwise reopen it. Vouch cannot undo that: reopening runs through `GITHUB_TOKEN`, which raises no events, so `vouch-check-pr.yml` never fires a second time. Reading the list here is the only place the check can live. It fails open like vouch does, warning and treating nobody as denounced if the file cannot be read, and it is the one piece of vouch semantics duplicated outside `vouch-check-pr.yml`, because `pr-gate.yml` never checks out the repository and so cannot import a shared parser. `.github/scripts/vouch-decision.test.js` covers the parsing and asserts `pr-gate.yml` still filters the list the same way.
`edited` must never reach the `gate` job. It fires on any title or description change, so when `gate` listened for it the gate re-judged pull requests that had been open for days and closed them the moment their author touched the description, which is what closed #6948. Rescuing on `edited` is safe for the same reason closing on it was not: the job can only move a PR from closed to open.
Reopening runs through `GITHUB_TOKEN`, which by design raises no further workflow events, so `gate` cannot bounce a freshly reopened PR straight back out.
The concurrency group is keyed on `github.event.action` as well as `github.event_name` and the number, and both keys carry weight. Without the event name, a maintainer applying `bug` right after `accepted` cancels the reopen mid-flight, since `cancel-in-progress` is on for `pull_request_target` and both label events would land in the same group. Without the action, `opened` and `edited` share a group on the same pull request, and an author who ticks a template checkbox in the seconds after opening cancels the run that was about to gate them: `gate` skips `edited` and `reopen` skips an open pull request, so the cancelled run is never replaced and the pull request stays ungated forever, since `opened` fires exactly once. Rapid successive edits still cancel each other, which is the dedup that was wanted.
The `edited` arm of `reopen` requires `github.event.pull_request.state == 'closed'`, so ordinary description edits on open pull requests do not start a runner.
Two known gaps, both mild. A PR that the gate closed, that someone reopened, and that a maintainer then closed deliberately still carries the marker, so labeling its issue reopens it again; a maintainer closes it once more. And an author who strips `Closes #<number>` out after passing keeps an open PR, which a reviewer sees anyway.
`GATE_EFFECTIVE_FROM` in `pr-gate.yml` is a `created_at` cutoff. `reopened` and `ready_for_review` still fire on PRs opened long before the gate existed, so without the cutoff part of the open backlog would be closed by a rule that did not exist when those PRs were filed. Set it to the actual merge date in UTC.
The gate's docs-only exemption covers `docs/` plus a named allowlist of four root files: `README.md`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, and `SECURITY.md`. It is an allowlist rather than a rule about top-level markdown because the repository root also holds `AGENTS.md`, `CLAUDE.md`, and `LLM.md`, which are the instructions coding agents read before touching this codebase. Those are functional files that happen to be written in prose, and rewriting them is a change to behaviour, so they stay gated. Markdown nested anywhere else stays gated for the same reason: `skills/**/*.md` and everything under `.github/` are functional too. Adding a genuinely prose root file means adding it to `rootDocs` in `pr-gate.yml`.
`.github/scripts/pr-gate-docs-exemption.test.js` covers that predicate. It pulls the `rootDocs` and `isDocs` lines out of `pr-gate.yml` and evaluates them, so it exercises the shipped rule rather than a copy that could drift from it, and it pins `AGENTS.md`, `CLAUDE.md`, and `LLM.md` on the gated side along with `skills/**/*.md`, nested `.github/` files, and the empty file list. It only accepts those two declarations in a literal one-line form, so keep `rootDocs` a `Set` of quoted names and `isDocs` a single arrow expression.
The two contribution gates answer different questions and neither covers for the other. `pr-gate.yml` judges the change, and the `accepted` label is how a maintainer says yes to it. `vouch-check-pr.yml` judges the author, and `VOUCHED.td` is how a maintainer says no to one. A vouched author with no accepted issue is still closed by the gate; a denounced author with an accepted issue is still closed by vouch. Read either one as a backstop for the other and both get weakened.
Vouch enforces on the denounce axis only, through `require-vouch: false` with `auto-close: true`. That pair is not the obvious reading of either input, so the decision table from v1.5.0 (`vouch/github.nu` at pinned SHA `d66fa29`) is worth stating outright:
| Author | `status` | Effect |
|---|---|---|
| ends in `[bot]` | `skipped` | nothing |
| collaborator with write or admin | `vouched` | nothing |
| listed in `VOUCHED.td` | `vouched` | nothing |
| listed as `-handle` | `closed` | action comments and closes |
| absent from the file | `allowed` | workflow comments, nothing closed |
`require-vouch: true` would close every first-time contributor, which is the opposite of what a trust list is for: the funnel has to stay open or nobody ever earns a vouch. `auto-close: false` is the setting that looked safe and did nothing at all, since in v1.5.0 both the unvouched and the denounced branch return before posting anything, leaving only a line in the run log. That is why `!denounce` was decorative until this pair landed.
Only the `allowed` arm is ours: a `github-script` step posts the soft comment, keyed on a `<!-- vouch-check -->` marker so a reopen does not comment twice. The `closed` arm belongs to the action, message and all. Keeping the two arms disjoint is what stops a denounced author getting two comments, so if that step is ever re-keyed off `allowed`, check the overlap first.
`.github/scripts/vouch-decision.test.js` holds that table as a `decide()` function and asserts the workflow's `require-vouch`, `auto-close`, and comment-step gating still produce it, comment counts included. Be clear about what that does and does not prove. `decide()` is a **hand transcription** of `gh-check-pr`, read from `vouch/github.nu` at the pinned SHA; the test cannot run the action, so it cannot notice the action changing underneath it. Left alone it would agree with itself forever, which makes bumping the pinned SHA the one edit it would otherwise sail through. So it also asserts `vouch-check-pr.yml` still pins `PINNED_VOUCH_SHA`, and a bump fails it on purpose: re-read `gh-check-pr` at the new revision, correct `decide()` and the table above, then move the constant. CI runs it through the `GitHub Scripts` job on any change to the scripts or the files they read.
Failure is open by design. If the action cannot read `VOUCHED.td` it falls back to an empty list, every author reads as absent, and nobody is closed by an API hiccup.
`vouch-manage-by-issue.yml` runs with `merge-immediately: "false"`. The `Main Branch Rule` ruleset requires one approving review and has no bypass actors, so the action's immediate `PUT /pulls/{n}/merge` would return 405 and leave `VOUCHED.td` unchanged on `main`. The bot opens the PR, a maintainer merges it. Setting `pull-request: "false"` is not an alternative: the same ruleset blocks direct pushes.
That workflow also needs `VOUCH_APP_ID` and `VOUCH_APP_PRIVATE_KEY` repository secrets. Without them it fails at the token step before doing anything. `vouch-check-pr.yml` needs neither.
## Issue forms and templates ## Issue forms and templates
@@ -99,4 +148,4 @@ Current field ids:
`VOUCHED.td` is one GitHub username per line, `#` for comments. Seeded from every author with at least one merged PR in this repository, then filtered: accounts at or below a 16% merge rate across five or more attempts were dropped, since landing one change out of many is the signature of automated submission rather than contribution. `VOUCHED.td` is one GitHub username per line, `#` for comments. Seeded from every author with at least one merged PR in this repository, then filtered: accounts at or below a 16% merge rate across five or more attempts were dropped, since landing one change out of many is the signature of automated submission rather than contribution.
Vouch's only built-in exemptions are accounts ending in `[bot]` and repo collaborators with `write` or `admin`. **Organization membership alone is not one of them.** So `vouch-check-pr.yml` carries a job-level `if:` that skips the check for `OWNER`, `MEMBER`, and `COLLABORATOR` authors, the same exemption `pr-gate.yml` already applies. `author_association` is `MEMBER` for every org member regardless of repository permission, so no member can be flagged even if their `VOUCHED.td` entry is missing, misspelled, or miscased. Org members are still listed in the file as a fallback, but the workflow guard is what actually holds. Vouch's only built-in exemptions are accounts ending in `[bot]` and repo collaborators with `write` or `admin`. **Organization membership alone is not one of them.** So `vouch-check-pr.yml` carries a job-level `if:` that skips the check for `OWNER`, `MEMBER`, and `COLLABORATOR` authors, the same exemption `pr-gate.yml` already applies. Org members are still listed in the file as a fallback, but the workflow guard is what actually holds.
+20 -6
View File
@@ -1,15 +1,29 @@
# The list of vouched (or denounced) users for this repository. # The list of vouched (or denounced) users for this repository.
# #
# Only vouched users can open pull requests here. A denounced user (prefixed # A denounced user (prefixed with a minus) is blocked outright: their pull
# with a minus) is blocked outright. # requests are closed on sight, whatever they link. Being absent from this file
# blocks nothing. An unvouched author gets one comment saying so and their pull
# request is reviewed like anyone else's, because a first contribution has to
# start somewhere. Vouching is how that comment stops.
#
# This list is about who, and it is the only thing that judges who. Whether a
# change is wanted is a separate question, answered by the accepted label and
# enforced by pr-gate.yml. Neither gate substitutes for the other: a vouched
# author still needs an accepted issue, and a denounced author is turned away
# even holding one.
# #
# Vouch automatically allows two kinds of account without consulting this file: # Vouch automatically allows two kinds of account without consulting this file:
# accounts ending in [bot], and repo collaborators with write or admin # accounts ending in [bot], and repo collaborators with write or admin
# permission. Org membership on its own is NOT one of them, so # permission. Org membership on its own is NOT one of them, so
# vouch-check-pr.yml skips the check entirely for OWNER, MEMBER, and # vouch-check-pr.yml skips the check for OWNER, MEMBER, and COLLABORATOR
# COLLABORATOR authors. mem0ai org members are listed below as well, but that # authors, and for any branch pushed to this repository.
# workflow guard is what actually protects them: a missing, misspelled, or #
# miscased entry here can never cause a member to be flagged. # Keep every mem0ai member listed below anyway. The author_association arm of
# that guard is weaker than it looks: MEMBER needs the member's org membership
# to be public and COLLABORATOR needs a direct repo invite, so a member with
# private membership and a team-derived role reads as CONTRIBUTOR. Working from
# a branch here covers them, working from their own fork leaves this file as
# the only thing that does. A missing or miscased entry is a real gap.
# #
# Syntax: # Syntax:
# - One handle per line (without @), sorted alphabetically. # - One handle per line (without @), sorted alphabetically.
@@ -0,0 +1,60 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const gate = fs.readFileSync(path.join(__dirname, '..', 'workflows', 'pr-gate.yml'), 'utf8');
const rootDocsLine = gate.match(/^\s*(const rootDocs = new Set\(\['[\w.-]+'(?:, '[\w.-]+')*\]\);)\s*$/m);
const isDocsLine = gate.match(/^\s*(const isDocs = \(\w+\) => [\w.'"()[\]\/, |&!=><+-]+;)\s*$/m);
assert.ok(
rootDocsLine,
'pr-gate.yml no longer declares rootDocs as a single-line Set of quoted filenames. ' +
'This test evaluates that line to exercise the shipped predicate rather than a copy of it, ' +
'and only accepts a literal shape, so widen the pattern deliberately or keep the declaration literal.',
);
assert.ok(
isDocsLine,
'pr-gate.yml no longer declares isDocs as a single-line arrow expression. ' +
'This test evaluates that line to exercise the shipped predicate rather than a copy of it, ' +
'and refuses anything with a statement body, so keep it an expression.',
);
const isDocs = new Function(`${rootDocsLine[1]}\n${isDocsLine[1]}\nreturn isDocs;`)();
const exempt = (files) => files.length > 0 && files.every(isDocs);
const cases = [
[['docs/a.mdx'], true],
[['docs/platform/quickstart.mdx'], true],
[['README.md'], true],
[['CONTRIBUTING.md'], true],
[['CODE_OF_CONDUCT.md'], true],
[['SECURITY.md'], true],
[['README.md', 'CONTRIBUTING.md', 'docs/x.mdx'], true],
[['AGENTS.md'], false],
[['CLAUDE.md'], false],
[['LLM.md'], false],
[['README.md', 'AGENTS.md'], false],
[['README.md', 'mem0/memory/main.py'], false],
[['skills/mem0/SKILL.md'], false],
[['.github/AGENTS.md'], false],
[['.github/workflows/ci.yml'], false],
[['docs-site/index.md'], false],
[[], false],
];
let failures = 0;
for (const [files, expected] of cases) {
const actual = exempt(files);
const label = files.length ? files.join(', ') : '(no files)';
if (actual === expected) {
console.log(`ok ${label} -> ${actual ? 'exempt' : 'gated'}`);
} else {
failures += 1;
console.log(`FAIL ${label} -> ${actual ? 'exempt' : 'gated'}, expected ${expected ? 'exempt' : 'gated'}`);
}
}
console.log(failures === 0 ? '\nPASS' : `\nFAIL (${failures} cases)`);
process.exit(failures === 0 ? 0 : 1);
+122
View File
@@ -0,0 +1,122 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const workflowPath = path.join(__dirname, '..', 'workflows', 'vouch-check-pr.yml');
const workflow = fs.readFileSync(workflowPath, 'utf8');
const PINNED_VOUCH_SHA = 'd66fa29a64600490892131ad87597c30c91fcac4';
assert.ok(
workflow.includes(`mitchellh/vouch/action/check-pr@${PINNED_VOUCH_SHA}`),
`decide() below is a hand transcription of gh-check-pr from vouch/github.nu at ${PINNED_VOUCH_SHA} (v1.5.0). ` +
'It reads the action, it does not run it, so on its own it agrees with itself whatever the action does. ' +
'vouch-check-pr.yml now pins a different revision: re-read gh-check-pr there, update decide() and the ' +
'decision table in .github/AGENTS.md to match it, then set PINNED_VOUCH_SHA to the new SHA.',
);
const actionDefaults = { 'require-vouch': true, 'auto-close': false };
const booleanInput = (name) => {
const match = workflow.match(new RegExp(`^\\s+${name}:\\s*"?(true|false)"?\\s*$`, 'm'));
return match ? match[1] === 'true' : actionDefaults[name];
};
const requireVouch = booleanInput('require-vouch');
const autoClose = booleanInput('auto-close');
const commentedStatus = (() => {
const match = workflow.match(/steps\.vouch\.outputs\.status == '(\w+)'/);
assert.ok(match, 'the follow-up comment step is not keyed on a vouch status');
return match[1];
})();
const decide = (author) => {
if (author === 'bot') return { status: 'skipped', closed: false, actionComments: false };
if (author === 'collaborator' || author === 'vouched') {
return { status: 'vouched', closed: false, actionComments: false };
}
if (author === 'denounced') {
if (!autoClose) return { status: 'closed', closed: false, actionComments: false };
return { status: 'closed', closed: true, actionComments: true };
}
if (!requireVouch) return { status: 'allowed', closed: false, actionComments: false };
if (!autoClose) return { status: 'closed', closed: false, actionComments: false };
return { status: 'closed', closed: true, actionComments: true };
};
const outcome = (author) => {
const result = decide(author);
return { ...result, workflowComments: result.status === commentedStatus };
};
const cases = [
{ author: 'bot', closed: false, comments: 0 },
{ author: 'collaborator', closed: false, comments: 0 },
{ author: 'vouched', closed: false, comments: 0 },
{ author: 'unvouched', closed: false, comments: 1 },
{ author: 'denounced', closed: true, comments: 1 },
];
let failures = 0;
for (const expected of cases) {
const actual = outcome(expected.author);
const comments = Number(actual.actionComments) + Number(actual.workflowComments);
try {
assert.strictEqual(actual.closed, expected.closed, `${expected.author}: closed`);
assert.strictEqual(comments, expected.comments, `${expected.author}: comment count`);
console.log(`ok ${expected.author} -> ${actual.status}, closed=${actual.closed}, comments=${comments}`);
} catch (error) {
failures += 1;
console.log(`FAIL ${expected.author} -> ${actual.status}, closed=${actual.closed}, comments=${comments}`);
console.log(` ${error.message}: expected ${JSON.stringify(expected)}`);
}
}
console.log(`\nvouch@${PINNED_VOUCH_SHA.slice(0, 7)} require-vouch=${requireVouch} auto-close=${autoClose} comment-on=${commentedStatus}`);
const parseDenounced = (contents) => new Set(contents
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('-'))
.map((line) => line.slice(1).split(/\s+/)[0].split(':').pop().toLowerCase())
.filter(Boolean));
const gate = fs.readFileSync(path.join(__dirname, '..', 'workflows', 'pr-gate.yml'), 'utf8');
assert.ok(
gate.includes(".filter((line) => line.startsWith('-'))"),
'pr-gate.yml no longer parses the denounce list the way this test does',
);
const vouched = fs.readFileSync(path.join(__dirname, '..', 'VOUCHED.td'), 'utf8');
const denouncedNow = parseDenounced(vouched);
const sample = parseDenounced([
'# -notacomment is a comment line',
'-SpamBot seeded 2026-08-12',
'-github:OtherSpammer',
'realcontributor',
'',
].join('\n'));
let parseFailures = 0;
for (const [label, actual, expected] of [
['denounce entry, with note', sample.has('spambot'), true],
['denounce entry, platform prefixed', sample.has('otherspammer'), true],
['comment line is not an entry', sample.has('notacomment'), false],
['vouched entry is not denounced', sample.has('realcontributor'), false],
['live file parses without throwing', denouncedNow instanceof Set, true],
]) {
try {
assert.strictEqual(actual, expected, label);
console.log(`ok ${label}`);
} catch (error) {
parseFailures += 1;
console.log(`FAIL ${label}: ${error.message}`);
}
}
console.log(`denounced in VOUCHED.td: ${denouncedNow.size}`);
const total = failures + parseFailures;
console.log(total === 0 ? 'PASS' : `FAIL (${total} assertions)`);
process.exit(total === 0 ? 0 : 1);
+27
View File
@@ -44,6 +44,7 @@ jobs:
n8n_nodes_mem0: ${{ steps.filter.outputs.n8n_nodes_mem0 }} n8n_nodes_mem0: ${{ steps.filter.outputs.n8n_nodes_mem0 }}
zapier_mem0: ${{ steps.filter.outputs.zapier_mem0 }} zapier_mem0: ${{ steps.filter.outputs.zapier_mem0 }}
docs_llms_txt: ${{ steps.filter.outputs.docs_llms_txt }} docs_llms_txt: ${{ steps.filter.outputs.docs_llms_txt }}
github_scripts: ${{ steps.filter.outputs.github_scripts }}
steps: steps:
- uses: dorny/paths-filter@v3 - uses: dorny/paths-filter@v3
id: filter id: filter
@@ -101,6 +102,13 @@ jobs:
- 'scripts/llms-txt-ignore.txt' - 'scripts/llms-txt-ignore.txt'
- '.github/workflows/docs-llms-txt-check.yml' - '.github/workflows/docs-llms-txt-check.yml'
- '.github/workflows/ci-gate.yml' - '.github/workflows/ci-gate.yml'
github_scripts:
- '.github/scripts/**'
- '.github/VOUCHED.td'
- '.github/workflows/pr-gate.yml'
- '.github/workflows/vouch-check-pr.yml'
- '.github/workflows/issue-labeler.yml'
- '.github/workflows/ci-gate.yml'
python-sdk: python-sdk:
name: Python SDK name: Python SDK
@@ -177,6 +185,24 @@ jobs:
uses: ./.github/workflows/docs-llms-txt-check.yml uses: ./.github/workflows/docs-llms-txt-check.yml
secrets: inherit secrets: inherit
github-scripts:
name: GitHub Scripts
needs: changes
if: needs.changes.outputs.github_scripts == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Run .github/scripts tests
run: |
for test in .github/scripts/*.test.js; do
echo "::group::$test"
node "$test"
echo "::endgroup::"
done
gate: gate:
name: CI Gate name: CI Gate
needs: needs:
@@ -192,6 +218,7 @@ jobs:
- n8n-nodes-mem0 - n8n-nodes-mem0
- zapier-mem0 - zapier-mem0
- docs-llms-txt - docs-llms-txt
- github-scripts
if: always() if: always()
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+118 -7
View File
@@ -2,11 +2,13 @@ name: PR Gate
on: on:
pull_request_target: pull_request_target:
types: [opened, reopened, edited, ready_for_review] types: [opened, reopened, ready_for_review, edited]
issues:
types: [labeled]
concurrency: concurrency:
group: pr-gate-${{ github.event.pull_request.number }} group: pr-gate-${{ github.event_name }}-${{ github.event.action }}-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: true cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
env: env:
GATE_EFFECTIVE_FROM: '2026-08-12T00:00:00Z' GATE_EFFECTIVE_FROM: '2026-08-12T00:00:00Z'
@@ -19,8 +21,11 @@ permissions:
jobs: jobs:
gate: gate:
if: >- if: >-
github.event_name == 'pull_request_target' &&
github.event.action != 'edited' &&
github.event.pull_request.draft == false && github.event.pull_request.draft == false &&
github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.type != 'Bot' &&
github.event.pull_request.head.repo.full_name != github.repository &&
!contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -47,7 +52,9 @@ jobs:
const files = await github.paginate(github.rest.pulls.listFiles, { const files = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: pr.number, per_page: 100, owner, repo, pull_number: pr.number, per_page: 100,
}); });
if (files.length > 0 && files.every((file) => file.filename.startsWith('docs/'))) { const rootDocs = new Set(['README.md', 'CONTRIBUTING.md', 'CODE_OF_CONDUCT.md', 'SECURITY.md']);
const isDocs = (filename) => filename.startsWith('docs/') || rootDocs.has(filename);
if (files.length > 0 && files.every((file) => isDocs(file.filename))) {
core.info('Docs-only PR, gate skipped'); core.info('Docs-only PR, gate skipped');
return; return;
} }
@@ -75,6 +82,7 @@ jobs:
} }
const body = [ const body = [
'<!-- pr-gate -->',
'Thanks for taking the time to open this.', 'Thanks for taking the time to open this.',
'', '',
'We only review pull requests that fix an issue we have already agreed to take on, so this one is closed for now.', 'We only review pull requests that fix an issue we have already agreed to take on, so this one is closed for now.',
@@ -84,10 +92,9 @@ jobs:
'', '',
'1. Make sure an issue describes the problem, with the version you are on, a runnable reproduction, and the real output or traceback you saw.', '1. Make sure an issue describes the problem, with the version you are on, a runnable reproduction, and the real output or traceback you saw.',
'2. Link it from this pull request description with `Closes #<number>`.', '2. Link it from this pull request description with `Closes #<number>`.',
'3. Ask a maintainer to label that issue `accepted`.', '3. Ask a maintainer to label that issue `accepted`. This pull request reopens by itself when they do.',
'4. Reopen this pull request. The check runs again and it stays open.',
'', '',
'Already linked an accepted issue? Edit the description to include `Closes #<number>` and reopen. The check reruns automatically.', 'Issue already labeled `accepted`? Just add `Closes #<number>` to the description. That reopens this too.',
'', '',
'Documentation-only changes skip this gate entirely.', 'Documentation-only changes skip this gate entirely.',
'', '',
@@ -101,3 +108,107 @@ jobs:
owner, repo, pull_number: pr.number, state: 'closed', owner, repo, pull_number: pr.number, state: 'closed',
}); });
core.info(`Closed #${pr.number}: no accepted issue linked`); core.info(`Closed #${pr.number}: no accepted issue linked`);
reopen:
if: >-
(github.event_name == 'issues' && github.event.label.name == 'accepted') ||
(github.event.action == 'edited' && github.event.pull_request.state == 'closed')
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const marker = '<!-- pr-gate -->';
const denounced = await (async () => {
try {
const { data } = await github.rest.repos.getContent({
owner, repo, path: '.github/VOUCHED.td',
ref: context.payload.repository.default_branch,
});
return new Set(Buffer.from(data.content, 'base64').toString('utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('-'))
.map((line) => line.slice(1).split(/\s+/)[0].split(':').pop().toLowerCase())
.filter(Boolean));
} catch (error) {
core.warning(`Could not read VOUCHED.td, treating nobody as denounced: ${error.message}`);
return new Set();
}
})();
const isReopenable = async (number) => {
const { repository } = await github.graphql(
`query ($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
state
author { login }
closingIssuesReferences(first: 20) {
nodes { labels(first: 50) { nodes { name } } }
}
}
}
}`,
{ owner, repo, number },
);
const pullRequest = repository.pullRequest;
if (denounced.has(pullRequest.author?.login?.toLowerCase())) {
core.info(`#${number} is from a denounced author. Vouch outranks this gate.`);
return false;
}
return pullRequest.state === 'CLOSED' &&
pullRequest.closingIssuesReferences.nodes.some((issue) =>
issue.labels.nodes.some((label) => label.name === 'accepted'));
};
let candidates;
if (context.eventName === 'issues') {
const { repository } = await github.graphql(
`query ($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $number) {
closedByPullRequestsReferences(first: 20, includeClosedPrs: true) {
nodes { number }
}
}
}
}`,
{ owner, repo, number: context.payload.issue.number },
);
candidates = repository.issue.closedByPullRequestsReferences.nodes.map((pr) => pr.number);
} else {
candidates = [context.payload.pull_request.number];
}
for (const number of candidates) {
if (!(await isReopenable(number))) {
core.info(`#${number} is not a closed pull request linking an accepted issue. Skipped.`);
continue;
}
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: number, per_page: 100,
});
if (!comments.some((comment) => comment.body?.startsWith(marker))) {
core.info(`#${number} was not closed by this gate. Left alone.`);
continue;
}
try {
await github.rest.pulls.update({
owner, repo, pull_number: number, state: 'open',
});
} catch (error) {
core.warning(`Could not reopen #${number}: ${error.message}`);
continue;
}
await github.rest.issues.createComment({
owner, repo, issue_number: number,
body: 'An `accepted` issue is linked now, so this is open again and ready for review.',
});
core.info(`Reopened #${number}`);
}
+33 -1
View File
@@ -16,12 +16,44 @@ jobs:
check: check:
if: >- if: >-
github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.type != 'Bot' &&
github.event.pull_request.head.repo.full_name != github.repository &&
!contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: mitchellh/vouch/action/check-pr@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0 - uses: mitchellh/vouch/action/check-pr@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0
id: vouch
with: with:
pr-number: ${{ github.event.pull_request.number }} pr-number: ${{ github.event.pull_request.number }}
auto-close: false require-vouch: false
auto-close: true
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- if: steps.vouch.outputs.status == 'allowed'
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
const marker = '<!-- vouch-check -->';
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pr.number, per_page: 100,
});
if (comments.some((comment) => comment.body?.startsWith(marker))) {
core.info('Vouch comment already posted, skipped.');
return;
}
const body = [
marker,
`Hi @${context.payload.pull_request.user.login}, thanks for opening this pull request.`,
'',
"This is just a soft check: you are not yet in this repo's vouched contributor list (`.github/VOUCHED.td`). Nothing is blocked and there is nothing you need to do.",
'',
`A maintainer can vouch for you by commenting \`!vouch @${context.payload.pull_request.user.login}\` on any issue.`,
].join('\n');
await github.rest.issues.createComment({
owner, repo, issue_number: pr.number, body,
});
+1 -1
View File
@@ -37,6 +37,6 @@ jobs:
denounce-keyword: "!denounce" denounce-keyword: "!denounce"
unvouch-keyword: "!unvouch" unvouch-keyword: "!unvouch"
pull-request: "true" pull-request: "true"
merge-immediately: "true" merge-immediately: "false"
env: env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
+21 -1
View File
@@ -10,6 +10,7 @@ This is a polyglot monorepo and **every package sets its own rules**. Read the `
## Do NOT ## Do NOT
- Open a pull request without a signed CLA. It will not be reviewed. See [The CLA is not optional](#the-cla-is-not-optional). - Open a pull request without a signed CLA. It will not be reviewed. See [The CLA is not optional](#the-cla-is-not-optional).
- Open a pull request that does not link an issue carrying the `accepted` label. A bot closes it within a minute. See [Two gates decide whether your pull request stays open](#two-gates-decide-whether-your-pull-request-stays-open).
- Modify anything in `.github/workflows/` without explicit maintainer approval. Publishing credentials are pinned to workflow filenames. - Modify anything in `.github/workflows/` without explicit maintainer approval. Publishing credentials are pinned to workflow filenames.
- Commit `.env` files, API keys, or credentials. - Commit `.env` files, API keys, or credentials.
- Skip pre-commit hooks. - Skip pre-commit hooks.
@@ -121,6 +122,24 @@ Full guide: [`CONTRIBUTING.md`](CONTRIBUTING.md). Conduct: [`CODE_OF_CONDUCT.md`
6. Open the PR against `main` and fill in [the template](.github/PULL_REQUEST_TEMPLATE.md). Do not paraphrase it; GitHub prefills it. 6. Open the PR against `main` and fill in [the template](.github/PULL_REQUEST_TEMPLATE.md). Do not paraphrase it; GitHub prefills it.
7. **Sign the CLA.** 7. **Sign the CLA.**
### Two gates decide whether your pull request stays open
Two workflows run on every pull request from a fork. They judge different things and neither covers for the other, so a pull request has to get past both.
**The [PR Gate](.github/workflows/pr-gate.yml) judges the change.** It closes any pull request that does not link an issue carrying the `accepted` label. Closed is a queue decision, not a verdict: when a maintainer applies the label the pull request reopens by itself. Drafts, documentation-only changes, and branches pushed to this repository rather than a fork are all exempt.
**The [vouch check](.github/workflows/vouch-check-pr.yml) judges the account.** It reads [`.github/VOUCHED.td`](.github/VOUCHED.td), which has three possible answers about any given person:
| The list says | Meaning | Effect on the pull request |
|---|---|---|
| `-handle` | a maintainer ran `!denounce` after the code of conduct process | closed, even with an accepted issue |
| nothing at all | everybody who has not contributed here before | **none.** One comment saying nothing is blocked. |
| `handle` | a maintainer ran `!vouch` | none, and the comment stops appearing |
Being vouched grants nothing. It is a "we have seen this person before" flag that mutes the newcomer comment, not permission to skip the accepted-issue rule. Being absent from the list costs nothing.
If you are an agent opening a pull request on someone's behalf, the practical consequence is one rule: **get the linked issue labelled `accepted` before you open the pull request, or expect the pull request to be closed and to reopen later.** Do not work around either gate, do not reopen a gated pull request by hand, and do not re-file the same change under a new pull request when one is closed.
### The CLA is not optional ### The CLA is not optional
**A pull request from a contributor who has not signed the Contributor License Agreement is not accepted, not reviewed, and not merged.** This is not a formality applied at merge time. An unsigned pull request does not enter the review queue at all: maintainers do not read the diff, do not leave feedback, and do not discuss the approach. It sits until the CLA is signed, and it is closed if it goes stale. **A pull request from a contributor who has not signed the Contributor License Agreement is not accepted, not reviewed, and not merged.** This is not a formality applied at merge time. An unsigned pull request does not enter the review queue at all: maintainers do not read the diff, do not leave feedback, and do not discuss the approach. It sits until the CLA is signed, and it is closed if it goes stale.
@@ -151,5 +170,6 @@ Beyond the CLA and the accepted-issue gate, the [Contribution Conduct](CODE_OF_C
| Documentation contributions | `docs/contributing/documentation.mdx` | | Documentation contributions | `docs/contributing/documentation.mdx` |
| PR template | `.github/PULL_REQUEST_TEMPLATE.md` | | PR template | `.github/PULL_REQUEST_TEMPLATE.md` |
| Issue forms | `.github/ISSUE_TEMPLATE/` | | Issue forms | `.github/ISSUE_TEMPLATE/` |
| Trust list (vouch) | `.github/VOUCHED.td` | | Contribution gates | [Two gates decide whether your pull request stays open](#two-gates-decide-whether-your-pull-request-stays-open) |
| Trust list (vouch) | [`.github/VOUCHED.td`](.github/VOUCHED.td) |
| CI/CD, gates, rulesets | [`.github/AGENTS.md`](.github/AGENTS.md) | | CI/CD, gates, rulesets | [`.github/AGENTS.md`](.github/AGENTS.md) |
+18 -3
View File
@@ -40,9 +40,24 @@ agree the change is one we want.
Pull requests that don't link an accepted issue are closed automatically by the Pull requests that don't link an accepted issue are closed automatically by the
[PR Gate](./.github/workflows/pr-gate.yml). **Closed does not mean rejected.** It [PR Gate](./.github/workflows/pr-gate.yml). **Closed does not mean rejected.** It
means the change isn't in the queue yet. Once a maintainer labels the issue, means the change isn't in the queue yet. Once a maintainer labels the issue the
reopen the pull request and it stays open. Documentation-only changes skip the pull request reopens itself, and you don't have to do anything. Documentation-only
gate entirely. changes skip the gate entirely.
A second check looks at who opened the pull request rather than what it changes.
If you are not yet in this repo's contributor list
([`.github/VOUCHED.td`](./.github/VOUCHED.td)) you get one comment saying so.
**Nothing is blocked and there is nothing you need to do.** A maintainer can add
you by commenting `!vouch @you` on any issue, which only stops that comment from
appearing again. Being on the list is not permission to skip the accepted-issue
rule, and being absent from it costs you nothing.
The list has a negative side too. A maintainer can `!denounce` an account that
has been through the
[code of conduct](./CODE_OF_CONDUCT.md#contribution-conduct) enforcement process,
and pull requests from that account are closed whether or not they link an
accepted issue. This is rare, it is never where anyone starts, and it is
reversible.
Security fixes are the one exception, and they don't go through public pull Security fixes are the one exception, and they don't go through public pull
requests at all. Follow the [Security Policy](./SECURITY.md) instead, which uses requests at all. Follow the [Security Policy](./SECURITY.md) instead, which uses