Skip to content

Commit 761425d

Browse files
committed
Move the docs-preview workflow scripts out of the YAML into .github/scripts
The `authorize` and `comment` steps of docs-preview.yml carried ~45 lines of inline JavaScript each. Move them into .github/scripts/docs_preview.js as two exported functions, the same shape require-linked-issue.yml already uses for pr_intake_gate.js, so they can be linted, reviewed and tested like code. The workflow steps shrink to a require plus a call; behaviour is unchanged. Both jobs now check out .github/scripts from the default branch (sparse, persist-credentials off) so the module is on disk for github-script's require, and `comment` gains the contents: read that needs. Under pull_request_target and issue_comment that is the trusted default-branch checkout, never PR code. Add scenario tests for both functions next to the gate's, and have the checks job run every *.test.js under .github/scripts rather than one named file.
1 parent 2e0a181 commit 761425d

4 files changed

Lines changed: 342 additions & 92 deletions

File tree

.github/scripts/docs_preview.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff 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 };
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff 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+
}

0 commit comments

Comments
 (0)