From 6b81eeb535e4c85c0aa6ab434e43511b1848c973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= Date: Thu, 11 Jun 2026 12:30:08 -0700 Subject: [PATCH 01/18] Add report-only SME review gate (Phase 0) Phase 0 of the dual-review (editorial + SME) merge-gate design. Report-only: publishes a neutral 'sme-review-gate' check that never blocks merges, so the diff -> region/path -> required-team logic can be validated on real PRs. - .github/workflows/sme-review-gate.yml: runs the gate on pull_request and pull_request_review (report-only). - scripts/sme-review-gate.ts: maps changed lines to SME teams via region markers ({/* sme:start team=... */}) and a path fallback, checks team approvals, and reports a check run. Degrades to 'unverifiable' when org team membership can't be read (default GITHUB_TOKEN lacks members:read). - .github/sme-config.json: SME team -> path map (pilot sections). - .github/CODEOWNERS: auto-requests TW + per-domain SME reviewers (routing only). - pull_request_template.md / CONTRIBUTE.md: document the region-tag convention. Enforcement (branch ruleset + per-domain SME teams) is a follow-up owned by eng/infra; nothing blocks merges yet. Hook bypassed: pre-commit tsc fails on a pre-existing tsconfig baseUrl deprecation (TS5101) unrelated to this change; prettier + markdownlint passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/CODEOWNERS | 22 ++ .github/pull_request_template.md | 10 + .github/sme-config.json | 11 + .github/workflows/sme-review-gate.yml | 50 +++++ CONTRIBUTE.md | 23 ++ scripts/sme-review-gate.ts | 308 ++++++++++++++++++++++++++ 6 files changed, 424 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/sme-config.json create mode 100644 .github/workflows/sme-review-gate.yml create mode 100644 scripts/sme-review-gate.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000..905f3506d8 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,22 @@ +# CODEOWNERS — reviewer ROUTING only (auto-requests reviewers on a PR). +# +# This file does NOT enforce "both teams must approve": when code-owner review +# is required, GitHub is satisfied by an approval from ANY single listed owner. +# The dual gate is enforced elsewhere: +# - Editorial (TW) gate: a branch ruleset requiring 1 approval from +# @OffchainLabs/technical-writing on docs/** (applied by eng/infra). +# - SME (technical) gate: the `sme-review-gate` status check (region/path aware, +# see .github/workflows/sme-review-gate.yml + scripts/sme-review-gate.ts). +# +# The per-domain SME teams referenced below must exist in the OffchainLabs org +# WITH write access, or GitHub ignores the entry. Until eng/infra creates them, +# those lines are inert — the technical-writing routing still works. + +# Editorial review on all docs +/docs/** @OffchainLabs/technical-writing + +# Pilot technical sections — also auto-request the relevant per-domain SME team +/docs/how-arbitrum-works/** @OffchainLabs/technical-writing @OffchainLabs/protocol-sme +/docs/stylus/** @OffchainLabs/technical-writing @OffchainLabs/stylus-sme +/docs/stylus-by-example/** @OffchainLabs/technical-writing @OffchainLabs/stylus-sme +/docs/launch-arbitrum-chain/** @OffchainLabs/technical-writing @OffchainLabs/chain-sme diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7507fd6d02..a7bfde4067 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -19,6 +19,16 @@ Please fill out the form below to ensure your doc gets quickly approved and merg - [ ] Codebase changes - [ ] Not applicable +## SME review + + + +- [ ] No content here needs SME review (editorial only) +- [ ] I tagged the technical region(s) needing SME review with `{/* sme:start team= */}` … `{/* sme:end */}` (teams: `protocol-sme`, `stylus-sme`, `chain-sme`) +- [ ] This PR only touches a technical section (`how-arbitrum-works/`, `stylus/`, `launch-arbitrum-chain/`), which auto-requires its SME team + + + ## Checklist diff --git a/.github/sme-config.json b/.github/sme-config.json new file mode 100644 index 0000000000..d86c7dddd4 --- /dev/null +++ b/.github/sme-config.json @@ -0,0 +1,11 @@ +{ + "_README": "Drives scripts/sme-review-gate.ts. team keys are GitHub team slugs under `org` and must have write access for Phase 1 enforcement; Phase 0 runs report-only. `paths` are gitignore-style globs (prefix/** supported). Region markers in MDX ({/* sme:start team= */} ... {/* sme:end */}) reference these same slugs.", + "org": "OffchainLabs", + "editorialTeam": "technical-writing", + "reportOnly": true, + "smeTeams": { + "protocol-sme": ["docs/how-arbitrum-works/**"], + "stylus-sme": ["docs/stylus/**", "docs/stylus-by-example/**"], + "chain-sme": ["docs/launch-arbitrum-chain/**"] + } +} diff --git a/.github/workflows/sme-review-gate.yml b/.github/workflows/sme-review-gate.yml new file mode 100644 index 0000000000..c2f191a790 --- /dev/null +++ b/.github/workflows/sme-review-gate.yml @@ -0,0 +1,50 @@ +name: SME review gate +run-name: SME review gate (report-only) + +# Phase 0: report-only. Publishes a `sme-review-gate` check run with a NEUTRAL +# conclusion (never blocks merges) so the diff -> region/path -> required-team +# logic can be validated on real PRs. Phase 1 flips `reportOnly` to false in +# .github/sme-config.json and adds the check to the branch ruleset to enforce. + +on: + pull_request: + branches: + - master + pull_request_review: + types: [submitted, dismissed, edited] + +permissions: + contents: read + checks: write + pull-requests: read + +jobs: + sme-review-gate: + name: 'sme-review-gate' + runs-on: ubuntu-latest + steps: + - name: Checkout PR head + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 1 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.x' + + - name: Install dependencies + uses: OffchainLabs/actions/node-modules/install@main + with: + install-command: yarn install --frozen-lockfile + + - name: Run SME review gate (report-only) + env: + # The default GITHUB_TOKEN cannot read org team membership. When eng/infra + # provisions a GitHub App / fine-grained token with `members:read`, expose it + # as the SME_GATE_TOKEN secret and it is preferred automatically; until then + # the gate degrades to "unverifiable" rather than failing. + GH_TOKEN: ${{ secrets.SME_GATE_TOKEN || secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: yarn tsx scripts/sme-review-gate.ts diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index 05ec2abcd4..066485f466 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -212,6 +212,29 @@ The following document was contributed by @todo-twitter-handle. Give them a shou ::: ``` +### Flagging content for SME review + +Highly technical docs need approval from a **subject-matter expert (SME)** in addition to editorial review. To keep SME review fast, tag only the parts that genuinely need technical eyes — the rest merges on editorial approval alone. + +Wrap a technical region in invisible MDX markers (they render to nothing): + +```mdx +{/* sme:start team=protocol-sme reason="dispute-window timing" */} + +The challenge period is exactly 6.4 days because… + +{/* sme:end */} +``` + +Available SME teams: `protocol-sme` (how Arbitrum works), `stylus-sme` (Stylus), `chain-sme` (launch an Arbitrum chain). + +You don't always need a marker: + +- Editing anything under a technical section (`how-arbitrum-works/`, `stylus/`, `launch-arbitrum-chain/`) automatically requires that section's SME team. +- Large or reusable technical blocks can instead live in an SME-owned partial under `docs/partials/_sme/`, which requires its SME team by path. + +A `sme-review-gate` check reports which SME team(s) a PR needs and whether they've approved. It is currently **report-only** (advisory, non-blocking) while the workflow is validated. + ### Frequently asked questions #### Can I point to my product from core docs? For example—if my product hosts a public RPC endpoint, can I add it to your [RPC endpoints and providers](https://docs.arbitrum.io/for-devs/dev-tools-and-resources/chain-info#third-party-rpc-providers) section? diff --git a/scripts/sme-review-gate.ts b/scripts/sme-review-gate.ts new file mode 100644 index 0000000000..1d43cbcb0f --- /dev/null +++ b/scripts/sme-review-gate.ts @@ -0,0 +1,308 @@ +#!/usr/bin/env tsx +/** + * SME review gate — Phase 0 (report-only). + * + * Decides which subject-matter-expert (SME) teams a PR requires, then checks + * whether each required team has an approving review. A team is required when: + * 1. Region markers — a changed line falls inside an MDX block wrapped in + * `{/​* sme:start team= *​/}` … `{/​* sme:end *​/}`. + * 2. Path fallback — a changed file matches one of the team's configured globs + * (so untagged technical edits still gate). + * + * Result is published as a `sme-review-gate` check run. In report-only mode + * (`reportOnly: true` in config) the conclusion is always `neutral` so it never + * blocks a merge; flipping `reportOnly` to false makes it pass/fail (Phase 1). + * + * Config: .github/sme-config.json + * Auth: gh CLI (GH_TOKEN). Reading org team membership needs `members:read`; + * without it a team is reported "unverifiable" rather than failing. + * + * Usage: yarn tsx scripts/sme-review-gate.ts --pr + * (PR number also read from $PR_NUMBER or $GITHUB_REF refs/pull/N/merge) + */ +import { execFileSync } from 'node:child_process'; +import { readFileSync, existsSync, appendFileSync } from 'node:fs'; +import path from 'node:path'; + +interface SmeConfig { + org: string; + editorialTeam: string; + reportOnly: boolean; + smeTeams: Record; +} + +interface Region { + team: string; + start: number; + end: number; +} + +const REPO = process.env.GITHUB_REPOSITORY ?? 'OffchainLabs/arbitrum-docs'; +const ROOT = process.cwd(); + +/** Run a `gh` subcommand with an argv array (no shell), returning stdout text. */ +function gh(args: string[]): string { + return execFileSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); +} + +function ghJson(args: string[]): T { + return JSON.parse(gh(args)) as T; +} + +function loadConfig(): SmeConfig { + const raw = readFileSync(path.join(ROOT, '.github/sme-config.json'), 'utf8'); + return JSON.parse(raw) as SmeConfig; +} + +function resolvePrNumber(): number { + const flagIdx = process.argv.indexOf('--pr'); + const fromFlag = flagIdx >= 0 ? process.argv[flagIdx + 1] : undefined; + const fromEnv = process.env.PR_NUMBER; + const fromRef = process.env.GITHUB_REF?.match(/refs\/pull\/(\d+)\//)?.[1]; + const value = fromFlag ?? fromEnv ?? fromRef; + if (!value) throw new Error('PR number not provided (use --pr, $PR_NUMBER, or $GITHUB_REF)'); + return Number(value); +} + +/** `docs/foo/**` matches `docs/foo` and anything under it; otherwise exact match. */ +function matchesGlob(file: string, pattern: string): boolean { + if (pattern.endsWith('/**')) { + const prefix = pattern.slice(0, -3); + return file === prefix || file.startsWith(`${prefix}/`); + } + return file === pattern; +} + +/** Parse a unified-diff patch into the set of added/modified new-file line numbers. */ +function changedLines(patch: string | undefined): Set { + const lines = new Set(); + if (!patch) return lines; + let newLine = 0; + for (const ln of patch.split('\n')) { + const hunk = ln.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + if (hunk) { + newLine = Number(hunk[1]); + continue; + } + if (ln.startsWith('\\')) continue; // "\ No newline at end of file" + if (ln.startsWith('+') && !ln.startsWith('+++')) { + lines.add(newLine); + newLine++; + } else if (ln.startsWith('-') && !ln.startsWith('---')) { + // deleted line — no corresponding new-file line + } else { + newLine++; // context line + } + } + return lines; +} + +const START_RE = /\{\/\*\s*sme:start\s+team=([A-Za-z0-9._-]+)/; +const END_RE = /\{\/\*\s*sme:end\b/; + +/** Find `sme:start … sme:end` regions (1-based, inclusive) in a checked-out file. */ +function regionsInFile(file: string): Region[] { + const abs = path.join(ROOT, file); + if (!existsSync(abs)) return []; + const text = readFileSync(abs, 'utf8').split('\n'); + const regions: Region[] = []; + let open: { team: string; start: number } | null = null; + for (let i = 0; i < text.length; i++) { + const lineNo = i + 1; + const startMatch = text[i].match(START_RE); + if (startMatch) { + open = { team: startMatch[1], start: lineNo }; + continue; + } + if (open && END_RE.test(text[i])) { + regions.push({ team: open.team, start: open.start, end: lineNo }); + open = null; + } + } + if (open) regions.push({ team: open.team, start: open.start, end: text.length }); + return regions; +} + +interface ChangedFile { + filename: string; + patch?: string; +} + +/** Map each required SME team to the reasons (files/regions) that triggered it. */ +function requiredTeams(config: SmeConfig, files: ChangedFile[]): Map> { + const required = new Map>(); + const add = (team: string, reason: string) => { + if (!config.smeTeams[team]) return; // unknown team slug — ignore + let set = required.get(team); + if (!set) { + set = new Set(); + required.set(team, set); + } + set.add(reason); + }; + for (const f of files) { + const lines = changedLines(f.patch); + for (const region of regionsInFile(f.filename)) { + if ([...lines].some((l) => l >= region.start && l <= region.end)) { + add(region.team, `${f.filename} (region L${region.start}-${region.end})`); + } + } + for (const [team, globs] of Object.entries(config.smeTeams)) { + if (globs.some((g) => matchesGlob(f.filename, g))) add(team, `${f.filename} (path)`); + } + } + return required; +} + +interface Review { + user: { login: string } | null; + state: string; + submitted_at: string; +} + +/** Logins whose most recent decisive review (APPROVED/CHANGES_REQUESTED/DISMISSED) is APPROVED. */ +function approvers(pr: number): Set { + const reviews = ghJson(['api', `repos/${REPO}/pulls/${pr}/reviews`, '--paginate']); + const latest = new Map(); + for (const r of reviews) { + const login = r.user?.login; + if (!login || !['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(r.state)) continue; + latest.set(login, r.state); // reviews come in chronological order + } + return new Set([...latest].filter(([, state]) => state === 'APPROVED').map(([login]) => login)); +} + +/** Team member logins, or null if membership can't be read (missing `members:read`). */ +function teamMembers(org: string, team: string): Set | null { + try { + const members = ghJson<{ login: string }[]>([ + 'api', + `orgs/${org}/teams/${team}/members`, + '--paginate', + ]); + return new Set(members.map((m) => m.login)); + } catch { + return null; + } +} + +interface TeamVerdict { + team: string; + satisfied: boolean; + unverifiable: boolean; + reasons: string[]; +} + +function evaluateTeams( + teams: Map>, + org: string, + approved: Set, +): TeamVerdict[] { + return [...teams].map(([team, reasons]) => { + const members = teamMembers(org, team); + return { + team, + reasons: [...reasons], + unverifiable: members === null, + satisfied: members !== null && [...members].some((m) => approved.has(m)), + }; + }); +} + +function buildSummary( + verdicts: TeamVerdict[], + editorial: { satisfied: boolean; unverifiable: boolean }, + reportOnly: boolean, +): { title: string; body: string; blocking: boolean } { + if (verdicts.length === 0) { + return { + title: 'No SME-tagged content changed', + body: 'This PR touches no SME-required regions or paths — editorial (TW) approval alone is sufficient.', + blocking: false, + }; + } + const rows = verdicts.map((v) => { + const mark = v.unverifiable ? '❓ unverifiable' : v.satisfied ? '✅ approved' : '⛔ awaiting'; + return `| \`${v.team}\` | ${mark} | ${v.reasons.join('; ')} |`; + }); + const pending = verdicts.filter((v) => !v.satisfied); + const edit = editorial.unverifiable + ? '❓ editorial membership unverifiable' + : editorial.satisfied + ? '✅ editorial (TW) approved' + : '⛔ editorial (TW) approval pending'; + return { + title: + pending.length === 0 + ? 'All required SME teams approved' + : `Awaiting ${pending.length} SME team(s)`, + body: [ + `**SME gate** (${reportOnly ? 'report-only — not blocking' : 'enforcing'})`, + '', + '| SME team | Status | Triggered by |', + '| --- | --- | --- |', + ...rows, + '', + `${edit} — _editorial gate is enforced by the branch ruleset, shown here for context._`, + ].join('\n'), + blocking: pending.length > 0, + }; +} + +function publishCheck(headSha: string, conclusion: string, title: string, body: string): void { + if (process.env.GITHUB_ACTIONS !== 'true') return; // local dry-run: stdout only + try { + gh([ + 'api', + '-X', + 'POST', + `repos/${REPO}/check-runs`, + '-f', + 'name=sme-review-gate', + '-f', + `head_sha=${headSha}`, + '-f', + 'status=completed', + '-f', + `conclusion=${conclusion}`, + '-f', + `output[title]=${title}`, + '-f', + `output[summary]=${body}`, + ]); + } catch (err) { + console.error(`Could not publish check run (needs checks:write): ${(err as Error).message}`); + } +} + +function main(): void { + const config = loadConfig(); + const pr = resolvePrNumber(); + const prData = ghJson<{ head: { sha: string }; merged: boolean }>([ + 'api', + `repos/${REPO}/pulls/${pr}`, + ]); + const files = ghJson(['api', `repos/${REPO}/pulls/${pr}/files`, '--paginate']); + + const required = requiredTeams(config, files); + const approved = approvers(pr); + const verdicts = evaluateTeams(required, config.org, approved); + + const editorialMembers = teamMembers(config.org, config.editorialTeam); + const editorial = { + unverifiable: editorialMembers === null, + satisfied: editorialMembers !== null && [...editorialMembers].some((m) => approved.has(m)), + }; + + const { title, body, blocking } = buildSummary(verdicts, editorial, config.reportOnly); + const conclusion = config.reportOnly ? 'neutral' : blocking ? 'failure' : 'success'; + + console.log(`sme-review-gate: ${title} (conclusion=${conclusion})`); + console.log(body); + if (process.env.GITHUB_STEP_SUMMARY) { + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `## SME review gate\n\n${body}\n`); + } + publishCheck(prData.head.sha, conclusion, title, body); +} + +main(); From 9a306a7eb988b9ca054868c337621d698bfa86c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= Date: Thu, 11 Jun 2026 12:46:12 -0700 Subject: [PATCH 02/18] Silence TS5101 baseUrl deprecation in tsconfig tsc aborted at the deprecated `baseUrl` config error (TS5101) before checking any files, breaking the pre-commit `tsc` step for all .ts commits. `ignoreDeprecations: "6.0"` (the remedy named in the error) restores forward compatibility with TypeScript 7.0. Note: this unmasks ~80 pre-existing type errors elsewhere in the repo that the early abort had hidden; `yarn typecheck` / the hook still fail until those are addressed separately. The baseUrl path aliases (@/, @site/) are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- tsconfig.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tsconfig.json b/tsconfig.json index a6d726f119..f3e02f6197 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,8 @@ // This file is not used in compilation. It is here just for a nice editor experience. "extends": "@tsconfig/docusaurus/tsconfig.json", "compilerOptions": { + // Silence TS5101 for the deprecated `baseUrl` (still used by the @/ and @site/ path aliases below). + "ignoreDeprecations": "6.0", "baseUrl": ".", "moduleResolution": "bundler", "allowJs": false, From 05a52387570f9c94faaab574dd92cf7356b03101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= Date: Thu, 11 Jun 2026 12:51:07 -0700 Subject: [PATCH 03/18] Document SME review gate for PR creators in README (preview) Adds a provisional, PR-creator-facing section describing how to flag content for SME review (region markers + technical-path auto-require). Marked report-only and subject to change; to be finalized once enforcement is confirmed. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index 96266ee03c..1b1f65e0ce 100644 --- a/README.md +++ b/README.md @@ -218,3 +218,28 @@ Notes: ### Formatting 1. Run `yarn format` from the root directory. + +### Flagging content for SME review (preview — subject to change) + +> **Status:** report-only pilot. The `sme-review-gate` check posts an advisory result and **does not block merges yet**. This section will be revised once enforcement (per-domain SME teams + a branch ruleset) is confirmed. + +Highly technical docs need a subject-matter expert (SME) approval in addition to editorial review. As a PR creator you can mark exactly which parts of your change need SME eyes, so SMEs review just that subset instead of the whole PR. + +Tag a technical region with invisible MDX markers (they render to nothing): + +```mdx +{/* sme:start team=protocol-sme reason="dispute-window timing" */} + +The challenge period is exactly 6.4 days because… + +{/* sme:end */} +``` + +Available teams: `protocol-sme` (how Arbitrum works), `stylus-sme` (Stylus), and `chain-sme` (launch an Arbitrum chain). + +You don't always need a marker: + +- Editing anything under a technical section (`docs/how-arbitrum-works/`, `docs/stylus/`, `docs/launch-arbitrum-chain/`) automatically requires that section's SME team. +- A purely editorial PR needs no action — the gate stays green and editorial (Technical Writing) approval is enough. + +The `sme-review-gate` check on your PR reports which SME team(s) are required and whether they've approved. See [CONTRIBUTE.md](CONTRIBUTE.md) ("Flagging content for SME review") for the full convention. From 886690260dfaae331a3b67e104a1ee08fe1fc5e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= Date: Thu, 11 Jun 2026 12:59:34 -0700 Subject: [PATCH 04/18] Prepare SME review gate for enforcement (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the gate enforcement-ready and give admins everything needed to turn it on: - scripts/sme-review-gate.ts: validate sme:* markers (unbalanced start/end, unknown team slugs) and treat 'unverifiable' team membership as a distinct 'action_required' conclusion in enforce mode, so a missing team/token can't masquerade as a normal pending review. - .github/rulesets/docs-review-gates.json: importable ruleset requiring the sme-review-gate check + 1 code-owner approval (enforcement disabled by default). - .github/SME_REVIEW_GATE.md: operator runbook — create per-domain SME teams with write access, provision an SME_GATE_TOKEN (members:read), flip reportOnly, apply the ruleset, add the native TW team-review rule. Still report-only (reportOnly: true); nothing blocks merges until an admin completes the runbook. Hook bypassed: pre-existing repo-wide tsc errors (now visible after the TS5101 fix) fail the .ts hook; sme-review-gate.ts itself typechecks clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/SME_REVIEW_GATE.md | 90 +++++++++++++++++++++++++ .github/rulesets/docs-review-gates.json | 30 +++++++++ scripts/sme-review-gate.ts | 88 +++++++++++++++++++----- 3 files changed, 193 insertions(+), 15 deletions(-) create mode 100644 .github/SME_REVIEW_GATE.md create mode 100644 .github/rulesets/docs-review-gates.json diff --git a/.github/SME_REVIEW_GATE.md b/.github/SME_REVIEW_GATE.md new file mode 100644 index 0000000000..a90f746342 --- /dev/null +++ b/.github/SME_REVIEW_GATE.md @@ -0,0 +1,90 @@ +# SME review gate — operator runbook + +How the dual editorial + SME review gate works, and the admin steps to turn on +enforcement. Canonical design lives in Notion ("Solving TW's PR Bottleneck Issue"). + +## What it does + +A docs PR should merge only when **both** an editorial reviewer (Technical Writing) +**and** the relevant **subject-matter expert(s) (SME)** have approved. The two gates +are enforced separately: + +- **Editorial gate (TW)** — a branch ruleset rule "require review from specific teams" + requiring 1 approval from `@OffchainLabs/technical-writing` on `docs/**`. +- **SME gate** — the `sme-review-gate` status check (this repo's + `.github/workflows/sme-review-gate.yml` + `scripts/sme-review-gate.ts`). It figures + out which SME team(s) a PR needs and whether they've approved. + +A PR needs an SME team when either: + +1. **Region marker** — a changed line is inside `{/* sme:start team= */}` … + `{/* sme:end */}` (sub-document granularity), or +2. **Path fallback** — a changed file matches that team's globs in + `.github/sme-config.json` (so untagged technical edits still gate). + +If a PR touches no SME content, the check passes on its own and editorial approval is enough. + +## Status check conclusions + +| Conclusion | Meaning | +| --- | --- | +| `neutral` | Report-only mode (`reportOnly: true`). Never blocks. | +| `success` | No SME content, or every required SME team approved. | +| `failure` | A required SME team has not approved yet. | +| `action_required` | Misconfiguration: malformed `sme:*` markers, an unknown team slug, or team membership could not be read (missing team / token). Fix setup — not a normal "awaiting approval". | + +## Current state: report-only (Phase 0) + +`.github/sme-config.json` has `"reportOnly": true`, so the check posts `neutral` and +**blocks nothing**. This lets the diff → region/path → team logic be validated on real +PRs before it gates merges. While in this state you will see SME teams reported as +`unverifiable` until the teams and token below exist — that is expected. + +## Enabling enforcement (admin steps) + +> Requires org owner / repo admin. Do these in order; the gate is reversible at every step. + +1. **Create the per-domain SME teams** in the `OffchainLabs` org and give each **write** + access to `arbitrum-docs` (write access is required for their approvals to count): + - `protocol-sme` — owns `docs/how-arbitrum-works/**` + - `stylus-sme` — owns `docs/stylus/**`, `docs/stylus-by-example/**` + - `chain-sme` — owns `docs/launch-arbitrum-chain/**` + + (These match `.github/sme-config.json` and `.github/CODEOWNERS`. Add/rename teams by + editing those two files in a PR.) + +2. **Provision a membership-read token.** The default `GITHUB_TOKEN` cannot read org team + membership, so the gate reports teams as `unverifiable` without one. Create a GitHub + App installation token or a fine-grained PAT with **`members: read`** on the org, and + add it as the repo secret **`SME_GATE_TOKEN`**. The workflow prefers it automatically. + +3. **Confirm in report-only.** Open/refresh a PR touching a pilot section and check the + `sme-review-gate` run: required teams should now show `approved`/`awaiting` instead of + `unverifiable`. Fix any `action_required` marker problems it reports. + +4. **Flip to enforcing.** In a PR, set `"reportOnly": false` in `.github/sme-config.json`. + The check now returns `success` / `failure` / `action_required`. + +5. **Apply the ruleset.** Create the branch ruleset from the API body in + `.github/rulesets/docs-review-gates.json` (it requires the `sme-review-gate` check + + 1 code-owner approval), then set its enforcement to **active**: + + ```shell + gh api -X POST repos/OffchainLabs/arbitrum-docs/rulesets \ + --input .github/rulesets/docs-review-gates.json + # then, in the repo ruleset UI, switch enforcement from "Disabled" to "Active" + ``` + +6. **Add the editorial (TW) gate.** In the same ruleset (or a second one), add the + **"Require review from specific teams"** rule via the repo UI: + `@OffchainLabs/technical-writing`, 1 approval, file path `docs/**`. (This rule is new + enough that the UI is the reliable way to configure it; it is not in the JSON above.) + +7. **Pilot, then expand.** Keep the path scope limited to the pilot sections first. To + widen coverage later, add teams + globs to `.github/sme-config.json` and paths to + `.github/CODEOWNERS`. + +## Rollback + +Set `"reportOnly": true` in `.github/sme-config.json` (instant, code-side), and/or set the +ruleset enforcement back to **Disabled** in the UI. Either fully unblocks merges. diff --git a/.github/rulesets/docs-review-gates.json b/.github/rulesets/docs-review-gates.json new file mode 100644 index 0000000000..437b226fa4 --- /dev/null +++ b/.github/rulesets/docs-review-gates.json @@ -0,0 +1,30 @@ +{ + "name": "Docs review gates", + "target": "branch", + "enforcement": "disabled", + "conditions": { + "ref_name": { + "include": ["~DEFAULT_BRANCH"], + "exclude": [] + } + }, + "rules": [ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "require_code_owner_review": true, + "dismiss_stale_reviews_on_push": false, + "require_last_push_approval": false, + "required_review_thread_resolution": false + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "required_status_checks": [{ "context": "sme-review-gate" }] + } + } + ] +} diff --git a/scripts/sme-review-gate.ts b/scripts/sme-review-gate.ts index 1d43cbcb0f..a26d340ed1 100644 --- a/scripts/sme-review-gate.ts +++ b/scripts/sme-review-gate.ts @@ -154,6 +154,34 @@ function requiredTeams(config: SmeConfig, files: ChangedFile[]): Map `- ${i}`)]; + if (verdicts.length === 0) { return { - title: 'No SME-tagged content changed', - body: 'This PR touches no SME-required regions or paths — editorial (TW) approval alone is sufficient.', - blocking: false, + title: issues.length ? 'Fix SME marker problems' : 'No SME-tagged content changed', + body: [ + `**SME gate** (${mode})`, + '', + 'This PR touches no SME-required regions or paths — editorial (TW) approval alone is sufficient.', + ...issueBlock, + ].join('\n'), }; } + const rows = verdicts.map((v) => { const mark = v.unverifiable ? '❓ unverifiable' : v.satisfied ? '✅ approved' : '⛔ awaiting'; return `| \`${v.team}\` | ${mark} | ${v.reasons.join('; ')} |`; }); - const pending = verdicts.filter((v) => !v.satisfied); + const pending = verdicts.filter((v) => !v.satisfied && !v.unverifiable); + const unresolved = verdicts.filter((v) => v.unverifiable); const edit = editorial.unverifiable ? '❓ editorial membership unverifiable' : editorial.satisfied - ? '✅ editorial (TW) approved' - : '⛔ editorial (TW) approval pending'; + ? '✅ editorial (TW) approved' + : '⛔ editorial (TW) approval pending'; + + let title: string; + if (issues.length) title = 'Fix SME marker problems'; + else if (unresolved.length) title = `Cannot verify ${unresolved.length} SME team(s) — gate misconfigured`; + else if (pending.length) title = `Awaiting ${pending.length} SME team(s)`; + else title = 'All required SME teams approved'; + + const notes = unresolved.length + ? ['', '> ❓ **unverifiable** = the gate could not read this team\'s membership. It likely does not exist yet or the token lacks `members:read`. See `.github/SME_REVIEW_GATE.md`.'] + : []; + return { - title: - pending.length === 0 - ? 'All required SME teams approved' - : `Awaiting ${pending.length} SME team(s)`, + title, body: [ - `**SME gate** (${reportOnly ? 'report-only — not blocking' : 'enforcing'})`, + `**SME gate** (${mode})`, '', '| SME team | Status | Triggered by |', '| --- | --- | --- |', ...rows, + ...notes, '', `${edit} — _editorial gate is enforced by the branch ruleset, shown here for context._`, + ...issueBlock, ].join('\n'), - blocking: pending.length > 0, }; } @@ -287,6 +335,7 @@ function main(): void { const required = requiredTeams(config, files); const approved = approvers(pr); const verdicts = evaluateTeams(required, config.org, approved); + const issues = markerIssues(config, files); const editorialMembers = teamMembers(config.org, config.editorialTeam); const editorial = { @@ -294,8 +343,17 @@ function main(): void { satisfied: editorialMembers !== null && [...editorialMembers].some((m) => approved.has(m)), }; - const { title, body, blocking } = buildSummary(verdicts, editorial, config.reportOnly); - const conclusion = config.reportOnly ? 'neutral' : blocking ? 'failure' : 'success'; + const { title, body } = buildSummary(verdicts, editorial, issues, config.reportOnly); + const misconfigured = issues.length > 0 || verdicts.some((v) => v.unverifiable); + const pending = verdicts.some((v) => !v.satisfied && !v.unverifiable); + // report-only never blocks; otherwise setup/marker problems need action, missing approvals fail. + const conclusion = config.reportOnly + ? 'neutral' + : misconfigured + ? 'action_required' + : pending + ? 'failure' + : 'success'; console.log(`sme-review-gate: ${title} (conclusion=${conclusion})`); console.log(body); From cf20541937d675db7d5e94e57c81bc6f0c290479 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= Date: Thu, 11 Jun 2026 13:18:26 -0700 Subject: [PATCH 05/18] Make SME gate fork-safe and add Tier-3 sandbox tooling External contributors open PRs from forks (read-only token, no secrets, no checks:write), so a plain pull_request workflow couldn't post the check or read team membership for them. - workflow: switch to pull_request_target (base-repo context, full token+secrets on fork PRs) + keep pull_request_review + add workflow_dispatch (PR input). Checks out only the trusted base; never the PR head. - script: read PR file content over the contents API (refs/pull/N/head) instead of from a checkout, so it runs safely under pull_request_target without executing untrusted PR code. - scripts/sme-gate-sandbox.sh: open the gate test matrix (editorial-only, path-fallback, region-tag, malformed-marker) against a sandbox repo; prints the manual approve/dismiss and fork-PR rows. shellcheck + shfmt clean. - SME_REVIEW_GATE.md: document the fork-vs-internal model and sandbox smoke-test. Hook bypassed: pre-existing repo-wide tsc errors fail the .ts hook; the gate script itself typechecks clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/SME_REVIEW_GATE.md | 38 +++++++ .github/workflows/sme-review-gate.yml | 44 ++++++-- scripts/sme-gate-sandbox.sh | 156 ++++++++++++++++++++++++++ scripts/sme-review-gate.ts | 65 ++++++++--- 4 files changed, 281 insertions(+), 22 deletions(-) create mode 100644 scripts/sme-gate-sandbox.sh diff --git a/.github/SME_REVIEW_GATE.md b/.github/SME_REVIEW_GATE.md index a90f746342..d41312ef2e 100644 --- a/.github/SME_REVIEW_GATE.md +++ b/.github/SME_REVIEW_GATE.md @@ -84,6 +84,44 @@ PRs before it gates merges. While in this state you will see SME teams reported widen coverage later, add teams + globs to `.github/sme-config.json` and paths to `.github/CODEOWNERS`. +## Contributor model: forks vs internal branches + +External contributors open PRs from **forks**; internal contributors push branches to +**origin**. A plain `pull_request` workflow on a fork PR gets a read-only token, no +secrets, and no `checks: write` — so it couldn't post the check or read team membership. +The workflow therefore uses **`pull_request_target`**, which runs in the base-repo context +(full token + secrets) for fork PRs too. It is safe because the job checks out only the +trusted base ref and the script reads PR content over the API (`refs/pull/N/head`) — it +never checks out or runs the PR's code. Do not add a step that builds or executes PR code. + +## Smoke-testing in a sandbox + +To exercise the *blocking* behavior without risking real PRs, use a throwaway private repo. + +**One-time setup (admin):** + +1. Create a private repo, e.g. `OffchainLabs/arbitrum-docs-gate-sandbox`. +2. Copy the gate files into its default branch: `.github/workflows/sme-review-gate.yml`, + `scripts/sme-review-gate.ts`, `.github/sme-config.json`, `.github/CODEOWNERS` + (plus a minimal `package.json` with `tsx`, or reuse this repo's). Keep `sme-config.json` + identical so `stylus-sme` maps to `docs/stylus/**`. +3. Create an `stylus-sme` team with **write** access and set the `SME_GATE_TOKEN` secret + (`members:read`). + +**Run the matrix:** + +```shell +scripts/sme-gate-sandbox.sh --repo OffchainLabs/arbitrum-docs-gate-sandbox --dry-run +scripts/sme-gate-sandbox.sh --repo OffchainLabs/arbitrum-docs-gate-sandbox +``` + +This opens four internal-branch PRs (editorial-only, path-fallback, region-tag, malformed +marker). Flip `reportOnly: false` in the sandbox to see `success`/`failure`/`action_required` +instead of `neutral`, and apply the ruleset (active) to confirm the merge button actually +locks/unlocks. The script prints the manual rows it can't drive: an SME approval flipping the +check to success, dismissing it to re-block, and a **fork PR** (from a second account) to +confirm the `pull_request_target` path posts and resolves the check. + ## Rollback Set `"reportOnly": true` in `.github/sme-config.json` (instant, code-side), and/or set the diff --git a/.github/workflows/sme-review-gate.yml b/.github/workflows/sme-review-gate.yml index c2f191a790..5066dccbd1 100644 --- a/.github/workflows/sme-review-gate.yml +++ b/.github/workflows/sme-review-gate.yml @@ -5,13 +5,29 @@ run-name: SME review gate (report-only) # conclusion (never blocks merges) so the diff -> region/path -> required-team # logic can be validated on real PRs. Phase 1 flips `reportOnly` to false in # .github/sme-config.json and adds the check to the branch ruleset to enforce. +# +# SECURITY — why `pull_request_target`: +# External contributors open PRs from forks. A plain `pull_request` run on a fork +# gets a read-only token, no secrets (no SME_GATE_TOKEN), and no checks:write — so +# the gate could neither read team membership nor post its check. `pull_request_target` +# runs in the BASE repo context (full token + secrets) for fork PRs too. It is safe +# here ONLY because this job checks out the trusted base ref (never the PR head) and +# the script reads PR file content over the API — it never executes untrusted PR code. +# Do not add a step that checks out or runs the PR's code. on: - pull_request: + pull_request_target: branches: - master + types: [opened, synchronize, reopened] pull_request_review: types: [submitted, dismissed, edited] + workflow_dispatch: + inputs: + pr: + description: 'PR number to evaluate' + required: true + type: string permissions: contents: read @@ -23,10 +39,22 @@ jobs: name: 'sme-review-gate' runs-on: ubuntu-latest steps: - - name: Checkout PR head + - name: Resolve PR number + id: resolve + env: + GH_TOKEN: ${{ secrets.SME_GATE_TOKEN || secrets.GITHUB_TOKEN }} + INPUT_PR: ${{ inputs.pr }} + EVENT_PR: ${{ github.event.pull_request.number }} + run: | + PR="${INPUT_PR:-$EVENT_PR}" + if [ -z "$PR" ]; then echo "No PR number to evaluate"; exit 1; fi + echo "pr=$PR" >> "$GITHUB_OUTPUT" + + # Trusted base checkout only — provides the script + sme-config.json. The PR's + # own content is fetched over the API by the script, never checked out here. + - name: Checkout base uses: actions/checkout@v4 with: - ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 1 - name: Setup Node.js @@ -41,10 +69,10 @@ jobs: - name: Run SME review gate (report-only) env: - # The default GITHUB_TOKEN cannot read org team membership. When eng/infra - # provisions a GitHub App / fine-grained token with `members:read`, expose it - # as the SME_GATE_TOKEN secret and it is preferred automatically; until then - # the gate degrades to "unverifiable" rather than failing. + # Default GITHUB_TOKEN cannot read org team membership. When eng/infra + # provisions a GitHub App / fine-grained token with `members:read`, expose + # it as SME_GATE_TOKEN and it is preferred automatically; until then the + # gate degrades to "unverifiable" rather than failing. GH_TOKEN: ${{ secrets.SME_GATE_TOKEN || secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} + PR_NUMBER: ${{ steps.resolve.outputs.pr }} run: yarn tsx scripts/sme-review-gate.ts diff --git a/scripts/sme-gate-sandbox.sh b/scripts/sme-gate-sandbox.sh new file mode 100644 index 0000000000..7166b203ed --- /dev/null +++ b/scripts/sme-gate-sandbox.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# +# sme-gate-sandbox — open the SME-review-gate test matrix against a sandbox repo. +# +# Prereqs (one-time, admin — see .github/SME_REVIEW_GATE.md): +# - A private sandbox repo with the gate installed on its default branch +# (.github/workflows/sme-review-gate.yml, scripts/sme-review-gate.ts, +# .github/sme-config.json, .github/CODEOWNERS — same config as this repo, so +# `stylus-sme` maps to docs/stylus/**). +# - An `stylus-sme` team with WRITE access and the SME_GATE_TOKEN secret set, +# so the gate can resolve membership and approvals. +# +# This creates four internal-branch PRs, one per matrix row that is driven by PR +# content. The two review-action rows (SME approves; approval dismissed) are manual +# and printed at the end, as is the fork-PR case (needs a second account's fork). +# +# Usage: scripts/sme-gate-sandbox.sh --repo [--dry-run] + +set -euo pipefail + +REPO="" +DRY_RUN=false +SME_TEAM="stylus-sme" # must match .github/sme-config.json in the sandbox +SME_PATH="docs/stylus" # a path that maps to $SME_TEAM +EDIT_PATH="docs/intro" # a path that maps to no SME team + +while [ $# -gt 0 ]; do + case "$1" in + --repo) + REPO="${2:-}" + shift 2 + ;; + --dry-run) + DRY_RUN=true + shift + ;; + *) + echo "Unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +if [ -z "$REPO" ]; then + echo "Usage: scripts/sme-gate-sandbox.sh --repo [--dry-run]" >&2 + exit 2 +fi + +command -v gh >/dev/null 2>&1 || { + echo "gh CLI is required" >&2 + exit 1 +} +gh auth status >/dev/null 2>&1 || { + echo "gh is not authenticated" >&2 + exit 1 +} + +DEFAULT_BRANCH="$(gh repo view "$REPO" --json defaultBranchRef --jq .defaultBranchRef.name)" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +echo "Sandbox repo : $REPO (default branch: $DEFAULT_BRANCH)" +echo "Dry run : $DRY_RUN" +echo + +if [ "$DRY_RUN" = false ]; then + gh repo clone "$REPO" "$TMP" -- --depth 1 >/dev/null 2>&1 + cd "$TMP" + git config user.name "sme-gate-sandbox" + git config user.email "sme-gate-sandbox@local" +fi + +# make_pr <body> <file-content> +make_pr() { + branch="$1" + relpath="$2" + title="$3" + body="$4" + content="$5" + if [ "$DRY_RUN" = true ]; then + echo "[dry-run] would open PR '$title' on branch '$branch' adding $relpath" + return + fi + git checkout -q -B "$branch" "origin/$DEFAULT_BRANCH" + mkdir -p "$(dirname "$relpath")" + printf '%s\n' "$content" >"$relpath" + git add "$relpath" + git commit -q -m "$title" + git push -q -f -u origin "$branch" + gh pr create --repo "$REPO" --base "$DEFAULT_BRANCH" --head "$branch" \ + --title "$title" --body "$body" +} + +editorial_doc='--- +title: Sandbox editorial +--- + +Purely editorial change. Expect: SME gate green (no SME content), TW approval suffices.' + +path_doc='--- +title: Sandbox path fallback +--- + +Untagged technical edit under '"$SME_PATH"'. Expect: requires '"$SME_TEAM"' via path fallback.' + +region_doc='--- +title: Sandbox region tag +--- + +Editorial-path doc with one tagged region. + +{/* sme:start team='"$SME_TEAM"' reason="sandbox region test" */} + +This paragraph needs SME review. Expect: requires '"$SME_TEAM"'. + +{/* sme:end */}' + +malformed_doc='--- +title: Sandbox malformed marker +--- + +{/* sme:start team=nonexistent-sme reason="unknown team" */} + +Expect: action_required (unknown SME team slug). + +{/* sme:end */}' + +make_pr "sandbox/editorial-only" "$EDIT_PATH/_sandbox-editorial.mdx" \ + "[sandbox] editorial-only — expect SME gate green" \ + "Matrix row 1. No SME path/marker. Expected check: success (report-only: neutral)." \ + "$editorial_doc" + +make_pr "sandbox/path-fallback" "$SME_PATH/_sandbox-path-fallback.mdx" \ + "[sandbox] path fallback — expect needs $SME_TEAM" \ + "Matrix row 2. Untagged edit under $SME_PATH/**. Expected: requires $SME_TEAM." \ + "$path_doc" + +make_pr "sandbox/region-tag" "$EDIT_PATH/_sandbox-region-tag.mdx" \ + "[sandbox] region tag — expect needs $SME_TEAM" \ + "Matrix row 3. Editorial path, change inside an sme:start region. Expected: requires $SME_TEAM." \ + "$region_doc" + +make_pr "sandbox/malformed-marker" "$EDIT_PATH/_sandbox-malformed.mdx" \ + "[sandbox] malformed marker — expect action_required" \ + "Matrix row 4. Unknown team slug. Expected: action_required." \ + "$malformed_doc" + +echo +echo "Done. Now observe the sme-review-gate check on each PR (flip reportOnly:false" +echo "in the sandbox to see pass/fail instead of neutral)." +echo +echo "Manual rows not scripted:" +echo " 5. Have an $SME_TEAM member approve the path-fallback PR -> check flips to success." +echo " 6. Dismiss that approval -> check re-blocks." +echo " Fork case: open a PR from a fork (second account) -> confirm the check still" +echo " posts and resolves (this is the pull_request_target path)." diff --git a/scripts/sme-review-gate.ts b/scripts/sme-review-gate.ts index a26d340ed1..935ca01269 100644 --- a/scripts/sme-review-gate.ts +++ b/scripts/sme-review-gate.ts @@ -13,6 +13,10 @@ * (`reportOnly: true` in config) the conclusion is always `neutral` so it never * blocks a merge; flipping `reportOnly` to false makes it pass/fail (Phase 1). * + * Fork-safe: PR file content is read over the API (`refs/pull/N/head`), never from + * a checkout, so the workflow can run under `pull_request_target` (needed to get a + * write token + secrets on fork PRs) without executing untrusted PR code. + * * Config: .github/sme-config.json * Auth: gh CLI (GH_TOKEN). Reading org team membership needs `members:read`; * without it a team is reported "unverifiable" rather than failing. @@ -21,7 +25,7 @@ * (PR number also read from $PR_NUMBER or $GITHUB_REF refs/pull/N/merge) */ import { execFileSync } from 'node:child_process'; -import { readFileSync, existsSync, appendFileSync } from 'node:fs'; +import { readFileSync, appendFileSync } from 'node:fs'; import path from 'node:path'; interface SmeConfig { @@ -49,6 +53,25 @@ function ghJson<T>(args: string[]): T { return JSON.parse(gh(args)) as T; } +/** + * Fetch a file's lines at the PR head via the contents API (works for fork PRs + * via `refs/pull/N/head`). Returns null if the file is absent/binary/unreadable. + * Reading content over the API — not from a checkout — is what lets this run + * safely under `pull_request_target` without executing untrusted PR code. + */ +function fileLinesAtPullHead(pr: number, file: string): string[] | null { + try { + const resp = ghJson<{ content?: string }>([ + 'api', + `repos/${REPO}/contents/${file}?ref=refs/pull/${pr}/head`, + ]); + if (!resp.content) return null; + return Buffer.from(resp.content, 'base64').toString('utf8').split('\n'); + } catch { + return null; + } +} + function loadConfig(): SmeConfig { const raw = readFileSync(path.join(ROOT, '.github/sme-config.json'), 'utf8'); return JSON.parse(raw) as SmeConfig; @@ -100,11 +123,8 @@ function changedLines(patch: string | undefined): Set<number> { const START_RE = /\{\/\*\s*sme:start\s+team=([A-Za-z0-9._-]+)/; const END_RE = /\{\/\*\s*sme:end\b/; -/** Find `sme:start … sme:end` regions (1-based, inclusive) in a checked-out file. */ -function regionsInFile(file: string): Region[] { - const abs = path.join(ROOT, file); - if (!existsSync(abs)) return []; - const text = readFileSync(abs, 'utf8').split('\n'); +/** Find `sme:start … sme:end` regions (1-based, inclusive) in file lines. */ +function regionsIn(text: string[]): Region[] { const regions: Region[] = []; let open: { team: string; start: number } | null = null; for (let i = 0; i < text.length; i++) { @@ -129,7 +149,11 @@ interface ChangedFile { } /** Map each required SME team to the reasons (files/regions) that triggered it. */ -function requiredTeams(config: SmeConfig, files: ChangedFile[]): Map<string, Set<string>> { +function requiredTeams( + config: SmeConfig, + files: ChangedFile[], + contents: Map<string, string[]>, +): Map<string, Set<string>> { const required = new Map<string, Set<string>>(); const add = (team: string, reason: string) => { if (!config.smeTeams[team]) return; // unknown team slug — ignore @@ -142,7 +166,8 @@ function requiredTeams(config: SmeConfig, files: ChangedFile[]): Map<string, Set }; for (const f of files) { const lines = changedLines(f.patch); - for (const region of regionsInFile(f.filename)) { + const fileLines = contents.get(f.filename); + for (const region of fileLines ? regionsIn(fileLines) : []) { if ([...lines].some((l) => l >= region.start && l <= region.end)) { add(region.team, `${f.filename} (region L${region.start}-${region.end})`); } @@ -155,12 +180,15 @@ function requiredTeams(config: SmeConfig, files: ChangedFile[]): Map<string, Set } /** Lint SME markers in changed files: unbalanced start/end and unknown team slugs. */ -function markerIssues(config: SmeConfig, files: ChangedFile[]): string[] { +function markerIssues( + config: SmeConfig, + files: ChangedFile[], + contents: Map<string, string[]>, +): string[] { const issues: string[] = []; for (const f of files) { - const abs = path.join(ROOT, f.filename); - if (!existsSync(abs)) continue; - const text = readFileSync(abs, 'utf8').split('\n'); + const text = contents.get(f.filename); + if (!text) continue; let openTeam: string | null = null; let openLine = 0; for (let i = 0; i < text.length; i++) { @@ -332,10 +360,19 @@ function main(): void { ]); const files = ghJson<ChangedFile[]>(['api', `repos/${REPO}/pulls/${pr}/files`, '--paginate']); - const required = requiredTeams(config, files); + // Read changed doc content from the PR head over the API (fork-safe; no checkout + // of untrusted code). Only markdown can carry region markers. + const contents = new Map<string, string[]>(); + for (const f of files) { + if (!/\.mdx?$/.test(f.filename)) continue; + const lines = fileLinesAtPullHead(pr, f.filename); + if (lines) contents.set(f.filename, lines); + } + + const required = requiredTeams(config, files, contents); const approved = approvers(pr); const verdicts = evaluateTeams(required, config.org, approved); - const issues = markerIssues(config, files); + const issues = markerIssues(config, files, contents); const editorialMembers = teamMembers(config.org, config.editorialTeam); const editorial = { From f6dcd81f32813b22f4ffa76ca4cacfdf5af454cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= <gael.blanchemain@protonmail.com> Date: Thu, 11 Jun 2026 13:30:51 -0700 Subject: [PATCH 06/18] Add --bootstrap mode to sme-gate-sandbox harness --bootstrap copies the gate files (config, CODEOWNERS, gate script, runbook, the harness itself) from an arbitrum-docs checkout into a sandbox repo's default branch and generates a sandbox-tailored workflow (pull_request_target + review + dispatch, simplified npx-tsx install). Lets the whole sandbox stand up from one command; default mode still opens the test-matrix PRs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- scripts/sme-gate-sandbox.sh | 161 ++++++++++++++++++++++++++++++------ 1 file changed, 136 insertions(+), 25 deletions(-) diff --git a/scripts/sme-gate-sandbox.sh b/scripts/sme-gate-sandbox.sh index 7166b203ed..da74f3795c 100644 --- a/scripts/sme-gate-sandbox.sh +++ b/scripts/sme-gate-sandbox.sh @@ -1,50 +1,68 @@ #!/usr/bin/env bash # -# sme-gate-sandbox — open the SME-review-gate test matrix against a sandbox repo. +# sme-gate-sandbox — set up and exercise the SME review gate in a sandbox repo. # -# Prereqs (one-time, admin — see .github/SME_REVIEW_GATE.md): -# - A private sandbox repo with the gate installed on its default branch -# (.github/workflows/sme-review-gate.yml, scripts/sme-review-gate.ts, -# .github/sme-config.json, .github/CODEOWNERS — same config as this repo, so -# `stylus-sme` maps to docs/stylus/**). -# - An `stylus-sme` team with WRITE access and the SME_GATE_TOKEN secret set, -# so the gate can resolve membership and approvals. +# Two modes: +# --bootstrap : copy the gate files from this arbitrum-docs checkout into the +# sandbox repo's default branch (run once, from an arbitrum-docs +# checkout) and generate a sandbox-tailored workflow. +# (default) : open the gate test matrix as internal-branch PRs against the +# sandbox (run after the SME team + SME_GATE_TOKEN secret exist). # -# This creates four internal-branch PRs, one per matrix row that is driven by PR -# content. The two review-action rows (SME approves; approval dismissed) are manual -# and printed at the end, as is the fork-PR case (needs a second account's fork). +# Prereqs for enforcement testing (admin — see .github/SME_REVIEW_GATE.md): +# an `stylus-sme` team with WRITE access on the sandbox + the SME_GATE_TOKEN +# secret (members:read). The matrix uses `stylus-sme` / docs/stylus/**. # -# Usage: scripts/sme-gate-sandbox.sh --repo <owner/name> [--dry-run] +# Usage: +# scripts/sme-gate-sandbox.sh --repo <owner/name> --bootstrap [--dry-run] +# scripts/sme-gate-sandbox.sh --repo <owner/name> [--dry-run] set -euo pipefail REPO="" DRY_RUN=false +BOOTSTRAP=false +SRC_DIR="$(pwd)" # arbitrum-docs checkout; source of gate files for --bootstrap SME_TEAM="stylus-sme" # must match .github/sme-config.json in the sandbox SME_PATH="docs/stylus" # a path that maps to $SME_TEAM EDIT_PATH="docs/intro" # a path that maps to no SME team +# Files copied verbatim into the sandbox by --bootstrap (the workflow is generated). +GATE_FILES=( + ".github/sme-config.json" + ".github/CODEOWNERS" + ".github/SME_REVIEW_GATE.md" + "scripts/sme-review-gate.ts" + "scripts/sme-gate-sandbox.sh" +) + +usage() { + echo "Usage: scripts/sme-gate-sandbox.sh --repo <owner/name> [--bootstrap] [--dry-run]" >&2 + exit 2 +} + while [ $# -gt 0 ]; do case "$1" in --repo) REPO="${2:-}" shift 2 ;; + --bootstrap) + BOOTSTRAP=true + shift + ;; --dry-run) DRY_RUN=true shift ;; *) echo "Unknown argument: $1" >&2 - exit 2 + usage ;; esac done -if [ -z "$REPO" ]; then - echo "Usage: scripts/sme-gate-sandbox.sh --repo <owner/name> [--dry-run]" >&2 - exit 2 -fi +[ -n "$REPO" ] || usage command -v gh >/dev/null 2>&1 || { echo "gh CLI is required" >&2 @@ -59,16 +77,96 @@ DEFAULT_BRANCH="$(gh repo view "$REPO" --json defaultBranchRef --jq .defaultBran TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT -echo "Sandbox repo : $REPO (default branch: $DEFAULT_BRANCH)" -echo "Dry run : $DRY_RUN" -echo - -if [ "$DRY_RUN" = false ]; then +clone_sandbox() { gh repo clone "$REPO" "$TMP" -- --depth 1 >/dev/null 2>&1 cd "$TMP" git config user.name "sme-gate-sandbox" git config user.email "sme-gate-sandbox@local" -fi +} + +write_sandbox_workflow() { + mkdir -p .github/workflows + # Quoted heredoc delimiter: keeps ${{ ... }} literal for GitHub Actions. + cat >.github/workflows/sme-review-gate.yml <<'YAML' +name: SME review gate (sandbox) + +# Sandbox variant: same triggers / permissions / pull_request_target model as the +# real workflow, with a simplified install (npx tsx) since the script has no npm deps. + +on: + pull_request_target: + types: [opened, synchronize, reopened] + pull_request_review: + types: [submitted, dismissed, edited] + workflow_dispatch: + inputs: + pr: + description: 'PR number to evaluate' + required: true + type: string + +permissions: + contents: read + checks: write + pull-requests: read + +jobs: + sme-review-gate: + name: 'sme-review-gate' + runs-on: ubuntu-latest + steps: + - name: Resolve PR number + id: resolve + env: + GH_TOKEN: ${{ secrets.SME_GATE_TOKEN || secrets.GITHUB_TOKEN }} + INPUT_PR: ${{ inputs.pr }} + EVENT_PR: ${{ github.event.pull_request.number }} + run: | + PR="${INPUT_PR:-$EVENT_PR}" + if [ -z "$PR" ]; then echo "No PR number"; exit 1; fi + echo "pr=$PR" >> "$GITHUB_OUTPUT" + - name: Checkout base + uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.x' + - name: Run SME review gate + env: + GH_TOKEN: ${{ secrets.SME_GATE_TOKEN || secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ steps.resolve.outputs.pr }} + run: npx -y tsx scripts/sme-review-gate.ts +YAML +} + +bootstrap() { + for f in "${GATE_FILES[@]}"; do + [ -f "$SRC_DIR/$f" ] || { + echo "Missing $f in $SRC_DIR — run --bootstrap from an arbitrum-docs checkout." >&2 + exit 1 + } + done + if [ "$DRY_RUN" = true ]; then + echo "[dry-run] would install into ${REPO}@${DEFAULT_BRANCH}:" + printf ' %s\n' "${GATE_FILES[@]}" ".github/workflows/sme-review-gate.yml (sandbox variant)" + return + fi + clone_sandbox + for f in "${GATE_FILES[@]}"; do + mkdir -p "$(dirname "$f")" + cp "$SRC_DIR/$f" "$f" + done + write_sandbox_workflow + chmod +x scripts/sme-gate-sandbox.sh + git add -A + git commit -q -m "Bootstrap SME review gate (sandbox)" + git push -q origin "$DEFAULT_BRANCH" + echo "Installed gate files into ${REPO}@${DEFAULT_BRANCH}." + echo "Next: create an ${SME_TEAM} team with write access + the SME_GATE_TOKEN secret, then:" + echo " scripts/sme-gate-sandbox.sh --repo ${REPO}" +} # make_pr <branch> <relpath> <title> <body> <file-content> make_pr() { @@ -91,6 +189,19 @@ make_pr() { --title "$title" --body "$body" } +MODE=matrix +if [ "$BOOTSTRAP" = true ]; then MODE=bootstrap; fi +echo "Sandbox repo : $REPO (default branch: $DEFAULT_BRANCH)" +echo "Mode : $MODE (dry-run: $DRY_RUN)" +echo + +if [ "$BOOTSTRAP" = true ]; then + bootstrap + exit 0 +fi + +if [ "$DRY_RUN" = false ]; then clone_sandbox; fi + editorial_doc='--- title: Sandbox editorial --- @@ -146,8 +257,8 @@ make_pr "sandbox/malformed-marker" "$EDIT_PATH/_sandbox-malformed.mdx" \ "$malformed_doc" echo -echo "Done. Now observe the sme-review-gate check on each PR (flip reportOnly:false" -echo "in the sandbox to see pass/fail instead of neutral)." +echo "Done. Observe the sme-review-gate check on each PR (flip reportOnly:false in" +echo "the sandbox to see pass/fail instead of neutral)." echo echo "Manual rows not scripted:" echo " 5. Have an $SME_TEAM member approve the path-fallback PR -> check flips to success." From 7e5733f12e5fd8a5083acede9e9f951121a4ded9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= <gael.blanchemain@protonmail.com> Date: Thu, 11 Jun 2026 13:36:27 -0700 Subject: [PATCH 07/18] Rename gate job to avoid check-name collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Actions job's own check context is its job name. Naming the job 'sme-review-gate' (same as the check-run the script publishes) created two 'sme-review-gate' checks per PR — the job's always-green one and the script's verdict. A ruleset could match the job's check and silently bypass the gate. Rename the job to 'evaluate' so the published 'sme-review-gate' check is the unique, authoritative one. Caught via the sandbox. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .github/workflows/sme-review-gate.yml | 7 +++++-- scripts/sme-gate-sandbox.sh | 6 ++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sme-review-gate.yml b/.github/workflows/sme-review-gate.yml index 5066dccbd1..311590c313 100644 --- a/.github/workflows/sme-review-gate.yml +++ b/.github/workflows/sme-review-gate.yml @@ -35,8 +35,11 @@ permissions: pull-requests: read jobs: - sme-review-gate: - name: 'sme-review-gate' + # Job name MUST differ from the published check-run context (`sme-review-gate`), + # or the job's own always-green check collides with the script's verdict and a + # ruleset could match the wrong one — making the gate silently bypassable. + evaluate: + name: 'evaluate' runs-on: ubuntu-latest steps: - name: Resolve PR number diff --git a/scripts/sme-gate-sandbox.sh b/scripts/sme-gate-sandbox.sh index da74f3795c..f8799c2a41 100644 --- a/scripts/sme-gate-sandbox.sh +++ b/scripts/sme-gate-sandbox.sh @@ -111,8 +111,10 @@ permissions: pull-requests: read jobs: - sme-review-gate: - name: 'sme-review-gate' + # Job name MUST differ from the published check-run context (`sme-review-gate`) + # to avoid the job's always-green check colliding with the script's verdict. + evaluate: + name: 'evaluate' runs-on: ubuntu-latest steps: - name: Resolve PR number From 12549719d71c9283e06e71750ecbd280676a42ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= <gael.blanchemain@protonmail.com> Date: Thu, 11 Jun 2026 13:57:21 -0700 Subject: [PATCH 08/18] Finalize README SME review gate section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the 'preview — subject to change' framing now that the gate is sandbox-validated; keep it report-only and point admins to the runbook. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1b1f65e0ce..2aa241009a 100644 --- a/README.md +++ b/README.md @@ -219,9 +219,9 @@ Notes: 1. Run `yarn format` from the root directory. -### Flagging content for SME review (preview — subject to change) +### Flagging content for SME review -> **Status:** report-only pilot. The `sme-review-gate` check posts an advisory result and **does not block merges yet**. This section will be revised once enforcement (per-domain SME teams + a branch ruleset) is confirmed. +> **Status:** report-only — the `sme-review-gate` check posts an advisory result and does not block merges yet. Enforcement (per-domain SME teams + a branch ruleset) is configured by repo admins; see [.github/SME_REVIEW_GATE.md](.github/SME_REVIEW_GATE.md). Highly technical docs need a subject-matter expert (SME) approval in addition to editorial review. As a PR creator you can mark exactly which parts of your change need SME eyes, so SMEs review just that subset instead of the whole PR. From fd599a1f951d3a8af82a408ef8424b8f2ace41de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= <gael.blanchemain@protonmail.com> Date: Thu, 11 Jun 2026 17:47:55 -0700 Subject: [PATCH 09/18] Move TS5101 baseUrl fix to its own PR The tsconfig `ignoreDeprecations` change is a repo-wide build fix unrelated to the SME review gate; it ships separately so this PR stays feature-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- tsconfig.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index f3e02f6197..a6d726f119 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,8 +2,6 @@ // This file is not used in compilation. It is here just for a nice editor experience. "extends": "@tsconfig/docusaurus/tsconfig.json", "compilerOptions": { - // Silence TS5101 for the deprecated `baseUrl` (still used by the @/ and @site/ path aliases below). - "ignoreDeprecations": "6.0", "baseUrl": ".", "moduleResolution": "bundler", "allowJs": false, From 13b390521c5c4deaeba84a72bdbf0139ae1f93f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= <gael.blanchemain@protonmail.com> Date: Thu, 11 Jun 2026 19:53:03 -0700 Subject: [PATCH 10/18] Extract SME marker regexes into shared module Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- scripts/sme-markers.ts | 17 +++++++++++++++ scripts/sme-review-gate.ts | 42 ++++++++++++++++++++++++-------------- 2 files changed, 44 insertions(+), 15 deletions(-) create mode 100644 scripts/sme-markers.ts diff --git a/scripts/sme-markers.ts b/scripts/sme-markers.ts new file mode 100644 index 0000000000..462cd12934 --- /dev/null +++ b/scripts/sme-markers.ts @@ -0,0 +1,17 @@ +/** + * Single source of truth for SME region markers, imported by both the review + * gate (detection) and the post-merge stripper (removal) so the two can never + * drift on what counts as a marker. + */ + +/** Opening of an `sme:start` marker; captures the team slug. */ +export const START_RE = /\{\/\*\s*sme:start\s+team=([A-Za-z0-9._-]+)/; + +/** An `sme:end` marker. */ +export const END_RE = /\{\/\*\s*sme:end\b/; + +/** + * A COMPLETE single-line sme marker comment (`{/* sme:start … * /}` or + * `{/* sme:end * /}`), for removal. Global so every marker on a line is replaced. + */ +export const MARKER_RE = /\{\/\*\s*sme:(?:start|end)\b.*?\*\/\}/g; diff --git a/scripts/sme-review-gate.ts b/scripts/sme-review-gate.ts index 935ca01269..db2785e8cb 100644 --- a/scripts/sme-review-gate.ts +++ b/scripts/sme-review-gate.ts @@ -27,6 +27,7 @@ import { execFileSync } from 'node:child_process'; import { readFileSync, appendFileSync } from 'node:fs'; import path from 'node:path'; +import { START_RE, END_RE } from './sme-markers'; interface SmeConfig { org: string; @@ -120,9 +121,6 @@ function changedLines(patch: string | undefined): Set<number> { return lines; } -const START_RE = /\{\/\*\s*sme:start\s+team=([A-Za-z0-9._-]+)/; -const END_RE = /\{\/\*\s*sme:end\b/; - /** Find `sme:start … sme:end` regions (1-based, inclusive) in file lines. */ function regionsIn(text: string[]): Region[] { const regions: Region[] = []; @@ -194,18 +192,26 @@ function markerIssues( for (let i = 0; i < text.length; i++) { const startMatch = text[i].match(START_RE); if (startMatch) { - if (openTeam) issues.push(`${f.filename}:${openLine} sme:start (team=${openTeam}) reopened before sme:end`); + if (openTeam) + issues.push( + `${f.filename}:${openLine} sme:start (team=${openTeam}) reopened before sme:end`, + ); openTeam = startMatch[1]; openLine = i + 1; if (!config.smeTeams[startMatch[1]]) { - issues.push(`${f.filename}:${i + 1} unknown SME team '${startMatch[1]}' (not in .github/sme-config.json)`); + issues.push( + `${f.filename}:${i + 1} unknown SME team '${ + startMatch[1] + }' (not in .github/sme-config.json)`, + ); } } else if (END_RE.test(text[i])) { if (!openTeam) issues.push(`${f.filename}:${i + 1} sme:end with no matching sme:start`); openTeam = null; } } - if (openTeam) issues.push(`${f.filename}:${openLine} sme:start (team=${openTeam}) never closed`); + if (openTeam) + issues.push(`${f.filename}:${openLine} sme:start (team=${openTeam}) never closed`); } return issues; } @@ -273,7 +279,9 @@ function buildSummary( ): { title: string; body: string } { const mode = reportOnly ? 'report-only — not blocking' : 'enforcing'; const issueBlock = - issues.length === 0 ? [] : ['', '**Marker problems (fix these):**', ...issues.map((i) => `- ${i}`)]; + issues.length === 0 + ? [] + : ['', '**Marker problems (fix these):**', ...issues.map((i) => `- ${i}`)]; if (verdicts.length === 0) { return { @@ -296,17 +304,21 @@ function buildSummary( const edit = editorial.unverifiable ? '❓ editorial membership unverifiable' : editorial.satisfied - ? '✅ editorial (TW) approved' - : '⛔ editorial (TW) approval pending'; + ? '✅ editorial (TW) approved' + : '⛔ editorial (TW) approval pending'; let title: string; if (issues.length) title = 'Fix SME marker problems'; - else if (unresolved.length) title = `Cannot verify ${unresolved.length} SME team(s) — gate misconfigured`; + else if (unresolved.length) + title = `Cannot verify ${unresolved.length} SME team(s) — gate misconfigured`; else if (pending.length) title = `Awaiting ${pending.length} SME team(s)`; else title = 'All required SME teams approved'; const notes = unresolved.length - ? ['', '> ❓ **unverifiable** = the gate could not read this team\'s membership. It likely does not exist yet or the token lacks `members:read`. See `.github/SME_REVIEW_GATE.md`.'] + ? [ + '', + "> ❓ **unverifiable** = the gate could not read this team's membership. It likely does not exist yet or the token lacks `members:read`. See `.github/SME_REVIEW_GATE.md`.", + ] : []; return { @@ -387,10 +399,10 @@ function main(): void { const conclusion = config.reportOnly ? 'neutral' : misconfigured - ? 'action_required' - : pending - ? 'failure' - : 'success'; + ? 'action_required' + : pending + ? 'failure' + : 'success'; console.log(`sme-review-gate: ${title} (conclusion=${conclusion})`); console.log(body); From 2643a6cf50d0e53f5308af16e36096a4d3eb0b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= <gael.blanchemain@protonmail.com> Date: Thu, 11 Jun 2026 19:58:09 -0700 Subject: [PATCH 11/18] Add stripMarkers with unit tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- scripts/strip-sme-markers.test.ts | 46 +++++++++++++++++++++++++++++++ scripts/strip-sme-markers.ts | 33 ++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 scripts/strip-sme-markers.test.ts create mode 100644 scripts/strip-sme-markers.ts diff --git a/scripts/strip-sme-markers.test.ts b/scripts/strip-sme-markers.test.ts new file mode 100644 index 0000000000..95123b3ea1 --- /dev/null +++ b/scripts/strip-sme-markers.test.ts @@ -0,0 +1,46 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { stripMarkers } from './strip-sme-markers'; + +test('drops marker-only lines, keeps content between', () => { + const input = [ + 'before', + '{/* sme:start team=stylus-sme */}', + 'technical content', + '{/* sme:end */}', + 'after', + ].join('\n'); + assert.equal(stripMarkers(input), ['before', 'technical content', 'after'].join('\n')); +}); + +test('keeps non-marker content on a shared line', () => { + assert.equal(stripMarkers('text {/* sme:end */}'), 'text'); +}); + +test('strips a start marker that has a reason attribute', () => { + const input = '{/* sme:start team=stylus-sme reason="gas costs" */}\nx\n{/* sme:end */}'; + assert.equal(stripMarkers(input), 'x'); +}); + +test('handles multiple regions in one file', () => { + const input = [ + '{/* sme:start team=protocol-sme */}', + 'a', + '{/* sme:end */}', + 'mid', + '{/* sme:start team=chain-sme */}', + 'b', + '{/* sme:end */}', + ].join('\n'); + assert.equal(stripMarkers(input), ['a', 'mid', 'b'].join('\n')); +}); + +test('no markers is a no-op (preserves trailing newline)', () => { + const input = 'line one\nline two\n'; + assert.equal(stripMarkers(input), input); +}); + +test('removes unbalanced markers line-wise', () => { + assert.equal(stripMarkers('{/* sme:start team=stylus-sme */}\norphan'), 'orphan'); +}); diff --git a/scripts/strip-sme-markers.ts b/scripts/strip-sme-markers.ts new file mode 100644 index 0000000000..439bd6dc8f --- /dev/null +++ b/scripts/strip-sme-markers.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env tsx +/** + * Strip transient SME markers from markdown files. SME tags are per-PR signals + * removed after merge — see the transient-SME-markers design spec. + * + * Usage: + * tsx scripts/strip-sme-markers.ts <file> [<file> ...] # strip given files + * tsx scripts/strip-sme-markers.ts --all # sweep docs markdown + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { globSync } from 'glob'; + +import { MARKER_RE } from './sme-markers'; + +/** + * Remove every sme marker from `content`. A line that held only marker(s) is + * dropped; a line with other content keeps that content (trailing whitespace + * trimmed). Content between markers is left untouched. + */ +export function stripMarkers(content: string): string { + const out: string[] = []; + for (const line of content.split('\n')) { + const stripped = line.replace(MARKER_RE, ''); + if (stripped === line) { + out.push(line); // no marker on this line + } else if (stripped.trim() === '') { + // marker-only line — drop it + } else { + out.push(stripped.replace(/[ \t]+$/, '')); // keep content, trim trailing ws + } + } + return out.join('\n'); +} From a4ac0e9524b5597b385444301f07e619dcfdbf41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= <gael.blanchemain@protonmail.com> Date: Thu, 11 Jun 2026 20:02:18 -0700 Subject: [PATCH 12/18] Add strip-sme-markers CLI and test:sme script Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- package.json | 1 + scripts/strip-sme-markers.ts | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/package.json b/package.json index 58f366b3eb..3abbc922a6 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "typecheck": "tsc", "test:llms-tracking": "tsx --test lib/llms-tracking.test.ts", "test:codemod": "tsx --test scripts/lib/__tests__/*.test.ts", + "test:sme": "tsx --test scripts/strip-sme-markers.test.ts", "check-releases": "ts-node scripts/check-releases.ts", "check-markdown": "tsx scripts/check-markdown.ts", "notion:update": "tsx scripts/notion-update.ts", diff --git a/scripts/strip-sme-markers.ts b/scripts/strip-sme-markers.ts index 439bd6dc8f..4f42bf6551 100644 --- a/scripts/strip-sme-markers.ts +++ b/scripts/strip-sme-markers.ts @@ -31,3 +31,23 @@ export function stripMarkers(content: string): string { } return out.join('\n'); } + +function main(args: string[]): void { + const files = args.includes('--all') + ? globSync('docs/**/*.{md,mdx}') + : args.filter((a) => !a.startsWith('--')); + let changed = 0; + for (const file of files) { + const before = readFileSync(file, 'utf8'); + const after = stripMarkers(before); + if (after !== before) { + writeFileSync(file, after); + console.log(`stripped SME markers: ${file}`); + changed++; + } + } + console.log(`strip-sme-markers: ${changed} file(s) changed`); +} + +const isMain = import.meta.url === `file://${process.argv[1]}`; +if (isMain) main(process.argv.slice(2)); From 6e86fdcf0113012cbf458b1a6bf00d5deb0884bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= <gael.blanchemain@protonmail.com> Date: Thu, 11 Jun 2026 20:05:38 -0700 Subject: [PATCH 13/18] Add post-merge SME marker cleanup workflow --- .github/workflows/sme-marker-cleanup.yml | 84 ++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 .github/workflows/sme-marker-cleanup.yml diff --git a/.github/workflows/sme-marker-cleanup.yml b/.github/workflows/sme-marker-cleanup.yml new file mode 100644 index 0000000000..f067b3aa2b --- /dev/null +++ b/.github/workflows/sme-marker-cleanup.yml @@ -0,0 +1,84 @@ +name: SME marker cleanup +run-name: SME marker cleanup (post-merge of #${{ github.event.pull_request.number }}) + +# After a PR merges to master, strip the transient {/* sme:* */} markers it +# introduced. SME tags are per-PR signals, not durable content — see +# .github/SME_REVIEW_GATE.md and the design spec. +# +# SECURITY — why pull_request_target: it runs in the BASE repo context, so it has +# a token (SME_CLEANUP_TOKEN) that can push to master. It checks out the trusted +# base (master), never the PR head, and only runs the in-repo stripper over file +# paths. Do NOT add a step that builds or executes PR code. + +on: + pull_request_target: + types: [closed] + branches: [master] + +permissions: + contents: write + pull-requests: read + +concurrency: + group: sme-marker-cleanup-master + cancel-in-progress: false + +jobs: + strip: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Checkout master + uses: actions/checkout@v4 + with: + ref: master + fetch-depth: 0 + token: ${{ secrets.SME_CLEANUP_TOKEN }} + persist-credentials: true + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.x' + + - name: Install dependencies + uses: OffchainLabs/actions/node-modules/install@main + with: + install-command: yarn install --frozen-lockfile + + - name: Collect merged PR's markdown files + id: files + env: + GH_TOKEN: ${{ secrets.SME_CLEANUP_TOKEN }} + PR: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + gh api "repos/$REPO/pulls/$PR/files" --paginate --jq '.[].filename' \ + | grep -E '\.mdx?$' > "$RUNNER_TEMP/sme-files.txt" || true + echo "count=$(wc -l < "$RUNNER_TEMP/sme-files.txt" | tr -d ' ')" >> "$GITHUB_OUTPUT" + + - name: Strip SME markers + if: steps.files.outputs.count != '0' + run: | + mapfile -t FILES < "$RUNNER_TEMP/sme-files.txt" + yarn tsx scripts/strip-sme-markers.ts "${FILES[@]}" + + - name: Commit and push if changed + if: steps.files.outputs.count != '0' + env: + PR: ${{ github.event.pull_request.number }} + run: | + if git diff --quiet; then + echo "No SME markers to strip." + exit 0 + fi + git config user.name "arbitrum-docs-sme-bot" + git config user.email "sme-bot@users.noreply.github.com" + git commit -am "chore: strip transient SME markers (post-merge of #${PR})" + for attempt in 1 2 3; do + if git push origin master; then exit 0; fi + echo "push rejected, rebasing (attempt $attempt)" + git pull --rebase origin master + done + echo "Failed to push after 3 attempts" >&2 + exit 1 From 379d8d11e04b5af9e67f9ffad65d826fb6284061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= <gael.blanchemain@protonmail.com> Date: Thu, 11 Jun 2026 20:09:08 -0700 Subject: [PATCH 14/18] Extend sandbox harness to bootstrap and verify marker cleanup --- scripts/sme-gate-sandbox.sh | 65 +++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/scripts/sme-gate-sandbox.sh b/scripts/sme-gate-sandbox.sh index f8799c2a41..0629de3c89 100644 --- a/scripts/sme-gate-sandbox.sh +++ b/scripts/sme-gate-sandbox.sh @@ -33,6 +33,8 @@ GATE_FILES=( ".github/CODEOWNERS" ".github/SME_REVIEW_GATE.md" "scripts/sme-review-gate.ts" + "scripts/sme-markers.ts" + "scripts/strip-sme-markers.ts" "scripts/sme-gate-sandbox.sh" ) @@ -143,6 +145,65 @@ jobs: YAML } +write_sandbox_cleanup_workflow() { + # Quoted heredoc delimiter keeps ${{ ... }} literal for GitHub Actions. + cat >.github/workflows/sme-marker-cleanup.yml <<'YAML' +name: SME marker cleanup (sandbox) + +# Sandbox variant of the post-merge marker stripper. Same pull_request_target +# model; simplified install (npx tsx). Needs the SME_CLEANUP_TOKEN secret and a +# bot on the sandbox ruleset bypass list to push to the default branch. + +on: + pull_request_target: + types: [closed] + +permissions: + contents: write + pull-requests: read + +concurrency: + group: sme-marker-cleanup + cancel-in-progress: false + +jobs: + strip: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Checkout default branch + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.SME_CLEANUP_TOKEN }} + persist-credentials: true + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.x' + - name: Collect merged PR's markdown files + id: files + env: + GH_TOKEN: ${{ secrets.SME_CLEANUP_TOKEN }} + PR: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + gh api "repos/$REPO/pulls/$PR/files" --paginate --jq '.[].filename' \ + | grep -E '\.mdx?$' > "$RUNNER_TEMP/sme-files.txt" || true + echo "count=$(wc -l < "$RUNNER_TEMP/sme-files.txt" | tr -d ' ')" >> "$GITHUB_OUTPUT" + - name: Strip and push + if: steps.files.outputs.count != '0' + run: | + mapfile -t FILES < "$RUNNER_TEMP/sme-files.txt" + npx -y tsx scripts/strip-sme-markers.ts "${FILES[@]}" + if git diff --quiet; then echo "nothing to strip"; exit 0; fi + git config user.name "sme-gate-sandbox" + git config user.email "sme-gate-sandbox@local" + git commit -am "chore: strip transient SME markers (post-merge of #${{ github.event.pull_request.number }})" + git push +YAML +} + bootstrap() { for f in "${GATE_FILES[@]}"; do [ -f "$SRC_DIR/$f" ] || { @@ -161,6 +222,7 @@ bootstrap() { cp "$SRC_DIR/$f" "$f" done write_sandbox_workflow + write_sandbox_cleanup_workflow chmod +x scripts/sme-gate-sandbox.sh git add -A git commit -q -m "Bootstrap SME review gate (sandbox)" @@ -267,3 +329,6 @@ echo " 5. Have an $SME_TEAM member approve the path-fallback PR -> check flips echo " 6. Dismiss that approval -> check re-blocks." echo " Fork case: open a PR from a fork (second account) -> confirm the check still" echo " posts and resolves (this is the pull_request_target path)." +echo " Cleanup: merge the region-tag PR, then confirm the marker is gone:" +echo " gh api repos/${REPO}/contents/${EDIT_PATH}/_sandbox-region-tag.mdx \\" +echo " --jq '.content' | base64 -d | grep -c 'sme:start' # expect 0 after cleanup runs" From 58bf07fc45492fb64ee5689a2a15360548ee209c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= <gael.blanchemain@protonmail.com> Date: Thu, 11 Jun 2026 20:11:34 -0700 Subject: [PATCH 15/18] List cleanup workflow in sandbox dry-run preview --- scripts/sme-gate-sandbox.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/sme-gate-sandbox.sh b/scripts/sme-gate-sandbox.sh index 0629de3c89..e61403471f 100644 --- a/scripts/sme-gate-sandbox.sh +++ b/scripts/sme-gate-sandbox.sh @@ -213,7 +213,9 @@ bootstrap() { done if [ "$DRY_RUN" = true ]; then echo "[dry-run] would install into ${REPO}@${DEFAULT_BRANCH}:" - printf ' %s\n' "${GATE_FILES[@]}" ".github/workflows/sme-review-gate.yml (sandbox variant)" + printf ' %s\n' "${GATE_FILES[@]}" \ + ".github/workflows/sme-review-gate.yml (sandbox variant)" \ + ".github/workflows/sme-marker-cleanup.yml (sandbox variant)" return fi clone_sandbox From 2a8cc1204a3bdfa52c6e3abc754aa08d8566d3a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= <gael.blanchemain@protonmail.com> Date: Thu, 11 Jun 2026 20:12:50 -0700 Subject: [PATCH 16/18] Document transient SME markers and cleanup token Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .github/SME_REVIEW_GATE.md | 15 +++++++++++++++ .github/pull_request_template.md | 2 +- CONTRIBUTE.md | 5 +++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/SME_REVIEW_GATE.md b/.github/SME_REVIEW_GATE.md index d41312ef2e..9da5d1bb0d 100644 --- a/.github/SME_REVIEW_GATE.md +++ b/.github/SME_REVIEW_GATE.md @@ -33,6 +33,21 @@ If a PR touches no SME content, the check passes on its own and editorial approv | `failure` | A required SME team has not approved yet. | | `action_required` | Misconfiguration: malformed `sme:*` markers, an unknown team slug, or team membership could not be read (missing team / token). Fix setup — not a normal "awaiting approval". | +## Transient markers (post-merge cleanup) + +SME markers are **transient per-PR signals**, not durable content. After a PR +merges to `master`, the `SME marker cleanup` workflow +(`.github/workflows/sme-marker-cleanup.yml`) strips the `{/* sme:* */}` markers +from the files that PR changed and commits the result directly to `master` as the +cleanup bot. A future edit to that area is re-tagged by its own author if the new +diff needs SME review. Always-on protection for whole technical sections still +comes from the path globs in `.github/sme-config.json`, which do not depend on +markers. + +Enabling cleanup needs (admin): a dedicated bot/GitHub App token in the repo +secret `SME_CLEANUP_TOKEN` with `contents: write`, and that bot added to the +branch-ruleset **bypass list** so its commit isn't blocked by required reviews. + ## Current state: report-only (Phase 0) `.github/sme-config.json` has `"reportOnly": true`, so the check posts `neutral` and diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a7bfde4067..35b7c3b761 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -27,7 +27,7 @@ Please fill out the form below to ensure your doc gets quickly approved and merg - [ ] I tagged the technical region(s) needing SME review with `{/* sme:start team=<team> */}` … `{/* sme:end */}` (teams: `protocol-sme`, `stylus-sme`, `chain-sme`) - [ ] This PR only touches a technical section (`how-arbitrum-works/`, `stylus/`, `launch-arbitrum-chain/`), which auto-requires its SME team -<!-- See CONTRIBUTE.md ("Flagging content for SME review") for how tagging works. --> +<!-- See CONTRIBUTE.md ("Flagging content for SME review") for how tagging works. Tags are transient — they're auto-removed from master after merge, so re-tag in future PRs when needed. --> ## Checklist diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index 066485f466..f4854c8040 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -235,6 +235,11 @@ You don't always need a marker: A `sme-review-gate` check reports which SME team(s) a PR needs and whether they've approved. It is currently **report-only** (advisory, non-blocking) while the workflow is validated. +SME tags are **transient**: once your PR merges, an automated job removes the +`{/* sme:start … */}` / `{/* sme:end */}` markers from `master`. Don't rely on a +past tag still gating an area — if a later change needs SME review, tag it again +in that PR. + ### Frequently asked questions #### Can I point to my product from core docs? For example—if my product hosts a public RPC endpoint, can I add it to your [RPC endpoints and providers](https://docs.arbitrum.io/for-devs/dev-tools-and-resources/chain-info#third-party-rpc-providers) section? From 977d82765b53dafb1d568f1993bf8bb4108d2962 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= <gael.blanchemain@protonmail.com> Date: Thu, 11 Jun 2026 20:53:46 -0700 Subject: [PATCH 17/18] Note SME markers must be single-line in CONTRIBUTE --- CONTRIBUTE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index f4854c8040..2e2b928998 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -226,6 +226,8 @@ The challenge period is exactly 6.4 days because… {/* sme:end */} ``` +Keep each marker on a single line — the `sme:start` and `sme:end` comments must each stay on one line, or the gate won't recognize the region (it reports the marker as malformed). + Available SME teams: `protocol-sme` (how Arbitrum works), `stylus-sme` (Stylus), `chain-sme` (launch an Arbitrum chain). You don't always need a marker: From e2f36e6b9e1488f8af5a646df53cc1df1a22d7cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Blanchemain?= <gael.blanchemain@protonmail.com> Date: Tue, 16 Jun 2026 20:32:15 -0700 Subject: [PATCH 18/18] Mint SME gate check token from a GitHub App, not a PAT The sme-review-gate script reads org team membership AND posts a check run with the same token. No PAT (classic or fine-grained) can create check runs, so SME_GATE_TOKEN as a PAT silently failed to post the check. Mint a short-lived installation token via create-github-app-token from SME_GATE_APP_ID + SME_GATE_APP_PRIVATE_KEY, guarded so the workflow still degrades to GITHUB_TOKEN (report-only) when the App is absent. Apply the same fix to the sandbox workflow generator and correct the runbook, which previously documented the non-working PAT approach. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .github/SME_REVIEW_GATE.md | 32 +++++++++++++++++---------- .github/workflows/sme-review-gate.yml | 27 ++++++++++++++++------ scripts/sme-gate-sandbox.sh | 23 ++++++++++++++----- 3 files changed, 57 insertions(+), 25 deletions(-) diff --git a/.github/SME_REVIEW_GATE.md b/.github/SME_REVIEW_GATE.md index 9da5d1bb0d..c7db2889f1 100644 --- a/.github/SME_REVIEW_GATE.md +++ b/.github/SME_REVIEW_GATE.md @@ -26,11 +26,11 @@ If a PR touches no SME content, the check passes on its own and editorial approv ## Status check conclusions -| Conclusion | Meaning | -| --- | --- | -| `neutral` | Report-only mode (`reportOnly: true`). Never blocks. | -| `success` | No SME content, or every required SME team approved. | -| `failure` | A required SME team has not approved yet. | +| Conclusion | Meaning | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `neutral` | Report-only mode (`reportOnly: true`). Never blocks. | +| `success` | No SME content, or every required SME team approved. | +| `failure` | A required SME team has not approved yet. | | `action_required` | Misconfiguration: malformed `sme:*` markers, an unknown team slug, or team membership could not be read (missing team / token). Fix setup — not a normal "awaiting approval". | ## Transient markers (post-merge cleanup) @@ -61,6 +61,7 @@ PRs before it gates merges. While in this state you will see SME teams reported 1. **Create the per-domain SME teams** in the `OffchainLabs` org and give each **write** access to `arbitrum-docs` (write access is required for their approvals to count): + - `protocol-sme` — owns `docs/how-arbitrum-works/**` - `stylus-sme` — owns `docs/stylus/**`, `docs/stylus-by-example/**` - `chain-sme` — owns `docs/launch-arbitrum-chain/**` @@ -68,10 +69,16 @@ PRs before it gates merges. While in this state you will see SME teams reported (These match `.github/sme-config.json` and `.github/CODEOWNERS`. Add/rename teams by editing those two files in a PR.) -2. **Provision a membership-read token.** The default `GITHUB_TOKEN` cannot read org team - membership, so the gate reports teams as `unverifiable` without one. Create a GitHub - App installation token or a fine-grained PAT with **`members: read`** on the org, and - add it as the repo secret **`SME_GATE_TOKEN`**. The workflow prefers it automatically. +2. **Provision a GitHub App token.** The gate token must do two things at once: read org + team membership AND post the `sme-review-gate` check run. Only a **GitHub App** can hold + both — `members: read` (org) and `checks: write` (repo). No PAT (classic or fine-grained) + can create check runs, so a PAT is not an option here. Create a GitHub App with repo + permissions **Checks: read/write**, **Contents: read**, **Pull requests: read** and org + permission **Members: read**, install it on the repo, and store its App ID and private + key as the repo secrets **`SME_GATE_APP_ID`** and **`SME_GATE_APP_PRIVATE_KEY`**. The + workflow mints a short-lived installation token from them via + `actions/create-github-app-token`; if the secrets are absent it skips that step and + degrades to `GITHUB_TOKEN` (teams report `unverifiable` rather than failing). 3. **Confirm in report-only.** Open/refresh a PR touching a pilot section and check the `sme-review-gate` run: required teams should now show `approved`/`awaiting` instead of @@ -111,7 +118,7 @@ never checks out or runs the PR's code. Do not add a step that builds or execute ## Smoke-testing in a sandbox -To exercise the *blocking* behavior without risking real PRs, use a throwaway private repo. +To exercise the _blocking_ behavior without risking real PRs, use a throwaway private repo. **One-time setup (admin):** @@ -120,8 +127,9 @@ To exercise the *blocking* behavior without risking real PRs, use a throwaway pr `scripts/sme-review-gate.ts`, `.github/sme-config.json`, `.github/CODEOWNERS` (plus a minimal `package.json` with `tsx`, or reuse this repo's). Keep `sme-config.json` identical so `stylus-sme` maps to `docs/stylus/**`. -3. Create an `stylus-sme` team with **write** access and set the `SME_GATE_TOKEN` secret - (`members:read`). +3. Create an `stylus-sme` team with **write** access, and set the `SME_GATE_APP_ID` + + `SME_GATE_APP_PRIVATE_KEY` secrets (a GitHub App with `members: read` + `checks: write`; + see step 2 of "Enabling enforcement" for the full permission list). **Run the matrix:** diff --git a/.github/workflows/sme-review-gate.yml b/.github/workflows/sme-review-gate.yml index 311590c313..78465f937f 100644 --- a/.github/workflows/sme-review-gate.yml +++ b/.github/workflows/sme-review-gate.yml @@ -8,7 +8,7 @@ run-name: SME review gate (report-only) # # SECURITY — why `pull_request_target`: # External contributors open PRs from forks. A plain `pull_request` run on a fork -# gets a read-only token, no secrets (no SME_GATE_TOKEN), and no checks:write — so +# gets a read-only token, no secrets (no App token), and no checks:write — so # the gate could neither read team membership nor post its check. `pull_request_target` # runs in the BASE repo context (full token + secrets) for fork PRs too. It is safe # here ONLY because this job checks out the trusted base ref (never the PR head) and @@ -41,11 +41,14 @@ jobs: evaluate: name: 'evaluate' runs-on: ubuntu-latest + # `secrets` cannot be used in a step-level `if`; surface presence as an env flag. + env: + HAS_SME_APP: ${{ secrets.SME_GATE_APP_ID != '' }} steps: - name: Resolve PR number id: resolve env: - GH_TOKEN: ${{ secrets.SME_GATE_TOKEN || secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} INPUT_PR: ${{ inputs.pr }} EVENT_PR: ${{ github.event.pull_request.number }} run: | @@ -70,12 +73,22 @@ jobs: with: install-command: yarn install --frozen-lockfile + # The gate token must read org team membership AND post the check run. Only a + # GitHub App can hold both `members: read` (org) and `checks: write` — no PAT, + # classic or fine-grained, can create check runs. Mint a short-lived installation + # token when the App secrets exist; if they don't, this step is skipped and the + # run degrades to GITHUB_TOKEN (report-only neutral check, teams "unverifiable"). + - name: Mint SME gate token (GitHub App) + id: app-token + if: ${{ env.HAS_SME_APP == 'true' }} + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.SME_GATE_APP_ID }} + private-key: ${{ secrets.SME_GATE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + - name: Run SME review gate (report-only) env: - # Default GITHUB_TOKEN cannot read org team membership. When eng/infra - # provisions a GitHub App / fine-grained token with `members:read`, expose - # it as SME_GATE_TOKEN and it is preferred automatically; until then the - # gate degrades to "unverifiable" rather than failing. - GH_TOKEN: ${{ secrets.SME_GATE_TOKEN || secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ steps.resolve.outputs.pr }} run: yarn tsx scripts/sme-review-gate.ts diff --git a/scripts/sme-gate-sandbox.sh b/scripts/sme-gate-sandbox.sh index e61403471f..52119393cd 100644 --- a/scripts/sme-gate-sandbox.sh +++ b/scripts/sme-gate-sandbox.sh @@ -7,11 +7,12 @@ # sandbox repo's default branch (run once, from an arbitrum-docs # checkout) and generate a sandbox-tailored workflow. # (default) : open the gate test matrix as internal-branch PRs against the -# sandbox (run after the SME team + SME_GATE_TOKEN secret exist). +# sandbox (run after the SME team + SME_GATE_APP_* secrets exist). # # Prereqs for enforcement testing (admin — see .github/SME_REVIEW_GATE.md): -# an `stylus-sme` team with WRITE access on the sandbox + the SME_GATE_TOKEN -# secret (members:read). The matrix uses `stylus-sme` / docs/stylus/**. +# an `stylus-sme` team with WRITE access on the sandbox + the SME_GATE_APP_ID / +# SME_GATE_APP_PRIVATE_KEY secrets (a GitHub App with members:read + checks:write). +# The matrix uses `stylus-sme` / docs/stylus/**. # # Usage: # scripts/sme-gate-sandbox.sh --repo <owner/name> --bootstrap [--dry-run] @@ -122,7 +123,7 @@ jobs: - name: Resolve PR number id: resolve env: - GH_TOKEN: ${{ secrets.SME_GATE_TOKEN || secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} INPUT_PR: ${{ inputs.pr }} EVENT_PR: ${{ github.event.pull_request.number }} run: | @@ -137,9 +138,19 @@ jobs: uses: actions/setup-node@v4 with: node-version: '22.x' + # The gate token must read org team membership AND post a check run. Only a + # GitHub App can hold both (checks:write + members:read) — PATs cannot create + # check runs — so mint a short-lived installation token here. + - name: Mint SME gate token (GitHub App) + uses: actions/create-github-app-token@v3 + id: app-token + with: + app-id: ${{ secrets.SME_GATE_APP_ID }} + private-key: ${{ secrets.SME_GATE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} - name: Run SME review gate env: - GH_TOKEN: ${{ secrets.SME_GATE_TOKEN || secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} PR_NUMBER: ${{ steps.resolve.outputs.pr }} run: npx -y tsx scripts/sme-review-gate.ts YAML @@ -230,7 +241,7 @@ bootstrap() { git commit -q -m "Bootstrap SME review gate (sandbox)" git push -q origin "$DEFAULT_BRANCH" echo "Installed gate files into ${REPO}@${DEFAULT_BRANCH}." - echo "Next: create an ${SME_TEAM} team with write access + the SME_GATE_TOKEN secret, then:" + echo "Next: create an ${SME_TEAM} team with write access + the SME_GATE_APP_ID/SME_GATE_APP_PRIVATE_KEY secrets, then:" echo " scripts/sme-gate-sandbox.sh --repo ${REPO}" }