neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Actions: the job token ends with its job and honours permissions:
a9f5a09 · Gabriel Morell · 2026-10-17 14:45
Message
{commit_body(@commit)}
Files changed
modified
README.md
+9
−5
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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