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/SME_REVIEW_GATE.md b/.github/SME_REVIEW_GATE.md new file mode 100644 index 0000000000..c7db2889f1 --- /dev/null +++ b/.github/SME_REVIEW_GATE.md @@ -0,0 +1,151 @@ +# 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". | + +## 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 +**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 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 + `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`. + +## 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_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:** + +```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 +ruleset enforcement back to **Disabled** in the UI. Either fully unblocks merges. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7507fd6d02..35b7c3b761 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/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/.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-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 diff --git a/.github/workflows/sme-review-gate.yml b/.github/workflows/sme-review-gate.yml new file mode 100644 index 0000000000..78465f937f --- /dev/null +++ b/.github/workflows/sme-review-gate.yml @@ -0,0 +1,94 @@ +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. +# +# 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 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 +# 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_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 + checks: write + pull-requests: read + +jobs: + # 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 + # `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.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: + 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 + + # 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: + 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/CONTRIBUTE.md b/CONTRIBUTE.md index 05ec2abcd4..2e2b928998 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -212,6 +212,36 @@ 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 */} +``` + +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: + +- 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. + +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? diff --git a/README.md b/README.md index 96266ee03c..2aa241009a 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 + +> **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. + +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. 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/sme-gate-sandbox.sh b/scripts/sme-gate-sandbox.sh new file mode 100644 index 0000000000..52119393cd --- /dev/null +++ b/scripts/sme-gate-sandbox.sh @@ -0,0 +1,347 @@ +#!/usr/bin/env bash +# +# sme-gate-sandbox — set up and exercise the SME review gate in a sandbox repo. +# +# 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_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_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 --bootstrap [--dry-run] +# scripts/sme-gate-sandbox.sh --repo [--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-markers.ts" + "scripts/strip-sme-markers.ts" + "scripts/sme-gate-sandbox.sh" +) + +usage() { + echo "Usage: scripts/sme-gate-sandbox.sh --repo [--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 + usage + ;; + esac +done + +[ -n "$REPO" ] || usage + +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 + +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" +} + +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: + # 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 + id: resolve + env: + GH_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' + # 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: ${{ steps.app-token.outputs.token }} + PR_NUMBER: ${{ steps.resolve.outputs.pr }} + run: npx -y tsx scripts/sme-review-gate.ts +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" ] || { + 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)" \ + ".github/workflows/sme-marker-cleanup.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 + write_sandbox_cleanup_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_APP_ID/SME_GATE_APP_PRIVATE_KEY secrets, then:" + echo " scripts/sme-gate-sandbox.sh --repo ${REPO}" +} + +# 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" +} + +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 +--- + +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. 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." +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" 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 new file mode 100644 index 0000000000..db2785e8cb --- /dev/null +++ b/scripts/sme-review-gate.ts @@ -0,0 +1,415 @@ +#!/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=<slug> *​/}` … `{/​* 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). + * + * 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. + * + * Usage: yarn tsx scripts/sme-review-gate.ts --pr <number> + * (PR number also read from $PR_NUMBER or $GITHUB_REF refs/pull/N/merge) + */ +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; + editorialTeam: string; + reportOnly: boolean; + smeTeams: Record<string, string[]>; +} + +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<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; +} + +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<number> { + const lines = new Set<number>(); + 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; +} + +/** 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++) { + 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[], + 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 + 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); + 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})`); + } + } + for (const [team, globs] of Object.entries(config.smeTeams)) { + if (globs.some((g) => matchesGlob(f.filename, g))) add(team, `${f.filename} (path)`); + } + } + return required; +} + +/** Lint SME markers in changed files: unbalanced start/end and unknown team slugs. */ +function markerIssues( + config: SmeConfig, + files: ChangedFile[], + contents: Map<string, string[]>, +): string[] { + const issues: string[] = []; + for (const f of files) { + 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++) { + const startMatch = text[i].match(START_RE); + if (startMatch) { + 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)`, + ); + } + } 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`); + } + return issues; +} + +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<string> { + const reviews = ghJson<Review[]>(['api', `repos/${REPO}/pulls/${pr}/reviews`, '--paginate']); + const latest = new Map<string, string>(); + 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<string> | 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<string, Set<string>>, + org: string, + approved: Set<string>, +): 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 }, + issues: string[], + reportOnly: boolean, +): { 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}`)]; + + if (verdicts.length === 0) { + return { + 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 && !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'; + + 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, + body: [ + `**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'), + }; +} + +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<ChangedFile[]>(['api', `repos/${REPO}/pulls/${pr}/files`, '--paginate']); + + // 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, contents); + + const editorialMembers = teamMembers(config.org, config.editorialTeam); + const editorial = { + unverifiable: editorialMembers === null, + satisfied: editorialMembers !== null && [...editorialMembers].some((m) => approved.has(m)), + }; + + 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); + 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(); 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..4f42bf6551 --- /dev/null +++ b/scripts/strip-sme-markers.ts @@ -0,0 +1,53 @@ +#!/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'); +} + +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));