Skip to content

fix(security): scope the Pages token to the deploy job and pin its actions - #12

Merged
xaleryb merged 2 commits into
mainfrom
fix/pages-workflow-permissions-and-pins
Sep 3, 2026
Merged

fix(security): scope the Pages token to the deploy job and pin its actions#12
xaleryb merged 2 commits into
mainfrom
fix/pages-workflow-permissions-and-pins

Conversation

@napetrov

@napetrov napetrov commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

2 of 5 in a split stack.

# PR scope base
1 #9 a dead link in an imported body warns instead of failing — unblocks Validate main
2 #12 scope the Pages token to the deploy job and pin its actions — unblocks Security main
3 #10 retry a 429 in the link check instead of accepting it #9
4 #11 run the pinned-upstream check even when the link check failed #9
5 #13 group the codeql-action pins so both halves move together #9

#9 and #12 are independent and both go straight to main — they fix the two unrelated jobs
that are red there, and neither waits on the other. #10, #11 and #13 sit on #9 only so their
Validate runs are green while main's link check is failing; GitHub retargets them to main
when #9 lands.

main is failing two independent jobs, so any PR fixing one still displays the other.
Nothing here introduces the failure it shows:

PR red check fixed by
#9, #10, #11, #13 zizmor — the Pages workflow from #7 #12
#12 validate — the dead docs.vllm.ai link #9

Everything else is green on every PR. All five test-merge into main cleanly in any order,
and the merged combination passes the full gate plus zizmor and actionlint.

What this changes

zizmor fails main with five high findings, all in the Pages workflow added in #7:

error[excessive-permissions]  deploy-github-page.yml:13  pages: write
error[excessive-permissions]  deploy-github-page.yml:14  id-token: write
error[unpinned-uses]          deploy-github-page.yml:57  actions/configure-pages@v5
error[unpinned-uses]          deploy-github-page.yml:60  actions/upload-pages-artifact@v3
error[unpinned-uses]          deploy-github-page.yml:73  actions/deploy-pages@v5

Permissions. pages: write and id-token: write were granted at the workflow level,
which hands them to both jobs. Only deploy needs them — and build is the job that should
least have them: it checks out a second branch and runs npm ci and npm run build over
its lockfile, which is the one place in this workflow where third-party code executes. A
token that can publish the site or mint an OIDC identity does not belong in the job running
someone else's postinstall script. Workflow level is now contents: read, with the two
write scopes on deploy.

Pins. Every other action in this repository is pinned by commit SHA; these three arrived
as tags. Each is pinned to the SHA the tag it was already using resolved to, so nothing
about what runs changes:

action was now
actions/configure-pages v5 983d7736… (v5.0.0)
actions/upload-pages-artifact v3 56afc609… (v3.0.1)
actions/deploy-pages v5 368f8252… (v5.0.1)

Newer majors exist — configure-pages v6.0.0 and upload-pages-artifact v5.0.0 — and are
deliberately left alone. Pinning and upgrading are different changes with different risk,
and dependabot will propose the upgrades with a CI run attached.

Kept as one PR rather than split into permissions and pins: it is one file, one regression
from one merge, and one zizmor run to re-check.

Verified

Same digest-pinned zizmor image security.yml uses:

No findings to report. Good job! (12 suppressed)

where the identical command reported 5 high before. actionlint clean.

Checklist

  • I checked description against requests a user would really type — see CONTRIBUTING.md. (no skill text changed)
  • python3 tools/validate_skills.py passes locally.
  • Every commit is signed off with git commit -s (DCO).

@xaleryb

xaleryb commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Reviewed, and pushed one commit to this branch — 18041f6. The permission scoping is the
right call, but as it stood the diff would have stopped the site from deploying, and no
pull request could have shown it.

The finding

Moving the write scopes to deploy leaves build with pages: none, and build still
runs Setup Pages. Traced through actions/configure-pages at the SHA you pinned:

where what it does
action.yml enablement defaults to 'false'
context.js enablement: core.getInput('enablement') === 'true'false
api-client.js getPagesSiteoctokit.rest.repos.getPagesGET /repos/{owner}/{repo}/pages
api-client.js catch { if (!enablement) { core.error(…); throw error } }
index.js catch { core.setFailed(error); process.exit(1) }

The docs for that endpoint say fine-grained tokens need Pages read, with a caveat that it
"can be used without authentication … if only public resources are requested". That caveat
does not apply here — an unauthenticated request from a clean IP answers 404, not 200:

$ curl -s -o /dev/null -w "%{http_code}" -H "Accept: application/vnd.github+json" \
    https://api.github.com/repos/intel/skills/pages
404

(from inside our network the same call returns 403 rate limit exceeded, which is not the
same answer and is easy to misread.)

So build fails, deploy never runs behind needs: build, and the site stops updating.
on: is push: branches: [main] with no pull_request, so this could only have surfaced
on the first push to main after the merge.

Why the scope is granted rather than the step deleted

Deleting Setup Pages also works and was my first thought — the step is unreachable as
written: no id, so its outputs cannot be referenced; enablement: false, so it creates
nothing; and it runs after the build it was meant to configure. But it is unreachable by
accident, not by design. base_path is the path GitHub actually serves the site under,
read from the repository's own Pages configuration, and #7 copied the step out of GitHub's
Pages starter workflow while hardcoding SITE_BASE: /skills/ instead of consuming it.

That hardcode duplicates a fact GitHub already holds, and it is wrong the moment the
repository is renamed or a custom domain is set — base_path is '' then, and every asset
URL has to lose its /skills/ prefix. Built it both ways to see what that costs:

SITE_BASE=/skills/  →  href="/skills/fonts/intelone-text-regular.woff2"
SITE_BASE=''        →  href="/fonts/intelone-text-regular.woff2"

Every stylesheet and all three woff2 fonts, 404 on a live site.

So the step moves above the build, gets id: pages, and its output feeds SITE_BASE. The
pages: read this needs is then earned rather than granted: one GET, to learn where the
site is served from, and nothing in build writes to Pages.

Verified

  • Built the site from github_page_src against the main catalog with SITE_BASE=/skills/
    and with SITE_BASE=/skills — which is what base_path yields here, since
    removeTrailingSlash strips the slash. diff -r reports the two dist trees
    byte-for-byte identical: astro.config.mjs's normalizeBase puts the slash back.
    SITE_BASE='' gives the domain-root build, matching all three cases in
    configure-pages' own output-pages-base-url.test.js.
  • actionlint clean over every workflow.
  • zizmor --min-severity=medium --min-confidence=medium — same binary, same flags as
    security.yml: 5 high findings on main, none on this branch.

One note on the diff, not a defect

A job's permissions: block replaces the workflow-level map rather than adding to it, so
contents: read had to be restated in build for the two checkouts. deploy is fine
without it — deploy-pages at your pin only calls the pages/deployments endpoints and
reads the artifact over ACTIONS_RUNTIME_TOKEN, never the contents API.

Two things I deliberately left alone, both from #7 rather than from this PR:

  • concurrency.cancel-in-progress: true. GitHub's Pages starter workflow uses false
    here, precisely so a production deployment already in flight is not cancelled.
  • upload-pages-artifact@56afc609 calls actions/upload-artifact@v4 inside its own
    composite. Pinning the outer SHA does not freeze the inner tag, and zizmor does not
    follow into composites — worth knowing before reading "everything is pinned" off this
    file.

Also worth stating in the PR body: CI here can confirm actionlint and zizmor and
nothing more, because this workflow has no pull_request trigger. The deploy itself is
proved by the first push to main after the merge, so that run is worth watching.

napetrov and others added 2 commits September 3, 2026 15:18
…tions

zizmor fails `main` with five high findings, all of them in the Pages workflow added
in #7:

    error[excessive-permissions]  deploy-github-page.yml:13  pages: write
    error[excessive-permissions]  deploy-github-page.yml:14  id-token: write
    error[unpinned-uses]          deploy-github-page.yml:57  actions/configure-pages@v5
    error[unpinned-uses]          deploy-github-page.yml:60  actions/upload-pages-artifact@v3
    error[unpinned-uses]          deploy-github-page.yml:73  actions/deploy-pages@v5

Permissions. `pages: write` and `id-token: write` were granted at the workflow level,
which hands them to both jobs. Only `deploy` needs them, and `build` is the job that
should least have them: it checks out a second branch and runs `npm ci` and
`npm run build` over its lockfile, which is the one place in this workflow where
third-party code executes. A token that can publish the site or mint an OIDC identity
does not belong in the job running someone else's postinstall script. Workflow level
is now `contents: read`, with the two write scopes on `deploy`.

Pins. Every other action in this repository is pinned by commit SHA; these three
arrived as tags. Each is pinned to the SHA the tag it was using already resolved to,
so nothing about what runs changes:

    actions/configure-pages       v5 -> 983d7736... (v5.0.0)
    actions/upload-pages-artifact v3 -> 56afc609... (v3.0.1)
    actions/deploy-pages          v5 -> 368f8252... (v5.0.1)

Newer majors exist — configure-pages v6.0.0 and upload-pages-artifact v5.0.0 — and
are deliberately left alone. Pinning and upgrading are different changes with
different risk, and dependabot will propose the upgrades with a CI run attached.

Verified with the same digest-pinned zizmor image security.yml uses: no findings,
where the same command reported 5 high before.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…hat needs it

Scoping the write permissions to `deploy` leaves `build` with `pages: none`, and
`build` still runs `Setup Pages`. actions/configure-pages GETs
/repos/{owner}/{repo}/pages, that endpoint is not public — an unauthenticated
request answers 404, not 200 — and with `enablement` at its default of false the
action's own error path is `core.error(...); throw`, which index.js turns into
`core.setFailed(error); process.exit(1)`. So `build` fails, `deploy` never runs
behind `needs: build`, and the site stops updating. The workflow has no
`pull_request` trigger, so no pull request can show this; it appears on the first
push to main after the merge.

The scope is granted rather than the step removed, because the step was doing
something worth doing and was simply never connected. base_path is the path
GitHub actually serves the site under, read from the repository's Pages
configuration; #7 copied the step out of GitHub's Pages starter workflow but
hardcoded `SITE_BASE: /skills/` instead of consuming it, left the step with no
`id` — which makes its outputs unreferenceable — and placed it after the build it
was meant to configure. So the step is moved above the build, given `id: pages`,
and its output feeds SITE_BASE.

The hardcode is a duplicate of a fact GitHub already holds, and it is wrong as
soon as the repository is renamed or a custom domain is set: base_path is then ''
and every asset URL has to lose its /skills/ prefix, which is every stylesheet
and all three woff2 fonts.

`contents: read` is restated in the job block because a job's permissions replace
the workflow-level map rather than adding to it, and the two checkouts need it.
`deploy` needs no contents: deploy-pages only calls the pages/deployments
endpoints and reads the artifact over ACTIONS_RUNTIME_TOKEN.

Verified: built the site from `github_page_src` against the `main` catalog with
SITE_BASE=/skills/ and with SITE_BASE=/skills, which is what base_path yields
here — `diff -r` reports the dist trees byte-for-byte identical, because
astro.config.mjs normalises the value. With SITE_BASE='' the assets move to the
domain root, which is the custom-domain case the hardcode gets wrong. actionlint
clean; zizmor at the same flags CI uses reports the 5 high findings on main and
none on this branch.

Signed-off-by: Rybkin <alexander.rybkin@intel.com>
@xaleryb
xaleryb force-pushed the fix/pages-workflow-permissions-and-pins branch from 18041f6 to 7401110 Compare September 3, 2026 22:19

@xaleryb xaleryb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved after a full pre-merge review, rebased onto d380dab.

The finding this PR now carries a fix for: a job-level permissions: block replaces the workflow-level map rather than adding to it ("If you specify the access for any of these permissions, all of those that are not specified are set to none"), so build would have run with pages: none and Setup Pages would have failed on a GET it could not make — invisible on any pull request, because this workflow has no pull_request trigger. Fixed by wiring the step rather than deleting it: id: pages, the step moved ahead of the build, and SITE_BASE taken from steps.pages.outputs.base_path.

Verified: the two site builds for /skills/ and /skills are byte-for-byte identical; base_path='' correctly moves assets to the domain root; zizmor goes from 5 high to no findings; actionlint clean; the six other open pull requests all merge and rebase clean on top of this one, with #2's and #4's action bumps intact; configure-pages v6 keeps the same outputs, so a later dependabot bump will not break the wiring.

One thing stays unproven until this lands: that pages: read suffices for the Pages GET. It is documented rather than measured, because the workflow only runs on push to main. Watching the first run after merge.

@xaleryb
xaleryb merged commit 2c8c60a into main Sep 3, 2026
7 checks passed
@xaleryb
xaleryb deleted the fix/pages-workflow-permissions-and-pins branch September 4, 2026 02:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants