Actions: the job token ends with its job and honours permissions:

a9f5a09 · Gabriel Morell · 2026-10-17 14:45

44 files +848 -70
Message
{commit_body(@commit)}

Files changed

modified README.md
+9 −5
@@ -92,7 +92,7 @@ reinvent them.
92 92 - **`on: pull_request`** for pull requests within one repository: `types:` `opened`, `synchronize` (the head moved) and `reopened` (the default; other activity types never fire). `branches:` / `branches-ignore:` filter the **target** branch, and `paths:` the PR's diff from its merge-base. Workflows are read from the PR's head commit, so a PR can change its own CI, and the job tests the head itself at `refs/pull/<N>/head` — not a merge with the target, as GitHub's `refs/pull/<N>/merge` does. The job gets `github.event_name`, `head_ref` and `base_ref` (the source and target branches), `ref_name` `<N>/head` and an empty `ref_type`. **Fork and federated pull requests don't run yet**: their head is code the target's maintainers haven't reviewed, and a job is handed the target repository's secrets and a token that can publish its packages; GitHub runs them without secrets and with a read-only token, and until that exists here they don't run at all. `pull_request_target` isn't supported
93 93 - **`paths:` / `paths-ignore:`** with GitHub's pattern rules (`*` stays within a directory, `**` crosses them, `?`, `[a-z]`; in `paths:`, `!pattern` un-matches and a later positive pattern re-matches). A push runs if any changed file is selected by `paths:`, or if not every changed file is matched by `paths-ignore:`; both set → `paths:` wins, with a warning. Changed files are the ref's old → new diff (renames count as both paths); a new branch is diffed from where it left the default branch. Tag pushes and manual runs ignore path filters, and when the changed files can't be worked out (no default branch to compare with, an unknown base, a git error, more than 3000 files) the filter passes — a run is never skipped because the forge couldn't look
94 94 - Re-run / cancel buttons on the workflow show page
95 − - Per-task HS256 JWT minted on `FetchTask`, gates artifact + cache endpoints
95 + - **The job token** (`${{ github.token }}`, an HS256 JWT minted per job on `FetchTask`) works only while its job runs and for five minutes after (for the runner's post-job cache save and artifact upload), and only for that attempt of the job — a leaked token no longer stays valid for a day. It clones and fetches its own repository, reads and publishes its packages, reads its metadata over the API, dispatches workflows (its own repository's, or one that granted it), and reaches the artifact + cache endpoints of its run. **`permissions:`** narrows it as on GitHub: workflow level, replaced by a job's own; `read-all`, `write-all`, `{}` or a map in which unnamed scopes are `none`. `contents` (`read` fetch, `write` also push — to its own repository, subject to branch protection like any direct push, attributed to whoever started the run), `packages` (`read` / `write`) and `actions` (`write` to dispatch) mean something; `metadata` is always read; the others (`issues`, `pull-requests`, `statuses`, `checks`, `deployments`, `id-token`, …) are accepted and ignored, since the token can't do those things here anyway. With no `permissions:` the token keeps `contents: read, packages: write, actions: write` — GitHub's permissive default would also let it push. **A push made with the job token starts no workflows** (no `push` run, no pull request `synchronize`), as on GitHub, so a workflow that pushes can't trigger itself forever; webhooks and pull request heads still update
96 96 - **GitHub Actions cache server v1**, scoped to the repository. Restore order: the primary key exactly, then each fallback key as a prefix (newest first), each tried on the run's own ref, then — for a `pull_request` run — the PR's base branch, then the default branch; a save goes to the run's own ref. Upload chunks are placed at their `Content-Range` offsets (`actions/cache` sends four at once) and assembled on finalize; a finalized entry is immutable. Age/size eviction worker, which also clears uploads abandoned for a day. **Not reached by a default forgejo-runner**: with `cache.enabled` (its default) the runner starts its own cache server and points `ACTIONS_CACHE_URL` at it, ignoring the URL gitgud sends; jobs only use this one if the runner has `cache.enabled: false` and `ACTIONS_CACHE_URL` set in `runner.envs`. `actions/cache@v4`'s cache service v2 isn't implemented
97 97 - A simplified **v1 "pipelines" artifact API**, scoped to the run: a job creates, lists and downloads its own run's artifacts only (so jobs of one run can share files). Not yet usable from the stock actions: forgejo-runner points `ACTIONS_RUNTIME_URL` at `/api/actions_pipeline/`, which isn't mounted, and `actions/upload-artifact@v3` / `download-artifact@v3` speak a different shape of that API (chunked `PUT`s with `itemPath`, container item listing). `@v4` (the Twirp `ArtifactService` under `ACTIONS_RESULTS_URL`) isn't supported
98 98 - Repo-scoped + org-scoped secrets/variables (AES-256-GCM at rest, key rotation)
@@ -109,7 +109,7 @@ reinvent them.
109 109 - **apt** ([DebianRepository/Format](https://wiki.debian.org/DebianRepository/Format)) — signed `InRelease`, `Packages.xz`, pool. **Namespace-wide repositories at `_all`**: one `deb` line and one keyring file for everything an org (or a user) publishes, rather than one of each per repository. **Browsable**, the way a real mirror is — click down through `dists/` and `pool/` in a browser, in the forge's own theme, with the listing derived from what the repository actually serves so it can neither advertise a 404 nor show a repository you couldn't read
110 110 - **raw** — static files with a JSON manifest, for clients that have no package manager: a microcontroller doing OTA, a shell script, an installer. `manifest.json` lists every version with an absolute URL and a SHA-256 per file and a `latest` pointer, so the whole client protocol is "fetch one document, then fetch a URL out of it". Publish with `curl --upload-file` to `/<version>/<filename>`; one package per repository, and `latest` means the last thing published rather than the highest version string, since raw bytes have no version grammar to sort by
111 111 - **OCI** hosted in [zot](https://zotregistry.dev/) — the forge owns `/v2/token` (`docker login` → Basic-auth handshake → RS256 JWT scoped to `repository:<owner>/<name>:pull,push`); zot owns the manifests and blobs. Deployment manifest in `zot.yml`; keypair from `mix gitgud.registry.keygen`; `(package, version)` mirrored via `/_webhooks/zot`
112 − - **Publish from CI with no stored secret** — `${{ github.token }}` is accepted by every registry and authorized to write its own repository only, exactly as at the OCI token endpoint
112 + - **Publish from CI with no stored secret** — `${{ github.token }}` is accepted by every registry and authorized to write its own repository only, exactly as at the OCI token endpoint, as far as the workflow's `permissions: packages:` allows (`write` unless it says otherwise)
113 113 - **Signing keys are per namespace**, not per repository — one `signed-by=` entry or one `--public-key` covers every repository an org publishes. Served at `/pkg/_keys/:format/:owner`; private halves encrypted at rest under the `GitGud.Secrets` master key
114 114 - Browseable at `/r/<owner>/<name>/packages`, grouped by format, with paste-ready install *and* publish instructions for both a terminal and a workflow
115 115
@@ -318,8 +318,11 @@ swap the adapter — call sites don't change.
318 318
319 319 Point a `forgejo-runner` at `/api/actions/_apis/runner.v1.*`. Register
320 320 via `RunnerService/Register`; the runner is then assignable to a repo /
321 −org pool. On each job poll we mint an HS256 JWT per task that gates the
322 −artifact + cache endpoints under `/api/actions/_apis/...`.
321 +org pool. On each job poll we mint an HS256 JWT per task — the job's
322 +`${{ github.token }}` — that gates the artifact + cache endpoints under
323 +`/api/actions/_apis/...` and authenticates the job to git, the package
324 +registries and the API, within the workflow's `permissions:` and only
325 +while the job runs.
323 326
324 327 The registration UI (`/r/<owner>/<name>/settings/runners` or
325 328 `/orgs/<handle>/settings/runners`) emits ready-to-paste snippets for
@@ -432,7 +435,8 @@ curl -H "Authorization: $TOKEN" --data-binary @pkg.deb \
432 435
433 436 From CI, there is no token to create. `${{ github.token }}` is minted
434 437 per job and authorized to write that job's own repository — the same
435 −deal the container registry already offers:
438 +deal the container registry already offers — unless the workflow's
439 +`permissions:` leave out `packages: write`:
436 440
437 441 ```yaml
438 442 - name: Publish to the forge
modified lib/git_gud/packages/access.ex
+19 −9
@@ -19,7 +19,10 @@ defmodule GitGud.Packages.Access do
19 19 own permission would otherwise allow.
20 20 * `{:token, repository_id, scopes}` — a repo-scoped registry token
21 21 (`ggrt_…`), `pull` and/or `push`.
22 − * `{:job, repository_id}` — a running CI job's `${{ github.token }}`.
22 + * `{:job, repository_id, perm}` — a running CI job's
23 + `${{ github.token }}`, with the `perm` claim its workflow's
24 + `permissions:` gave it (`GitGud.Workflows.Permissions`; nil for a
25 + token minted before tokens carried one).
23 26 * `:anonymous`
24 27
25 28 ## Actions
@@ -39,11 +42,12 @@ defmodule GitGud.Packages.Access do
39 42 alias GitGud.Repositories
40 43 alias GitGud.Repositories.Repository
41 44 alias GitGud.Workflows.JobTokenGrants
45 + alias GitGud.Workflows.Permissions
42 46
43 47 @type principal ::
44 48 {:user, User.t(), :all | [String.t()]}
45 49 | {:token, integer(), [String.t()]}
46 − | {:job, integer()}
50 + | {:job, integer(), map() | nil}
47 51 | :anonymous
48 52
49 53 @type action :: :read | :write | :delete
@@ -69,15 +73,21 @@ defmodule GitGud.Packages.Access do
69 73
70 74 # A job token carries the forge's own judgement that the job belongs to
71 75 # its repository, so it reads and writes that repository's packages with
72 − # no stored credential. Elsewhere it reads only what the other
73 − # repository has granted it (or what anyone may read).
74 − def allowed?({:job, repo_id}, %Repository{id: repo_id}, action),
75 − do: action in [:read, :write]
76 + # no stored credential — as far as its `packages:` permission goes.
77 + # Elsewhere it reads only what the other repository has granted it, and
78 + # only with `packages: read`; what anyone may read it reads regardless.
79 + def allowed?({:job, repo_id, perm}, %Repository{id: repo_id} = repo, action)
80 + when action in [:read, :write] do
81 + Permissions.permits?(perm, "packages", action) or
82 + (action == :read and allowed?(:anonymous, repo, :read))
83 + end
76 84
77 − def allowed?({:job, source_id}, %Repository{} = repo, :read),
78 − do: JobTokenGrants.allows?(repo, source_id, :read) or allowed?(:anonymous, repo, :read)
85 + def allowed?({:job, source_id, perm}, %Repository{} = repo, :read) do
86 + (Permissions.permits?(perm, "packages", :read) and
87 + JobTokenGrants.allows?(repo, source_id, :read)) or allowed?(:anonymous, repo, :read)
88 + end
79 89
80 − def allowed?({:job, _other}, %Repository{}, _action), do: false
90 + def allowed?({:job, _source_id, _perm}, %Repository{}, _action), do: false
81 91
82 92 def allowed?({:user, %User{} = user, scopes}, %Repository{} = repo, action) do
83 93 scope_permits?(scopes, action) and can?(user, repo, permission(action))
modified lib/git_gud/packages/auth.ex
+9 −7
@@ -24,9 +24,10 @@ defmodule GitGud.Packages.Auth do
24 24 minted for one job, and it authenticates here the same way it already
25 25 does at the OCI token endpoint: it may read and write its *own*
26 26 repository's packages, and elsewhere read only what another repository
27 − has granted it (`GitGud.Workflows.JobTokenGrants`). That is what lets a
28 − publish step be pasted into a workflow with no secret to create
29 − first — and it is the same deal the container registry already
27 + has granted it (`GitGud.Workflows.JobTokenGrants`) — as far as the
28 + workflow's `permissions: packages:` allows, and only while the job runs.
29 + That is what lets a publish step be pasted into a workflow with no
30 + secret to create first — and it is the same deal the container registry already
30 31 offers, so the two don't need different instructions.
31 32
32 33 ## Who may do what
@@ -48,7 +49,8 @@ defmodule GitGud.Packages.Auth do
48 49 * `{:user, %User{}, scopes}` — a forge account: `:all` via password,
49 50 the token's scopes via `ggp_…`
50 51 * `{:token, repository_id, scopes}` — a repo-scoped `ggrt_…`
51 − * `{:job, repository_id}` — a running CI job's `${{ github.token }}`
52 + * `{:job, repository_id, perm}` — a running CI job's
53 + `${{ github.token }}`, with its `permissions:` claim
52 54 * `:anonymous`
53 55 """
54 56 @type principal :: Access.principal()
@@ -112,9 +114,9 @@ defmodule GitGud.Packages.Auth do
112 114 end
113 115
114 116 defp verify_job_or_user(username, secret) do
115 − case RunnerJwt.verify(secret) do
116 − {:ok, %{"pid" => repo_id}} when is_integer(repo_id) ->
117 − {:job, repo_id}
117 + case RunnerJwt.verify_active(secret) do
118 + {:ok, %{"pid" => repo_id} = claims} when is_integer(repo_id) ->
119 + {:job, repo_id, claims["perm"]}
118 120
119 121 _ ->
120 122 case Accounts.verify_personal_access_token(secret) do
modified lib/git_gud/pull_requests.ex
+12 −2
@@ -465,8 +465,17 @@ defmodule GitGud.PullRequests do
465 465 Called when a fork has been pushed. Updates `refs/pull/<N>/head` on
466 466 the target repo for every open cross-repo PR that has `source_repo` +
467 467 `branch` as its source, and re-snapshots `head_sha` on the PR row.
468 +
469 + `dispatch: false` skips the `synchronize` workflow dispatch (a push made
470 + with a CI job's token starts no workflows).
468 471 """
469 − def refresh_cross_repo_heads(%Repository{} = source_repo, branch, new_sha, pusher_id \\ nil) do
472 + def refresh_cross_repo_heads(
473 + %Repository{} = source_repo,
474 + branch,
475 + new_sha,
476 + pusher_id \\ nil,
477 + opts \\ []
478 + ) do
470 479 prs =
471 480 Repo.all(
472 481 from p in PullRequest,
@@ -512,7 +521,8 @@ defmodule GitGud.PullRequests do
512 521
513 522 # `pull_request` workflows' `synchronize`. Fork PRs pass through
514 523 # here too and are turned away by the dispatcher.
515 − :ok = PullRequestDispatch.enqueue(updated, "synchronize", pusher_id)
524 + if Keyword.get(opts, :dispatch, true),
525 + do: :ok = PullRequestDispatch.enqueue(updated, "synchronize", pusher_id)
516 526 end
517 527 end
518 528
modified lib/git_gud/repositories/hook_receiver.ex
+6 −2
@@ -22,8 +22,11 @@ defmodule GitGud.Repositories.HookReceiver do
22 22 `pusher_id` (optional) is the id of the user who pushed, threaded
23 23 from the SSH/HTTP transport via the `gitgud-hook` bridge so workflow
24 24 runs can be attributed to them. nil when unattributable.
25 +
26 + `job_token: true` marks a push made with a CI job's token, which
27 + starts no workflows (see `CommitBackfill`).
25 28 """
26 − def receive(repository_id, stdin, pusher_id \\ nil) when is_binary(stdin) do
29 + def receive(repository_id, stdin, pusher_id \\ nil, opts \\ []) when is_binary(stdin) do
27 30 triples =
28 31 stdin
29 32 |> String.split("\n", trim: true)
@@ -40,7 +43,8 @@ defmodule GitGud.Repositories.HookReceiver do
40 43 ref: ref,
41 44 old_sha_hex: old_hex,
42 45 new_sha_hex: new_hex,
43 − pusher_id: pusher_id
46 + pusher_id: pusher_id,
47 + job_token: Keyword.get(opts, :job_token, false)
44 48 }
45 49 |> CommitBackfill.new_job()
46 50 |> Oban.insert()
modified lib/git_gud/repositories/workers/commit_backfill.ex
+15 −5

Click to load diff…

added lib/git_gud/workflows/permissions.ex
+113 −0

Click to load diff…

modified lib/git_gud/workflows/runner_jwt.ex
+86 −4

Click to load diff…

modified lib/git_gud_web/controllers/api/v1/repository_controller.ex
+1 −1

Click to load diff…

modified lib/git_gud_web/controllers/api/v1/workflow_controller.ex
+9 −7

Click to load diff…

modified lib/git_gud_web/controllers/hook_controller.ex
+5 −1

Click to load diff…

modified lib/git_gud_web/controllers/registry_controller.ex
+1 −1

Click to load diff…

modified lib/git_gud_web/controllers/runner_controller.ex
+2 −1

Click to load diff…

modified lib/git_gud_web/plugs/git_smart_http.ex
+42 −10

Click to load diff…

modified lib/git_gud_web/plugs/runner_jwt_auth.ex
+3 −2

Click to load diff…

modified priv/scripts/gitgud-hook
+9 −0

Click to load diff…

added test/git_gud/workflows/permissions_test.exs
+56 −0

Click to load diff…

modified test/git_gud/workflows/pull_request_dispatch_test.exs
+21 −2

Click to load diff…

modified test/git_gud/workflows/runner_jwt_test.exs
+84 −0

Click to load diff…

modified test/git_gud_web/controllers/actions_endpoints_test.exs
+2 −0

Click to load diff…

modified test/git_gud_web/controllers/api/v1/workflow_controller_test.exs
+21 −2

Click to load diff…

modified test/git_gud_web/controllers/registry_controller_test.exs
+23 −3

Click to load diff…

added test/git_gud_web/git_job_token_test.exs
+226 −0

Click to load diff…

modified test/git_gud_web/package_usage_test.exs
+74 −6

Click to load diff…

Parents: ff3668c