diff --git a/.github/scripts/docs_preview.js b/.github/scripts/docs_preview.js new file mode 100644 index 0000000000..5d18a500c1 --- /dev/null +++ b/.github/scripts/docs_preview.js @@ -0,0 +1,108 @@ +// Docs preview gate and PR comment. .github/workflows/docs-preview.yml wires +// these up: `authorize` decides whether a run may build and deploy a preview +// (and for which commit), `comment` reports the outcome on the pull request. +// The security model is described in the workflow's header. +'use strict'; + +const MARKER = ''; +const BOT_LOGIN = 'github-actions[bot]'; + +// Sets the job outputs `authorized`, `pr_number`, `head_sha` and +// `slash_attempt` for a pull_request_target or /preview-docs issue_comment run. +async function authorize({ github, context, core }) { + const { owner, repo } = context.repo; + + async function permissionFor(username) { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username }); + return { level: data.permission, role: data.role_name }; + } + + let authorized = false; + let prNumber = ''; + let headSha = ''; + let slashAttempt = false; + + if (context.eventName === 'pull_request_target') { + // Gate on the *sender* (whoever caused this run — on synchronize that + // is the pusher), not the PR author, so a non-admin pushing to an + // admin-opened branch does not get an automatic build. + const actor = context.payload.sender.login; + prNumber = String(context.payload.pull_request.number); + headSha = context.payload.pull_request.head.sha; + const perm = await permissionFor(actor); + authorized = perm.level === 'admin'; + core.info(`pull_request_target by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`); + } else { + // issue_comment: the job-level `if:` already guarantees this is a PR + // comment starting with /preview-docs. + slashAttempt = true; + const actor = context.payload.comment.user.login; + prNumber = String(context.payload.issue.number); + const perm = await permissionFor(actor); + authorized = perm.level === 'admin' || perm.role === 'maintain'; + if (authorized) { + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: Number(prNumber) }); + if (pr.state !== 'open') { + authorized = false; + core.info(`PR #${prNumber} is ${pr.state}; refusing to preview.`); + } else { + headSha = pr.head.sha; + } + } + core.info(`/preview-docs by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`); + } + + core.setOutput('authorized', String(authorized)); + core.setOutput('pr_number', prNumber); + core.setOutput('head_sha', headSha); + core.setOutput('slash_attempt', String(slashAttempt)); +} + +// Posts or updates the preview comment on the PR. Reads the outcome of the +// earlier jobs from the step's env: AUTHORIZED, PR_NUMBER, HEAD_SHA, +// DEPLOY_RESULT, DEPLOYMENT_URL, ALIAS_URL, RUN_URL. +async function comment({ github, context }) { + const { owner, repo } = context.repo; + const env = process.env; + const issue_number = Number(env.PR_NUMBER); + + async function upsert(body) { + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 }); + const existing = comments.find((c) => c.user?.login === BOT_LOGIN && c.body?.includes(MARKER)); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } + } + + if (env.AUTHORIZED !== 'true') { + await github.rest.issues.createComment({ + owner, repo, issue_number, + body: `@${context.actor} — only repository admins or maintainers can run \`/preview-docs\` (and the PR must be open).`, + }); + return; + } + + if (env.DEPLOY_RESULT !== 'success') { + await upsert( + `${MARKER}\n### šŸ“š Documentation preview\n\n` + + `āŒ Preview build **failed** for \`${env.HEAD_SHA.slice(0, 7)}\` — [workflow logs](${env.RUN_URL}).` + ); + return; + } + + const previewUrl = env.ALIAS_URL || env.DEPLOYMENT_URL; + const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'); + await upsert( + `${MARKER}\n### šŸ“š Documentation preview\n\n` + + `| | |\n|---|---|\n` + + `| **Preview** | ${previewUrl} |\n` + + `| **Deployment** | ${env.DEPLOYMENT_URL} |\n` + + `| **Commit** | \`${env.HEAD_SHA.slice(0, 7)}\` |\n` + + `| **Triggered by** | @${context.actor} |\n` + + `| **Updated** | ${ts} |\n` + ); +} + +module.exports = { authorize, comment }; diff --git a/.github/scripts/docs_preview.test.js b/.github/scripts/docs_preview.test.js new file mode 100644 index 0000000000..1b6d575350 --- /dev/null +++ b/.github/scripts/docs_preview.test.js @@ -0,0 +1,212 @@ +// Scenario tests for docs_preview.js: who gets a preview build (`authorize`) +// and what the pull request shows afterwards (`comment`). +// +// node --test .github/scripts/docs_preview.test.js +// +// No dependencies; the GitHub client is a small fake defined at the bottom. +// CI runs it in the checks job (.github/workflows/shared.yml). +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { authorize, comment } = require('./docs_preview.js'); + +const REPO = { owner: 'modelcontextprotocol', repo: 'python-sdk' }; +const HEAD = 'e4dfda7baa127ab00ebcd1d5324560cbe3cdfe42'; +const MARKER = ''; + +// `permission` / `role_name` as the collaborators API reports them. +const PEOPLE = { + admin: { permission: 'admin', role_name: 'admin' }, + maintainer: { permission: 'write', role_name: 'maintain' }, + writer: { permission: 'write', role_name: 'write' }, + outsider: { permission: 'read', role_name: 'read' }, +}; + +// ── authorize ────────────────────────────────────────────────────────────── +// `expect` is the full set of job outputs the step writes. + +const authorizeScenarios = [ + { + name: 'admin pushes to (or opens) a PR → automatic preview of that head', + event: pushed(7, 'admin'), + expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'false' }, + }, + { + name: 'someone with write but not admin pushes → no automatic preview', + event: pushed(7, 'writer'), + expect: { authorized: 'false', pr_number: '7', head_sha: HEAD, slash_attempt: 'false' }, + }, + { + name: 'maintainer comments /preview-docs on an open PR → preview of its current head', + event: slash(7, 'maintainer'), + expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'true' }, + }, + { + name: 'admin comments /preview-docs → authorized as well', + event: slash(7, 'admin'), + expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'true' }, + }, + { + name: 'writer without the maintain role comments /preview-docs → refused, recorded as an attempt', + event: slash(7, 'writer'), + expect: { authorized: 'false', pr_number: '7', head_sha: '', slash_attempt: 'true' }, + }, + { + name: 'outsider comments /preview-docs → refused, recorded as an attempt', + event: slash(7, 'outsider'), + expect: { authorized: 'false', pr_number: '7', head_sha: '', slash_attempt: 'true' }, + }, + { + name: '/preview-docs on a closed PR → refused even for a maintainer', + pr: { state: 'closed' }, + event: slash(7, 'maintainer'), + expect: { authorized: 'false', pr_number: '7', head_sha: '', slash_attempt: 'true' }, + }, +]; + +for (const s of authorizeScenarios) { + test(`authorize: ${s.name}`, async () => { + const world = makeWorld({ pr: { number: 7, ...s.pr } }); + assert.deepEqual(await runAuthorize(world, s.event), s.expect); + assert.equal(world.writes.length, 0); + }); +} + +test('authorize: a failing permission lookup fails the step instead of deciding either way', async () => { + const world = makeWorld({ pr: { number: 7 } }); + world.failPermissionLookup = true; + await assert.rejects(runAuthorize(world, pushed(7, 'admin')), /boom/); +}); + +// ── comment ──────────────────────────────────────────────────────────────── + +const DEPLOYED = { + AUTHORIZED: 'true', + PR_NUMBER: '7', + HEAD_SHA: HEAD, + DEPLOY_RESULT: 'success', + DEPLOYMENT_URL: 'https://1a2b3c.mcp-python-sdk-docs.pages.dev', + ALIAS_URL: 'https://pr-7.mcp-python-sdk-docs.pages.dev', + RUN_URL: 'https://github.com/modelcontextprotocol/python-sdk/actions/runs/1', +}; + +test('comment: a refused /preview-docs gets a plain reply to the commenter, not a preview comment', async () => { + const world = makeWorld({ pr: { number: 7 } }); + await runComment(world, { ...DEPLOYED, AUTHORIZED: 'false', HEAD_SHA: '', DEPLOY_RESULT: 'skipped' }, 'outsider'); + assert.equal(world.comments.length, 1); + assert.match(world.comments[0].body, /^@outsider — only repository admins or maintainers can run `\/preview-docs`/); + assert.ok(!world.comments[0].body.includes(MARKER)); +}); + +test('comment: first successful deploy posts one preview comment linking the alias URL and the commit', async () => { + const world = makeWorld({ pr: { number: 7 } }); + await runComment(world, DEPLOYED, 'admin'); + assert.equal(world.comments.length, 1); + const body = world.comments[0].body; + assert.ok(body.startsWith(`${MARKER}\n### šŸ“š Documentation preview`)); + assert.match(body, /\| \*\*Preview\*\* \| https:\/\/pr-7\.mcp-python-sdk-docs\.pages\.dev \|/); + assert.match(body, /\| \*\*Deployment\*\* \| https:\/\/1a2b3c\.mcp-python-sdk-docs\.pages\.dev \|/); + assert.match(body, /\| \*\*Commit\*\* \| `e4dfda7` \|/); + assert.match(body, /\| \*\*Triggered by\*\* \| @admin \|/); + assert.deepEqual(world.writes, ['comment on #7']); +}); + +test('comment: a later deploy edits the existing preview comment instead of adding another', async () => { + const world = makeWorld({ pr: { number: 7 }, comments: [{ user: 'someone', body: 'LGTM' }, { user: 'github-actions[bot]', body: `${MARKER}\nold table` }] }); + await runComment(world, { ...DEPLOYED, HEAD_SHA: 'f'.repeat(40) }, 'admin'); + assert.equal(world.comments.length, 2); + assert.match(world.comments[1].body, /\| \*\*Commit\*\* \| `fffffff` \|/); + assert.deepEqual(world.writes, ['edit comment 101']); +}); + +test("comment: someone else's comment that happens to contain the marker is left alone", async () => { + const world = makeWorld({ pr: { number: 7 }, comments: [{ user: 'someone', body: `quoting ${MARKER} here` }] }); + await runComment(world, DEPLOYED, 'admin'); + assert.equal(world.comments.length, 2); + assert.equal(world.comments[0].body, `quoting ${MARKER} here`); + assert.deepEqual(world.writes, ['comment on #7']); +}); + +test('comment: with no alias URL the preview link falls back to the deployment URL', async () => { + const world = makeWorld({ pr: { number: 7 } }); + await runComment(world, { ...DEPLOYED, ALIAS_URL: '' }, 'admin'); + assert.match(world.comments[0].body, /\| \*\*Preview\*\* \| https:\/\/1a2b3c\.mcp-python-sdk-docs\.pages\.dev \|/); +}); + +test('comment: a build or deploy that did not succeed is reported with the short SHA and a link to the run', async () => { + const world = makeWorld({ pr: { number: 7 }, comments: [{ user: 'github-actions[bot]', body: `${MARKER}\nold table` }] }); + await runComment(world, { ...DEPLOYED, DEPLOY_RESULT: 'skipped', DEPLOYMENT_URL: '', ALIAS_URL: '' }, 'admin'); + assert.equal(world.comments.length, 1); + assert.equal( + world.comments[0].body, + `${MARKER}\n### šŸ“š Documentation preview\n\nāŒ Preview build **failed** for \`e4dfda7\` — [workflow logs](${DEPLOYED.RUN_URL}).` + ); +}); + +// ── Harness ──────────────────────────────────────────────────────────────── + +function pushed(number, sender) { + return { eventName: 'pull_request_target', actor: sender, payload: { action: 'synchronize', pull_request: { number, head: { sha: HEAD } }, sender: { login: sender } } }; +} +function slash(number, commenter) { + return { eventName: 'issue_comment', actor: commenter, payload: { action: 'created', issue: { number, pull_request: {} }, comment: { body: '/preview-docs', user: { login: commenter } } } }; +} + +async function runAuthorize(world, event) { + const outputs = {}; + const core = { info: () => {}, setOutput: (k, v) => { outputs[k] = v; } }; + await authorize({ github: world.github, context: { repo: REPO, ...event }, core }); + return outputs; +} + +async function runComment(world, env, actor) { + const saved = {}; + for (const [k, v] of Object.entries(env)) { saved[k] = process.env[k]; process.env[k] = v; } + try { + await comment({ github: world.github, context: { repo: REPO, actor }, core: {} }); + } finally { + for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } + } +} + +// ── A tiny in-memory GitHub ──────────────────────────────────────────────── + +function makeWorld({ pr, comments = [] }) { + const world = { pr: { state: 'open', ...pr }, comments: [], writes: [], failPermissionLookup: false, nextCommentId: 100 }; + for (const c of comments) world.comments.push({ id: world.nextCommentId++, ...c }); + + const err = (status, message = 'fake error') => Object.assign(new Error(message), { status }); + const write = (what) => world.writes.push(what); + const checkPr = (n) => { if (n !== world.pr.number) throw err(404); }; + + const rest = { + repos: { + getCollaboratorPermissionLevel: async ({ username }) => { + if (world.failPermissionLookup) throw err(500, 'boom'); + const person = PEOPLE[username]; + if (!person) throw err(404, 'not a user'); + return { data: { ...person, user: { login: username } } }; + }, + }, + pulls: { + get: async ({ pull_number }) => { checkPr(pull_number); return { data: { number: pull_number, state: world.pr.state, head: { sha: HEAD } } }; }, + }, + issues: { + listComments: async ({ issue_number }) => { checkPr(issue_number); return { data: world.comments.map((c) => ({ id: c.id, body: c.body, user: { login: c.user } })) }; }, + createComment: async ({ issue_number, body }) => { + checkPr(issue_number); + write(`comment on #${issue_number}`); + world.comments.push({ id: world.nextCommentId++, user: 'github-actions[bot]', body }); + }, + updateComment: async ({ comment_id, body }) => { + write(`edit comment ${comment_id}`); + const c = world.comments.find((x) => x.id === comment_id); + if (!c) throw err(404); + c.body = body; + }, + }, + }; + world.github = { rest, paginate: async (fn, args) => (await fn(args)).data }; + return world; +} diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index 3be6510118..7236f2b695 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -8,7 +8,8 @@ name: Docs Preview # auto-preview, admin/maintainer commenter for /preview-docs) and isolated # from Cloudflare secrets — `build` runs PR code with no secrets and hands # the static site to `deploy` via an artifact, so PR code never shares a -# runner with the Cloudflare token. +# runner with the Cloudflare token. `authorize` and `comment` run +# .github/scripts/docs_preview.js, checked out from the default branch only. # # Required configuration: # - secrets.CLOUDFLARE_API_TOKEN (scope: Account → Cloudflare Pages → Edit) @@ -58,57 +59,19 @@ jobs: head_sha: ${{ steps.check.outputs.head_sha }} slash_attempt: ${{ steps.check.outputs.slash_attempt }} steps: + - name: Check out the scripts (default branch) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: .github/scripts + - name: Determine authorization id: check uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | - const { owner, repo } = context.repo; - - async function permissionFor(username) { - const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username }); - return { level: data.permission, role: data.role_name }; - } - - let authorized = false; - let prNumber = ''; - let headSha = ''; - let slashAttempt = false; - - if (context.eventName === 'pull_request_target') { - // Gate on the *sender* (whoever caused this run — on synchronize that - // is the pusher), not the PR author, so a non-admin pushing to an - // admin-opened branch does not get an automatic build. - const actor = context.payload.sender.login; - prNumber = String(context.payload.pull_request.number); - headSha = context.payload.pull_request.head.sha; - const perm = await permissionFor(actor); - authorized = perm.level === 'admin'; - core.info(`pull_request_target by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`); - } else { - // issue_comment: the job-level `if:` already guarantees this is a PR - // comment starting with /preview-docs. - slashAttempt = true; - const actor = context.payload.comment.user.login; - prNumber = String(context.payload.issue.number); - const perm = await permissionFor(actor); - authorized = perm.level === 'admin' || perm.role === 'maintain'; - if (authorized) { - const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: Number(prNumber) }); - if (pr.state !== 'open') { - authorized = false; - core.info(`PR #${prNumber} is ${pr.state}; refusing to preview.`); - } else { - headSha = pr.head.sha; - } - } - core.info(`/preview-docs by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`); - } - - core.setOutput('authorized', String(authorized)); - core.setOutput('pr_number', prNumber); - core.setOutput('head_sha', headSha); - core.setOutput('slash_attempt', String(slashAttempt)); + const { authorize } = require('./.github/scripts/docs_preview.js'); + await authorize({ github, context, core }); build: needs: authorize @@ -192,8 +155,15 @@ jobs: (needs.authorize.outputs.authorized == 'true' || needs.authorize.outputs.slash_attempt == 'true') runs-on: ubuntu-latest permissions: + contents: read # check out the script from the default branch pull-requests: write steps: + - name: Check out the scripts (default branch) + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: .github/scripts + - name: Post or update preview comment uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: @@ -206,45 +176,5 @@ jobs: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} with: script: | - const { owner, repo } = context.repo; - const env = process.env; - const issue_number = Number(env.PR_NUMBER); - const marker = ''; - - async function upsert(body) { - const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 }); - const existing = comments.find(c => c.user?.login === 'github-actions[bot]' && c.body?.includes(marker)); - if (existing) { - await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); - } else { - await github.rest.issues.createComment({ owner, repo, issue_number, body }); - } - } - - if (env.AUTHORIZED !== 'true') { - await github.rest.issues.createComment({ - owner, repo, issue_number, - body: `@${context.actor} — only repository admins or maintainers can run \`/preview-docs\` (and the PR must be open).`, - }); - return; - } - - if (env.DEPLOY_RESULT !== 'success') { - await upsert( - `${marker}\n### šŸ“š Documentation preview\n\n` + - `āŒ Preview build **failed** for \`${env.HEAD_SHA.slice(0, 7)}\` — [workflow logs](${env.RUN_URL}).` - ); - return; - } - - const previewUrl = env.ALIAS_URL || env.DEPLOYMENT_URL; - const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'); - await upsert( - `${marker}\n### šŸ“š Documentation preview\n\n` + - `| | |\n|---|---|\n` + - `| **Preview** | ${previewUrl} |\n` + - `| **Deployment** | ${env.DEPLOYMENT_URL} |\n` + - `| **Commit** | \`${env.HEAD_SHA.slice(0, 7)}\` |\n` + - `| **Triggered by** | @${context.actor} |\n` + - `| **Updated** | ${ts} |\n` - ); + const { comment } = require('./.github/scripts/docs_preview.js'); + await comment({ github, context, core }); diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index 3dd8dd8fd2..a223c7aa45 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -43,9 +43,9 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 24 # match the runtime actions/github-script@v8 uses - - name: PR intake gate scenarios - run: node --test .github/scripts/pr_intake_gate.test.js + node-version: 24 # match actions/github-script's node24 runtime + - name: Workflow script tests + run: node --test '.github/scripts/*.test.js' - name: Surface types match vendored schema run: |