@@ -4,11 +4,14 @@ name: Docs Preview
44#
55# Security: the build executes Python from the PR (mkdocstrings imports
66# src/mcp, `!!python/name:` config directives run, and heads may ship their
7- # own build scripts). The build is gated by `authorize` (admin sender for
8- # auto-preview, admin/maintainer commenter for /preview-docs) and isolated
9- # from Cloudflare secrets — `build` runs PR code with no secrets and hands
10- # the static site to `deploy` via an artifact, so PR code never shares a
11- # runner with the Cloudflare token.
7+ # own build scripts). The build is gated by `authorize` (admin sender on a
8+ # same-repo branch for auto-preview, admin/maintainer commenter for
9+ # /preview-docs) and isolated from Cloudflare secrets — `build` runs PR code
10+ # with no secrets and hands the static site to `deploy` via an artifact, so
11+ # PR code never shares a runner with the Cloudflare token. Fork PRs get no
12+ # automatic preview: actions/checkout refuses to fetch a fork's head in a
13+ # pull_request_target run, so a maintainer requests one with /preview-docs,
14+ # which runs under issue_comment with the same gating and isolation.
1215#
1316# Required configuration:
1417# - secrets.CLOUDFLARE_API_TOKEN (scope: Account → Cloudflare Pages → Edit)
@@ -76,15 +79,25 @@ jobs:
7679 let slashAttempt = false;
7780
7881 if (context.eventName === 'pull_request_target') {
79- // Gate on the *sender* (whoever caused this run — on synchronize that
80- // is the pusher), not the PR author, so a non-admin pushing to an
81- // admin-opened branch does not get an automatic build.
82- const actor = context.payload.sender.login;
83- prNumber = String(context.payload.pull_request.number);
84- headSha = context.payload.pull_request.head.sha;
85- const perm = await permissionFor(actor);
86- authorized = perm.level === 'admin';
87- core.info(`pull_request_target by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`);
82+ const pr = context.payload.pull_request;
83+ prNumber = String(pr.number);
84+ headSha = pr.head.sha;
85+ // No automatic preview for fork PRs: actions/checkout refuses to fetch
86+ // a fork's head in a pull_request_target run. A maintainer can still
87+ // request one with /preview-docs. (head.repo is null once the fork is
88+ // deleted.)
89+ const headRepo = pr.head.repo;
90+ if (!headRepo || headRepo.id !== context.payload.repository.id) {
91+ core.info(`PR #${prNumber} head is on ${headRepo ? headRepo.full_name : 'a deleted fork'}; fork PRs are previewed via /preview-docs only.`);
92+ } else {
93+ // Gate on the *sender* (whoever caused this run — on synchronize that
94+ // is the pusher), not the PR author, so a non-admin pushing to an
95+ // admin-opened branch does not get an automatic build.
96+ const actor = context.payload.sender.login;
97+ const perm = await permissionFor(actor);
98+ authorized = perm.level === 'admin';
99+ core.info(`pull_request_target by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`);
100+ }
88101 } else {
89102 // issue_comment: the job-level `if:` already guarantees this is a PR
90103 // comment starting with /preview-docs.
@@ -125,19 +138,20 @@ jobs:
125138 - name : Install uv
126139 uses : astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
127140 with :
128- # pull_request_target runs share the base-branch Actions cache; saving
129- # a cache populated while untrusted PR code ran would let it poison
130- # later trusted workflows. Mirrors publish-pypi.yml.
141+ # Keep the untrusted build away from the shared Actions cache. GitHub
142+ # already limits pull_request_target and issue_comment runs to
143+ # read-only access in the default branch's cache scope, so this is
144+ # defence in depth (and avoids a refused save in the post step).
145+ # Mirrors publish-pypi.yml.
131146 enable-cache : false
132147 version : 0.9.5
133148
134- # pull_request_target runs this workflow file from the base branch, so
135- # the whole recipe — dependency sync included — must come from the
136- # checkout itself: heads that ship scripts/docs/build.sh (the Zensical
137- # toolchain) build with it; older heads, and v1.x heads previewed via
138- # /preview-docs, still build with MkDocs. Both arms must write the site
139- # to site/. Keep the detection in sync with build_site() in
140- # scripts/build-docs.sh.
149+ # Both triggers run this workflow file from the default branch (whatever
150+ # the PR targets), so the whole recipe — dependency sync included — must
151+ # come from the checkout itself: heads that ship scripts/docs/build.sh
152+ # (the Zensical toolchain) build with it; older heads and v1.x heads still
153+ # build with MkDocs. Both arms must write the site to site/. Keep the
154+ # detection in sync with build_site() in scripts/build-docs.sh.
141155 - run : |
142156 if [ -f scripts/docs/build.sh ]; then
143157 bash scripts/docs/build.sh
0 commit comments