Plan: a job token that expires with the job and honours permissions:
Status: ready to execute. Written to be carried out start to finish by one agent without further questions. Prerequisite for workflow-fork-pull-requests.md, which needs a read-only token. Independent of the other CI plans.
Why
The job token (${{ github.token }}; secrets.GITHUB_TOKEN once
workflow-runner-context.md is in) is minted
by GitGud.Workflows.RunnerJwt.mint/2 when a job is claimed and checked by
RunnerJwt.verify/1 everywhere it is accepted. Two things differ from what
a workflow author assumes:
- It outlives the job by up to 24 hours.
verify/1checks the signature, issuer, audience andexp(ttl_seconds, default 86 400) — never whether the job is still running. GitHub’s token expires when the job finishes. A token that leaks into a log, an artifact or a third-party action keeps working for a day: it can publish packages to the repository and read its private code. Silent. permissions:is ignored. Whatever a workflow or job declares, the token can do the same fixed set of things (below). A workflow that sayspermissions: contents: readto sandbox a third-party action still hands it package-publish rights; one that sayscontents: writeto push a tag or a version bump gets a 403 from git (receive-packis refused to every job token inGitGudWeb.Plugs.GitSmartHttp). The first is silent, the second loud.
What a job token can do today (verified):
| where | what | code |
|---|---|---|
| git over HTTP | clone/fetch own repo only (upload-pack) | GitGudWeb.Plugs.GitSmartHttp (authenticate/1 → {:job, pid}, the {:job, pid} clause) |
| package registries + OCI | read and write own repo’s packages; read a repo that granted it | GitGud.Packages.Auth.verify_job_or_user/2, GitGud.Packages.Access.allowed?/3 ({:job, …} clauses) |
| REST API | read own repo’s metadata | GitGudWeb.Api.V1.RepositoryController.runner_token_for?/2 |
| workflow_dispatch API | dispatch a repo that granted dispatch |
GitGudWeb.Api.V1.WorkflowController.authorize/2, GitGud.Workflows.JobTokenGrants.allows?/3 |
| artifacts + cache | own run / own repo | GitGudWeb.Plugs.RunnerJwtAuth + the two controllers |
Semantics to match (GitHub, “Controlling permissions for GITHUB_TOKEN”)
permissions:at workflow level, overridable per job (job wins entirely, no merge). Values:read-all,write-all,{}(nothing), or a map of scope →read|write|none. When a map is given, every scope not listed isnone.- Scopes that mean something here:
contents(git read/write),packages(registry read/write),actions(write= may dispatch workflows, subject to the target’s job-token grant;read= nothing extra today),metadata(always read). Others (issues,pull-requests,statuses,checks,deployments,id-token, …) are accepted and ignored — gitgud’s API doesn’t let a job token do those things yet; list them in the docs. - Default when no
permissions:key at either level: keep today’s rights so no existing workflow breaks:contents: read, packages: write, actions: write, metadata: read. (GitHub’s “permissive” default would also grantcontents: write; don’t widen silently — a workflow that wants to push says so.) contents: writeallowsgit pushto the job’s own repository, subject to branch protection as a push by nobody in particular: it can’t satisfy required approvals, and force-push/deletion rules apply (BranchProtections.check_ref_update/5already treats a push without a PR that way).- The token stops working when the job ends, with a short grace period for the runner’s post-job steps (cache save, artifact finalize): accept a token whose job is non-terminal, or terminal for less than 5 minutes.
Where things are
| Token | lib/git_gud/workflows/runner_jwt.ex mint/2, verify/1; minted in lib/git_gud_web/controllers/runner_controller.ex build_task/1 |
| Consumers | the five in the table above, plus lib/git_gud_web/plugs/runner_jwt_auth.ex |
| Job definition | job.definition (raw job YAML: permissions), run.workflow_doc (top level permissions) |
| Git push path | GitSmartHttp (can?/3, the receive-pack branch), and the pre-receive check lib/git_gud_web/controllers/pre_receive_controller.ex (how does it learn who pushed? check what the hook sends and what it does with a job principal) |
| Tests | test/git_gud/workflows/runner_jwt_test.exs, test/git_gud_web/ tests for git smart HTTP, package registry and API job-token cases (grep RunnerJwt.mint under test/) |
Steps
1. Effective permissions
GitGud.Workflows.Permissions.effective(workflow_doc, job_definition) →
%{"contents" => :read | :write | :none, "packages" => …, "actions" => …}
per the rules above (job-level replaces workflow-level; read-all /
write-all / {}; unknown scopes dropped; malformed values → :none,
logged). Pure, unit-tested.
2. Put them in the token
RunnerJwt.mint/2 → mint(job, run, perms) adding claim "perm" (e.g.
%{"contents" => "read", "packages" => "write", "actions" => "write"}).
build_task/1 computes perms from run.workflow_doc and
job.definition. A token without "perm" (minted before this change,
still within its 24 h) is treated as the old default — so nothing in
flight breaks during the deploy.
3. Expire with the job
Add RunnerJwt.verify_active(token): verify/1, then load the job
(claims["tid"]) and require it to exist and be non-terminal, or terminal
with completed_at within the last 5 minutes. Replace verify/1 with
verify_active/1 in every consumer (the table above; grep
RunnerJwt.verify(). Keep verify/1 for tests and internal use. One
Repo.get per authenticated request is acceptable; if a consumer is hot
(the OCI registry does many requests per push), cache the job’s state in
the conn assigns for the request only — no global cache.
4. Enforce the scopes
Add RunnerJwt.permits?(claims, scope, level). Then:
- packages: in
GitGud.Packages.Access.allowed?/3the principal{:job, repo_id}becomes{:job, repo_id, perms}(updatePackages.Authand every match on{:job, …}— grep it). Own-repo:writerequirespackages: write; own-repo:readrequires at leastpackages: read; reading a granting repo requirespackages: read. - contents: in
GitSmartHttp, a job principal may upload-pack its own repo withcontents: read|write, and receive-pack its own repo withcontents: write. Follow the receive-pack path to the pre-receive hook and make sure a job push is checked against branch protection and attributed (the run’striggered_by_idif any; otherwise record the pusher as nil — read howCommitBackfilltreatspusher_id: nil). A push by a job must not trigger the same workflow forever: GitHub doesn’t startpushworkflows for pushes made withGITHUB_TOKEN. Do the same: carry a marker through the hook (e.g. the job id in an env var the pre-receive/post-receive hook forwards — find howpusher_idgets intoCommitBackfill.new_job/1and addjob_token: truenext to it), and inCommitBackfillskipdispatch_workflows/5for such pushes (still record commits, webhooks, PR refresh — but nosynchronizedispatch either). - actions:
WorkflowController.authorize/2additionally requiresactions: write. - metadata: always allowed.
5. Docs
README CI list: token lifetime, permissions: support (which scopes
mean something), the default, and that pushes made with the job token
don’t start workflows.
Tests
Permissions.effective/2: no key → default;read-all;write-all;{}; map with one scope → others none; job overrides workflow.verify_active/1: running job ok; job terminal 1 minute ago ok; 10 minutes ago → error; deleted job → error; signature still checked.- Packages:
permissions: {contents: read}→ publishing with the job token is refused, reading too; default → publishing works (regression). - Git:
contents: write→ push to own repo succeeds, to another repo fails; default → push refused (403/401 as today); a push to a protected branch requiring approvals is refused. - A push made with a job token creates no workflow runs (on-disk test
through
CommitBackfill.perform/1with the marker set). - Dispatch API with a granted job token lacking
actions: write→ 403.
Done when
mix testpasses; no new compile warnings (comparemix compile --forcewarning lists before/after);mix formatonly touched files.- One commit in the repository’s message style, calling out that leaked job tokens previously stayed valid for 24 hours.
- Not pushed.