Skip to content

Give CI's jobs a read-only token by default - #197

Merged
MaxGhenis merged 2 commits into
mainfrom
ci-default-read-permissions
Oct 19, 2026
Merged

MaxGhenis merged 2 commits into
mainfrom
ci-default-read-permissions

Conversation

@MaxGhenis

Copy link
Copy Markdown
Contributor

What

.github/workflows/ci.yml gets a workflow-level permissions: contents: read. Every job without its own block now gets a read-only GITHUB_TOKEN instead of the repository default, which is write (gh api repos/PolicyEngine/policyengine-scorecard/actions/permissions/workflow → default_workflow_permissions: write).

Three jobs had no block and held that write token: python (pytest · ruff format), app (typecheck · build) and publish-db. The python job runs uv run --with … PyPI packages on pushes to main, and actions/checkout persists the token into .git/config for them. replay-engine and receiver-sandbox already set contents: read and keep their blocks.

This is the other half of the least-privilege work from both reviews of #193. #196 removes the results bot's workflows permission. This PR touches only ci.yml's top lines; #196's ci.yml changes start at the replay-variants job, so the two don't conflict.

Why nothing needs more than contents: read

Job Uses of GITHUB_TOKEN Needs
python checkout fetches this repo; setup-uv's github-token default reads astral-sh/uv's public releases; upload-artifact@v4 has no token input contents: read
app checkout; setup-bun's token default reads oven-sh/bun's public tags contents: read
publish-db download-artifact@v4 with name only, from the same run nothing

Artifact transfer within one run does not use GITHUB_TOKEN:

  • download-artifact v4 action.yml: github-token is "required when downloading artifacts from a different repository or from a different workflow run. If this is not specified, the action will attempt to download artifacts from the current repository and the current workflow run." Its README adds that by default the permissions are scoped so they can only download artifacts within the current workflow run.
  • src/download-artifact.ts sets findBy only if (inputs.token). Without it, @actions/artifact's getArtifact calls getArtifactInternal, a Twirp client whose token is getRuntimeToken() = ACTIONS_RUNTIME_TOKEN (packages/artifact/src/internal/shared/config.ts).
  • In-repo precedent: uk-replay.yml's run job has only contents: read and runs download-artifact@v4 and upload-artifact@v4. Both succeeded in runs 39204514294, 39200154998, 39196267700 and 39180905504.

GitHub's docs (workflow-syntax, "How permissions are calculated for a workflow job"): the repository default is adjusted by the workflow-level block, then the job-level block, and "if you specify the access for any of these permissions, all of those that are not specified are set to none."

Invariants (pinned in tests/test_ci_gates.py)

  • ci.yml's workflow-level permissions is exactly {"contents": "read"}.
  • Each job's effective grant (its own block, else the workflow's, else the repo default) is within {"contents": "read"}, or within its entry in NEEDS_MORE, which is empty. A string grant (read-all/write-all) or a missing grant fails.
  • The check is not vacuous. Nine parametrized widenings are each caught: no workflow block, workflow write-all, read-all, contents: write, an added actions: read, python contents: write, app write-all, publish-db adding actions: read, and replay-engine id-token: write. Narrowing ({}, contents: none) passes.
  • publish-db downloads the artifact with the same name the python job uploads, from this run (with is exactly {name}, no github-token, needs: python), on upload-artifact@v4/download-artifact@v4. A major bump should re-check the token claim. Nothing in ci.yml hands secrets.GITHUB_TOKEN or github.token to a step.

Against main's ci.yml, the check reports ['the workflow level is None', 'python gets None', 'app gets None', 'publish-db gets None'].

Property-based tests don't fit here: the input is one static file. The mutation cases above do that job.

Tests run locally

One file at a time, -n ≤ 4, .venv-tests:

  • tests/test_ci_gates.py: 20 passed
  • tests/test_uk_replay_queue.py: 22 passed (its token pins hold)
  • tests/test_uk_replay_results_pr.py: 62 passed (add_matrix_variant compares the whole parsed file, and the new top-level key is in both before and after)
  • tests/test_uk_replay_determinism.py: 12 passed
  • uvx ruff format --check tests/test_ci_gates.py: clean

This PR's CI shows python (upload) and app running under the new block. publish-db runs only on pushes to main. The merge's push run will show it still uploads, or warns as before (the Supabase host stopped resolving on 6 October).

axiom: n/a: CI configuration only

🤖 Generated with Claude Code

The repository's default GITHUB_TOKEN is write, and three ci.yml jobs
(pytest · ruff format, typecheck · build, publish-db) had no permissions
block, so they held it. The pytest job runs PyPI packages with
checkout's persisted token on pushes to main. A workflow-level
`permissions: contents: read` now covers every job without its own block.

None of the three needs more: checkout fetches this repository,
setup-uv and setup-bun read public releases, and the database artifact
is uploaded and then downloaded within one run through the Actions
runtime token, not GITHUB_TOKEN (download-artifact v4 without
`github-token` reads only the current run).

tests/test_ci_gates.py pins the workflow block to exactly contents:
read, checks every job's effective grant against it, shows the check
catches nine widenings and allows narrowing, and pins publish-db's
same-run, tokenless download.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
policyengine-scorecard Ready Ready Preview Oct 19, 2026 12:36pm UTC

Request Review

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 19, 2026 12:37
@MaxGhenis
MaxGhenis merged commit 2e84137 into main Oct 19, 2026
11 checks passed
@MaxGhenis
MaxGhenis deleted the ci-default-read-permissions branch October 19, 2026 13:29

This branch was successfully deployed

1 active deployment
Preview — 9a0ad772 Deployed Oct 19, 2026 by vercel[bot]
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.

1 participant