From bd99c9a7162ed9783a0c3b17212e4652b3c8ce4a Mon Sep 17 00:00:00 2001 From: Kaan Kacar Date: Thu, 27 Aug 2026 22:55:36 +0300 Subject: [PATCH 1/3] Add secretless site CI for fork PRs preview-pr.yml skips fork PRs because it needs a write token. That leaves fork PRs with no lint or build evidence. This job runs the same checks with no secrets and a read-only token, so it is safe to run on untrusted code. workflow_dispatch covers fork PRs opened before this landed. --- .github/workflows/site-ci-fork.yml | 74 ++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 .github/workflows/site-ci-fork.yml diff --git a/.github/workflows/site-ci-fork.yml b/.github/workflows/site-ci-fork.yml new file mode 100644 index 0000000..98663a4 --- /dev/null +++ b/.github/workflows/site-ci-fork.yml @@ -0,0 +1,74 @@ +name: Site CI for fork PRs + +# Secretless lint/type/build check for the PRs `preview-pr.yml` skips. +# +# The preview workflow deliberately excludes fork PRs: it needs a write +# token to push to `gh-pages` and comment, and fork code must never run +# next to write access. That leaves fork PRs with no CI evidence that +# the site still lints and builds, so a reviewer has to build the +# branch locally before merging site changes. +# +# This job closes that gap safely. It runs the same checks the preview +# runs (install, lint, lint:ts, build) but holds nothing an attacker +# could take: no secrets are referenced, and `permissions` limits the +# GITHUB_TOKEN to read-only contents access. Untrusted code executed +# here (dependency postinstall hooks, the build itself) can only waste +# this runner's minutes. +# +# `workflow_dispatch` exists so a maintainer can produce the same +# evidence for fork PRs opened before this workflow landed. + +on: + pull_request: + branches: [main] + paths: ['site/**'] + workflow_dispatch: + inputs: + pr: + description: 'PR number to check (builds refs/pull//head)' + required: true + type: string + +permissions: + contents: read + +concurrency: + group: site-ci-${{ github.event.pull_request.number || inputs.pr }} + cancel-in-progress: true + +defaults: + run: + working-directory: site + +jobs: + site-ci: + # Internal PRs already get these checks from `preview-pr.yml`; only + # fork PRs (and manual dispatches) need this job. + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + # Build the PR head, not the auto-generated merge commit, for the + # same reason as preview-pr.yml: the evidence should match exactly + # what was pushed. + - uses: actions/checkout@v4 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/pull/{0}/head', inputs.pr) || github.event.pull_request.head.sha }} + + - uses: pnpm/action-setup@v4 + with: + version: 10.15.1 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: site/pnpm-lock.yaml + + - run: pnpm install --frozen-lockfile + + - run: pnpm lint + + - run: pnpm lint:ts + + - run: pnpm build From f3f60bfbc171dade19442cf452b92b3581829407 Mon Sep 17 00:00:00 2001 From: Kaan Kacar Date: Thu, 27 Aug 2026 20:01:15 +0000 Subject: [PATCH 2/3] Cover skills/ in the fork CI trigger and drop the checkout token The site build reads skills/ as well as site/: prebuild copies every source advertised in skills.ts and fails when one is missing. A site/**-only path filter would miss a fork PR that renames or deletes a skill file, which is exactly the break that stops the main deploy. Also set persist-credentials: false so the checkout leaves no token behind for the fork's build scripts to read. --- .github/workflows/site-ci-fork.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/site-ci-fork.yml b/.github/workflows/site-ci-fork.yml index 98663a4..59beacb 100644 --- a/.github/workflows/site-ci-fork.yml +++ b/.github/workflows/site-ci-fork.yml @@ -21,7 +21,15 @@ name: Site CI for fork PRs on: pull_request: branches: [main] - paths: ['site/**'] + # Same trigger set as `deploy-pages.yml`, because the build reads + # both trees: `prebuild` copies every `source:` advertised in + # site/src/data/skills.ts out of `skills/`, and fails when one is + # missing. A PR that renames or deletes a skill file without + # updating skills.ts breaks the build from `skills/` alone. + paths: + - "skills/**" + - "site/**" + - ".github/workflows/site-ci-fork.yml" workflow_dispatch: inputs: pr: @@ -54,6 +62,11 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/pull/{0}/head', inputs.pr) || github.event.pull_request.head.sha }} + # Drop the token from .git/config once the fetch is done. The + # token is read-only on a public repo, so it grants nothing an + # anonymous clone lacks, but this leaves the checkout with + # literally no credential for fork code to read. + persist-credentials: false - uses: pnpm/action-setup@v4 with: From ab404129a1a0a9678f259b25918f9c3f8a26161c Mon Sep 17 00:00:00 2001 From: Kaan Kacar Date: Thu, 27 Aug 2026 23:05:16 +0300 Subject: [PATCH 3/3] Drop workflow_dispatch: fork code must not run in a trusted context A dispatch executes the fork's code on the default-branch ref, so its cache save lands in default-branch scope, which write-capable workflows restore. Close and reopen a pre-existing fork PR instead; the reopened event runs this check in the untrusted pull_request context. --- .github/workflows/site-ci-fork.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/site-ci-fork.yml b/.github/workflows/site-ci-fork.yml index 59beacb..d101713 100644 --- a/.github/workflows/site-ci-fork.yml +++ b/.github/workflows/site-ci-fork.yml @@ -15,8 +15,14 @@ name: Site CI for fork PRs # here (dependency postinstall hooks, the build itself) can only waste # this runner's minutes. # -# `workflow_dispatch` exists so a maintainer can produce the same -# evidence for fork PRs opened before this workflow landed. +# There is intentionally NO workflow_dispatch trigger: a manual +# dispatch would execute fork code in a trusted (default-branch) +# context, where its cache writes land in default-branch scope and can +# be restored later by write-capable workflows. To produce evidence for +# a fork PR opened before this workflow landed, close and reopen the PR +# (or have the author push): the `reopened` event re-runs this check in +# the untrusted pull_request context. First-time contributors' runs +# still wait for a maintainer's "Approve and run" click, as usual. on: pull_request: @@ -30,18 +36,12 @@ on: - "skills/**" - "site/**" - ".github/workflows/site-ci-fork.yml" - workflow_dispatch: - inputs: - pr: - description: 'PR number to check (builds refs/pull//head)' - required: true - type: string permissions: contents: read concurrency: - group: site-ci-${{ github.event.pull_request.number || inputs.pr }} + group: site-ci-${{ github.event.pull_request.number }} cancel-in-progress: true defaults: @@ -51,8 +51,8 @@ defaults: jobs: site-ci: # Internal PRs already get these checks from `preview-pr.yml`; only - # fork PRs (and manual dispatches) need this job. - if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name != github.repository + # fork PRs need this job. + if: github.event.pull_request.head.repo.full_name != github.repository runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -61,7 +61,7 @@ jobs: # what was pushed. - uses: actions/checkout@v4 with: - ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/pull/{0}/head', inputs.pr) || github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha }} # Drop the token from .git/config once the fetch is done. The # token is read-only on a public repo, so it grants nothing an # anonymous clone lacks, but this leaves the checkout with