| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,108 @@ | |||
| 1 | + // Docs preview gate and PR comment. .github/workflows/docs-preview.yml wires | ||
| 2 | + // these up: `authorize` decides whether a run may build and deploy a preview | ||
| 3 | + // (and for which commit), `comment` reports the outcome on the pull request. | ||
| 4 | + // The security model is described in the workflow's header. | ||
| 5 | + 'use strict'; | ||
| 6 | + | ||
| 7 | + const MARKER = '<!-- docs-preview -->'; | ||
| 8 | + const BOT_LOGIN = 'github-actions[bot]'; | ||
| 9 | + | ||
| 10 | + // Sets the job outputs `authorized`, `pr_number`, `head_sha` and | ||
| 11 | + // `slash_attempt` for a pull_request_target or /preview-docs issue_comment run. | ||
| 12 | + async function authorize({ github, context, core }) { | ||
| 13 | + const { owner, repo } = context.repo; | ||
| 14 | + | ||
| 15 | + async function permissionFor(username) { | ||
| 16 | + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username }); | ||
| 17 | + return { level: data.permission, role: data.role_name }; | ||
| 18 | + } | ||
| 19 | + | ||
| 20 | + let authorized = false; | ||
| 21 | + let prNumber = ''; | ||
| 22 | + let headSha = ''; | ||
| 23 | + let slashAttempt = false; | ||
| 24 | + | ||
| 25 | + if (context.eventName === 'pull_request_target') { | ||
| 26 | + // Gate on the *sender* (whoever caused this run — on synchronize that | ||
| 27 | + // is the pusher), not the PR author, so a non-admin pushing to an | ||
| 28 | + // admin-opened branch does not get an automatic build. | ||
| 29 | + const actor = context.payload.sender.login; | ||
| 30 | + prNumber = String(context.payload.pull_request.number); | ||
| 31 | + headSha = context.payload.pull_request.head.sha; | ||
| 32 | + const perm = await permissionFor(actor); | ||
| 33 | + authorized = perm.level === 'admin'; | ||
| 34 | + core.info(`pull_request_target by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`); | ||
| 35 | + } else { | ||
| 36 | + // issue_comment: the job-level `if:` already guarantees this is a PR | ||
| 37 | + // comment starting with /preview-docs. | ||
| 38 | + slashAttempt = true; | ||
| 39 | + const actor = context.payload.comment.user.login; | ||
| 40 | + prNumber = String(context.payload.issue.number); | ||
| 41 | + const perm = await permissionFor(actor); | ||
| 42 | + authorized = perm.level === 'admin' || perm.role === 'maintain'; | ||
| 43 | + if (authorized) { | ||
| 44 | + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: Number(prNumber) }); | ||
| 45 | + if (pr.state !== 'open') { | ||
| 46 | + authorized = false; | ||
| 47 | + core.info(`PR #${prNumber} is ${pr.state}; refusing to preview.`); | ||
| 48 | + } else { | ||
| 49 | + headSha = pr.head.sha; | ||
| 50 | + } | ||
| 51 | + } | ||
| 52 | + core.info(`/preview-docs by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`); | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + core.setOutput('authorized', String(authorized)); | ||
| 56 | + core.setOutput('pr_number', prNumber); | ||
| 57 | + core.setOutput('head_sha', headSha); | ||
| 58 | + core.setOutput('slash_attempt', String(slashAttempt)); | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + // Posts or updates the preview comment on the PR. Reads the outcome of the | ||
| 62 | + // earlier jobs from the step's env: AUTHORIZED, PR_NUMBER, HEAD_SHA, | ||
| 63 | + // DEPLOY_RESULT, DEPLOYMENT_URL, ALIAS_URL, RUN_URL. | ||
| 64 | + async function comment({ github, context }) { | ||
| 65 | + const { owner, repo } = context.repo; | ||
| 66 | + const env = process.env; | ||
| 67 | + const issue_number = Number(env.PR_NUMBER); | ||
| 68 | + | ||
| 69 | + async function upsert(body) { | ||
| 70 | + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 }); | ||
| 71 | + const existing = comments.find((c) => c.user?.login === BOT_LOGIN && c.body?.includes(MARKER)); | ||
| 72 | + if (existing) { | ||
| 73 | + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); | ||
| 74 | + } else { | ||
| 75 | + await github.rest.issues.createComment({ owner, repo, issue_number, body }); | ||
| 76 | + } | ||
| 77 | + } | ||
| 78 | + | ||
| 79 | + if (env.AUTHORIZED !== 'true') { | ||
| 80 | + await github.rest.issues.createComment({ | ||
| 81 | + owner, repo, issue_number, | ||
| 82 | + body: `@${context.actor} — only repository admins or maintainers can run \`/preview-docs\` (and the PR must be open).`, | ||
| 83 | + }); | ||
| 84 | + return; | ||
| 85 | + } | ||
| 86 | + | ||
| 87 | + if (env.DEPLOY_RESULT !== 'success') { | ||
| 88 | + await upsert( | ||
| 89 | + `${MARKER}\n### 📚 Documentation preview\n\n` + | ||
| 90 | + `❌ Preview build **failed** for \`${env.HEAD_SHA.slice(0, 7)}\` — [workflow logs](${env.RUN_URL}).` | ||
| 91 | + ); | ||
| 92 | + return; | ||
| 93 | + } | ||
| 94 | + | ||
| 95 | + const previewUrl = env.ALIAS_URL || env.DEPLOYMENT_URL; | ||
| 96 | + const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'); | ||
| 97 | + await upsert( | ||
| 98 | + `${MARKER}\n### 📚 Documentation preview\n\n` + | ||
| 99 | + `| | |\n|---|---|\n` + | ||
| 100 | + `| **Preview** | ${previewUrl} |\n` + | ||
| 101 | + `| **Deployment** | ${env.DEPLOYMENT_URL} |\n` + | ||
| 102 | + `| **Commit** | \`${env.HEAD_SHA.slice(0, 7)}\` |\n` + | ||
| 103 | + `| **Triggered by** | @${context.actor} |\n` + | ||
| 104 | + `| **Updated** | ${ts} |\n` | ||
| 105 | + ); | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + module.exports = { authorize, comment }; | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -0,0 +1,212 @@ | |||
| 1 | + // Scenario tests for docs_preview.js: who gets a preview build (`authorize`) | ||
| 2 | + // and what the pull request shows afterwards (`comment`). | ||
| 3 | + // | ||
| 4 | + // node --test .github/scripts/docs_preview.test.js | ||
| 5 | + // | ||
| 6 | + // No dependencies; the GitHub client is a small fake defined at the bottom. | ||
| 7 | + // CI runs it in the checks job (.github/workflows/shared.yml). | ||
| 8 | + 'use strict'; | ||
| 9 | + | ||
| 10 | + const test = require('node:test'); | ||
| 11 | + const assert = require('node:assert/strict'); | ||
| 12 | + const { authorize, comment } = require('./docs_preview.js'); | ||
| 13 | + | ||
| 14 | + const REPO = { owner: 'modelcontextprotocol', repo: 'python-sdk' }; | ||
| 15 | + const HEAD = 'e4dfda7baa127ab00ebcd1d5324560cbe3cdfe42'; | ||
| 16 | + const MARKER = '<!-- docs-preview -->'; | ||
| 17 | + | ||
| 18 | + // `permission` / `role_name` as the collaborators API reports them. | ||
| 19 | + const PEOPLE = { | ||
| 20 | + admin: { permission: 'admin', role_name: 'admin' }, | ||
| 21 | + maintainer: { permission: 'write', role_name: 'maintain' }, | ||
| 22 | + writer: { permission: 'write', role_name: 'write' }, | ||
| 23 | + outsider: { permission: 'read', role_name: 'read' }, | ||
| 24 | + }; | ||
| 25 | + | ||
| 26 | + // ── authorize ────────────────────────────────────────────────────────────── | ||
| 27 | + // `expect` is the full set of job outputs the step writes. | ||
| 28 | + | ||
| 29 | + const authorizeScenarios = [ | ||
| 30 | + { | ||
| 31 | + name: 'admin pushes to (or opens) a PR → automatic preview of that head', | ||
| 32 | + event: pushed(7, 'admin'), | ||
| 33 | + expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'false' }, | ||
| 34 | + }, | ||
| 35 | + { | ||
| 36 | + name: 'someone with write but not admin pushes → no automatic preview', | ||
| 37 | + event: pushed(7, 'writer'), | ||
| 38 | + expect: { authorized: 'false', pr_number: '7', head_sha: HEAD, slash_attempt: 'false' }, | ||
| 39 | + }, | ||
| 40 | + { | ||
| 41 | + name: 'maintainer comments /preview-docs on an open PR → preview of its current head', | ||
| 42 | + event: slash(7, 'maintainer'), | ||
| 43 | + expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'true' }, | ||
| 44 | + }, | ||
| 45 | + { | ||
| 46 | + name: 'admin comments /preview-docs → authorized as well', | ||
| 47 | + event: slash(7, 'admin'), | ||
| 48 | + expect: { authorized: 'true', pr_number: '7', head_sha: HEAD, slash_attempt: 'true' }, | ||
| 49 | + }, | ||
| 50 | + { | ||
| 51 | + name: 'writer without the maintain role comments /preview-docs → refused, recorded as an attempt', | ||
| 52 | + event: slash(7, 'writer'), | ||
| 53 | + expect: { authorized: 'false', pr_number: '7', head_sha: '', slash_attempt: 'true' }, | ||
| 54 | + }, | ||
| 55 | + { | ||
| 56 | + name: 'outsider comments /preview-docs → refused, recorded as an attempt', | ||
| 57 | + event: slash(7, 'outsider'), | ||
| 58 | + expect: { authorized: 'false', pr_number: '7', head_sha: '', slash_attempt: 'true' }, | ||
| 59 | + }, | ||
| 60 | + { | ||
| 61 | + name: '/preview-docs on a closed PR → refused even for a maintainer', | ||
| 62 | + pr: { state: 'closed' }, | ||
| 63 | + event: slash(7, 'maintainer'), | ||
| 64 | + expect: { authorized: 'false', pr_number: '7', head_sha: '', slash_attempt: 'true' }, | ||
| 65 | + }, | ||
| 66 | + ]; | ||
| 67 | + | ||
| 68 | + for (const s of authorizeScenarios) { | ||
| 69 | + test(`authorize: ${s.name}`, async () => { | ||
| 70 | + const world = makeWorld({ pr: { number: 7, ...s.pr } }); | ||
| 71 | + assert.deepEqual(await runAuthorize(world, s.event), s.expect); | ||
| 72 | + assert.equal(world.writes.length, 0); | ||
| 73 | + }); | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + test('authorize: a failing permission lookup fails the step instead of deciding either way', async () => { | ||
| 77 | + const world = makeWorld({ pr: { number: 7 } }); | ||
| 78 | + world.failPermissionLookup = true; | ||
| 79 | + await assert.rejects(runAuthorize(world, pushed(7, 'admin')), /boom/); | ||
| 80 | + }); | ||
| 81 | + | ||
| 82 | + // ── comment ──────────────────────────────────────────────────────────────── | ||
| 83 | + | ||
| 84 | + const DEPLOYED = { | ||
| 85 | + AUTHORIZED: 'true', | ||
| 86 | + PR_NUMBER: '7', | ||
| 87 | + HEAD_SHA: HEAD, | ||
| 88 | + DEPLOY_RESULT: 'success', | ||
| 89 | + DEPLOYMENT_URL: 'https://1a2b3c.mcp-python-sdk-docs.pages.dev', | ||
| 90 | + ALIAS_URL: 'https://pr-7.mcp-python-sdk-docs.pages.dev', | ||
| 91 | + RUN_URL: 'https://github.com/modelcontextprotocol/python-sdk/actions/runs/1', | ||
| 92 | + }; | ||
| 93 | + | ||
| 94 | + test('comment: a refused /preview-docs gets a plain reply to the commenter, not a preview comment', async () => { | ||
| 95 | + const world = makeWorld({ pr: { number: 7 } }); | ||
| 96 | + await runComment(world, { ...DEPLOYED, AUTHORIZED: 'false', HEAD_SHA: '', DEPLOY_RESULT: 'skipped' }, 'outsider'); | ||
| 97 | + assert.equal(world.comments.length, 1); | ||
| 98 | + assert.match(world.comments[0].body, /^@outsider — only repository admins or maintainers can run `\/preview-docs`/); | ||
| 99 | + assert.ok(!world.comments[0].body.includes(MARKER)); | ||
| 100 | + }); | ||
| 101 | + | ||
| 102 | + test('comment: first successful deploy posts one preview comment linking the alias URL and the commit', async () => { | ||
| 103 | + const world = makeWorld({ pr: { number: 7 } }); | ||
| 104 | + await runComment(world, DEPLOYED, 'admin'); | ||
| 105 | + assert.equal(world.comments.length, 1); | ||
| 106 | + const body = world.comments[0].body; | ||
| 107 | + assert.ok(body.startsWith(`${MARKER}\n### 📚 Documentation preview`)); | ||
| 108 | + assert.match(body, /\| \*\*Preview\*\* \| https:\/\/pr-7\.mcp-python-sdk-docs\.pages\.dev \|/); | ||
| 109 | + assert.match(body, /\| \*\*Deployment\*\* \| https:\/\/1a2b3c\.mcp-python-sdk-docs\.pages\.dev \|/); | ||
| 110 | + assert.match(body, /\| \*\*Commit\*\* \| `e4dfda7` \|/); | ||
| 111 | + assert.match(body, /\| \*\*Triggered by\*\* \| @admin \|/); | ||
| 112 | + assert.deepEqual(world.writes, ['comment on #7']); | ||
| 113 | + }); | ||
| 114 | + | ||
| 115 | + test('comment: a later deploy edits the existing preview comment instead of adding another', async () => { | ||
| 116 | + const world = makeWorld({ pr: { number: 7 }, comments: [{ user: 'someone', body: 'LGTM' }, { user: 'github-actions[bot]', body: `${MARKER}\nold table` }] }); | ||
| 117 | + await runComment(world, { ...DEPLOYED, HEAD_SHA: 'f'.repeat(40) }, 'admin'); | ||
| 118 | + assert.equal(world.comments.length, 2); | ||
| 119 | + assert.match(world.comments[1].body, /\| \*\*Commit\*\* \| `fffffff` \|/); | ||
| 120 | + assert.deepEqual(world.writes, ['edit comment 101']); | ||
| 121 | + }); | ||
| 122 | + | ||
| 123 | + test("comment: someone else's comment that happens to contain the marker is left alone", async () => { | ||
| 124 | + const world = makeWorld({ pr: { number: 7 }, comments: [{ user: 'someone', body: `quoting ${MARKER} here` }] }); | ||
| 125 | + await runComment(world, DEPLOYED, 'admin'); | ||
| 126 | + assert.equal(world.comments.length, 2); | ||
| 127 | + assert.equal(world.comments[0].body, `quoting ${MARKER} here`); | ||
| 128 | + assert.deepEqual(world.writes, ['comment on #7']); | ||
| 129 | + }); | ||
| 130 | + | ||
| 131 | + test('comment: with no alias URL the preview link falls back to the deployment URL', async () => { | ||
| 132 | + const world = makeWorld({ pr: { number: 7 } }); | ||
| 133 | + await runComment(world, { ...DEPLOYED, ALIAS_URL: '' }, 'admin'); | ||
| 134 | + assert.match(world.comments[0].body, /\| \*\*Preview\*\* \| https:\/\/1a2b3c\.mcp-python-sdk-docs\.pages\.dev \|/); | ||
| 135 | + }); | ||
| 136 | + | ||
| 137 | + test('comment: a build or deploy that did not succeed is reported with the short SHA and a link to the run', async () => { | ||
| 138 | + const world = makeWorld({ pr: { number: 7 }, comments: [{ user: 'github-actions[bot]', body: `${MARKER}\nold table` }] }); | ||
| 139 | + await runComment(world, { ...DEPLOYED, DEPLOY_RESULT: 'skipped', DEPLOYMENT_URL: '', ALIAS_URL: '' }, 'admin'); | ||
| 140 | + assert.equal(world.comments.length, 1); | ||
| 141 | + assert.equal( | ||
| 142 | + world.comments[0].body, | ||
| 143 | + `${MARKER}\n### 📚 Documentation preview\n\n❌ Preview build **failed** for \`e4dfda7\` — [workflow logs](${DEPLOYED.RUN_URL}).` | ||
| 144 | + ); | ||
| 145 | + }); | ||
| 146 | + | ||
| 147 | + // ── Harness ──────────────────────────────────────────────────────────────── | ||
| 148 | + | ||
| 149 | + function pushed(number, sender) { | ||
| 150 | + return { eventName: 'pull_request_target', actor: sender, payload: { action: 'synchronize', pull_request: { number, head: { sha: HEAD } }, sender: { login: sender } } }; | ||
| 151 | + } | ||
| 152 | + function slash(number, commenter) { | ||
| 153 | + return { eventName: 'issue_comment', actor: commenter, payload: { action: 'created', issue: { number, pull_request: {} }, comment: { body: '/preview-docs', user: { login: commenter } } } }; | ||
| 154 | + } | ||
| 155 | + | ||
| 156 | + async function runAuthorize(world, event) { | ||
| 157 | + const outputs = {}; | ||
| 158 | + const core = { info: () => {}, setOutput: (k, v) => { outputs[k] = v; } }; | ||
| 159 | + await authorize({ github: world.github, context: { repo: REPO, ...event }, core }); | ||
| 160 | + return outputs; | ||
| 161 | + } | ||
| 162 | + | ||
| 163 | + async function runComment(world, env, actor) { | ||
| 164 | + const saved = {}; | ||
| 165 | + for (const [k, v] of Object.entries(env)) { saved[k] = process.env[k]; process.env[k] = v; } | ||
| 166 | + try { | ||
| 167 | + await comment({ github: world.github, context: { repo: REPO, actor }, core: {} }); | ||
| 168 | + } finally { | ||
| 169 | + for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } | ||
| 170 | + } | ||
| 171 | + } | ||
| 172 | + | ||
| 173 | + // ── A tiny in-memory GitHub ──────────────────────────────────────────────── | ||
| 174 | + | ||
| 175 | + function makeWorld({ pr, comments = [] }) { | ||
| 176 | + const world = { pr: { state: 'open', ...pr }, comments: [], writes: [], failPermissionLookup: false, nextCommentId: 100 }; | ||
| 177 | + for (const c of comments) world.comments.push({ id: world.nextCommentId++, ...c }); | ||
| 178 | + | ||
| 179 | + const err = (status, message = 'fake error') => Object.assign(new Error(message), { status }); | ||
| 180 | + const write = (what) => world.writes.push(what); | ||
| 181 | + const checkPr = (n) => { if (n !== world.pr.number) throw err(404); }; | ||
| 182 | + | ||
| 183 | + const rest = { | ||
| 184 | + repos: { | ||
| 185 | + getCollaboratorPermissionLevel: async ({ username }) => { | ||
| 186 | + if (world.failPermissionLookup) throw err(500, 'boom'); | ||
| 187 | + const person = PEOPLE[username]; | ||
| 188 | + if (!person) throw err(404, 'not a user'); | ||
| 189 | + return { data: { ...person, user: { login: username } } }; | ||
| 190 | + }, | ||
| 191 | + }, | ||
| 192 | + pulls: { | ||
| 193 | + get: async ({ pull_number }) => { checkPr(pull_number); return { data: { number: pull_number, state: world.pr.state, head: { sha: HEAD } } }; }, | ||
| 194 | + }, | ||
| 195 | + issues: { | ||
| 196 | + listComments: async ({ issue_number }) => { checkPr(issue_number); return { data: world.comments.map((c) => ({ id: c.id, body: c.body, user: { login: c.user } })) }; }, | ||
| 197 | + createComment: async ({ issue_number, body }) => { | ||
| 198 | + checkPr(issue_number); | ||
| 199 | + write(`comment on #${issue_number}`); | ||
| 200 | + world.comments.push({ id: world.nextCommentId++, user: 'github-actions[bot]', body }); | ||
| 201 | + }, | ||
| 202 | + updateComment: async ({ comment_id, body }) => { | ||
| 203 | + write(`edit comment ${comment_id}`); | ||
| 204 | + const c = world.comments.find((x) => x.id === comment_id); | ||
| 205 | + if (!c) throw err(404); | ||
| 206 | + c.body = body; | ||
| 207 | + }, | ||
| 208 | + }, | ||
| 209 | + }; | ||
| 210 | + world.github = { rest, paginate: async (fn, args) => (await fn(args)).data }; | ||
| 211 | + return world; | ||
| 212 | + } | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments