neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Actions: give a job the github.event, ids and secrets.GITHUB_TOKEN it's promised
e9900fa · Gabriel Morell · 2026-10-17 15:09
Message
{commit_body(@commit)}
Files changed
modified
README.md
+1
−0
| 91 | 91 | - **Triggers**: `push` (with `branches:` / `branches-ignore:` / `tags:` / `tags-ignore:`, GitHub's rule that naming one kind excludes the other kind of ref, and a list beating its `-ignore` if both are set), `pull_request` and `workflow_dispatch` |
| 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 | + - **The job's context** follows Forgejo's: `github.event_name`, `ref`, `ref_name`, `ref_type`, `sha`, `repository` (`owner/name`), `repository_id`, `repository_owner(_id)`, `actor(_id)`, `run_id`, `run_number`, `run_attempt` (1, bumped by re-running the job; re-running the whole run makes a new run and a new `run_id`), `workflow`, `workflow_ref` (`owner/name/<path>@<ref>`), `job`, `server_url`, `api_url`, `token`. **`github.event`** is GitHub's webhook payload, trimmed: every event has `repository` and `sender`; `push` has `ref`, `before` (40 zeros for a new ref), `after`, `created`, `deleted`, `forced` (always `false`: not tracked), `compare`, `commits` (at most 20) and `head_commit`, `pusher`; `pull_request` has `action`, `number` and `pull_request` (`title`, `body`, `html_url`, `user`, `head`/`base` with `ref`, `sha`, `label`, `repo`); `workflow_dispatch` has `inputs`, `ref`, `workflow`. **`secrets.GITHUB_TOKEN`** (and `GITEA_TOKEN`, `FORGEJO_TOKEN`) is the job token. The workflow's top-level `env:`, `defaults:`, `permissions:`, `run-name:` and `on:` reach the runner with the job | |
| 94 | 95 | - Re-run / cancel buttons on the workflow show page |
| 95 | 96 | - **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 | 97 | - **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 |
modified
lib/git_gud/workflows.ex
+136
−40
| 13 | 13 | |
| 14 | 14 | import Ecto.Query, warn: false |
| 15 | 15 | |
| 16 | + alias GitGud.Accounts.User | |
| 16 | 17 | alias GitGud.Git |
| 17 | 18 | alias GitGud.PullRequests.PullRequest |
| 18 | 19 | alias GitGud.Repo |
| 19 | 20 | alias GitGud.Repositories |
| 20 | 21 | alias GitGud.Repositories.Repository |
| 22 | + alias GitGud.Workflows.EventPayload | |
| 21 | 23 | alias GitGud.Workflows.Matrix |
| 22 | 24 | alias GitGud.Workflows.Ref |
| 23 | 25 | alias GitGud.Workflows.Runner |
| 38 | 40 | `event` is `%{trigger: "push" | "pull_request", head_sha: <20 bytes>, |
| 39 | 41 | ref: "refs/heads/main"}`, optionally with `base_sha:` — the ref's |
| 40 | 42 | previous value, `nil` or all zeros for a new ref — which is what |
| 41 | − `paths:` / `paths-ignore:` filters are decided against. | |
| 43 | + `paths:` / `paths-ignore:` filters are decided against — and | |
| 44 | + `triggered_by_id:`, the pusher. | |
| 42 | 45 | |
| 43 | 46 | Returns `{:ok, [run]}`. |
| 44 | 47 | """ |
| 64 | 67 | changed_paths(repo, base, head_sha) |
| 65 | 68 | end) |
| 66 | 69 | |
| 70 | + triggered_by_id = Map.get(event, :triggered_by_id) | |
| 71 | + | |
| 67 | 72 | runs = |
| 68 | − create_matching_runs(repo, workflows, trigger, ctx, %{ | |
| 69 | − head_sha: head_sha, | |
| 70 | − head_ref: ref, | |
| 71 | − triggered_by_id: Map.get(event, :triggered_by_id) | |
| 72 | − }) | |
| 73 | + create_matching_runs( | |
| 74 | + repo, | |
| 75 | + workflows, | |
| 76 | + trigger, | |
| 77 | + ctx, | |
| 78 | + %{head_sha: head_sha, head_ref: ref, triggered_by_id: triggered_by_id}, | |
| 79 | + fn -> push_payload(repo, event, head_sha, triggered_by_id) end | |
| 80 | + ) | |
| 73 | 81 | |
| 74 | 82 | {:ok, runs} |
| 75 | 83 | end |
| 76 | 84 | |
| 85 | + # `after` is the ref's new value as pushed (an annotated tag's object); | |
| 86 | + # the commits start from `head_sha`, the commit it peels to. | |
| 87 | + defp push_payload(repo, event, head_sha, triggered_by_id) do | |
| 88 | + pusher = triggered_by_id && Repo.get(User, triggered_by_id) | |
| 89 | + base_sha = Map.get(event, :base_sha) | |
| 90 | + | |
| 91 | + EventPayload.push(Repo.preload(repo, [:owner, :organization]), %{ | |
| 92 | + ref: event.ref, | |
| 93 | + before: base_sha, | |
| 94 | + after: event.head_sha, | |
| 95 | + commits: push_commits(repo, base_sha, head_sha), | |
| 96 | + pusher: pusher, | |
| 97 | + sender: pusher | |
| 98 | + }) | |
| 99 | + end | |
| 100 | + | |
| 101 | + # The pushed commits, newest first, at most 20: history from the head | |
| 102 | + # back to the ref's old value — or, for a new ref, to where it left the | |
| 103 | + # default branch. Walking first-parent-ish `git log` order and stopping | |
| 104 | + # at the base commit is good enough: a merge's other side is listed until | |
| 105 | + # the cut-off, rather than computed as `base..head` exactly. | |
| 106 | + defp push_commits(repo, base_sha, head_sha) do | |
| 107 | + stop = | |
| 108 | + if new_ref?(base_sha), | |
| 109 | + do: merge_base_with(repo, head_sha, default_branch_ref(repo)), | |
| 110 | + else: base_sha | |
| 111 | + | |
| 112 | + case Repositories.list_commits(repo, head_sha, limit: 20) do | |
| 113 | + {:ok, commits} -> Enum.take_while(commits, &(&1.sha != stop)) | |
| 114 | + _ -> [] | |
| 115 | + end | |
| 116 | + end | |
| 117 | + | |
| 77 | 118 | @doc """ |
| 78 | 119 | Start the `on: pull_request` workflows for a pull request. |
| 79 | 120 |
| 121 | 162 | end |
| 122 | 163 | |
| 123 | 164 | workflows = discover_workflows(repo, pr.head_sha) |
| 165 | + target_sha = ref_head(repo, target_ref) | |
| 166 | + triggered_by_id = Keyword.get(opts, :triggered_by_id) | |
| 124 | 167 | |
| 125 | 168 | ctx = |
| 126 | 169 | %{branch: Ref.name(target_ref), action: action} |
| 127 | 170 | |> put_changed_paths(workflows, "pull_request", fn -> |
| 128 | − changed_paths(repo, merge_base_with(repo, pr.head_sha, target_ref), pr.head_sha) | |
| 171 | + changed_paths(repo, merge_base_of(repo, pr.head_sha, target_sha), pr.head_sha) | |
| 129 | 172 | end) |
| 130 | 173 | |
| 131 | 174 | runs = |
| 132 | − create_matching_runs(repo, workflows, "pull_request", ctx, %{ | |
| 133 | − head_sha: pr.head_sha, | |
| 134 | − head_ref: head_ref, | |
| 135 | − triggered_by_id: Keyword.get(opts, :triggered_by_id), | |
| 136 | − pull_request_number: pr.number, | |
| 137 | − pr_head_ref: Ref.name(branch_ref(pr.source_ref)), | |
| 138 | − pr_base_ref: Ref.name(target_ref) | |
| 139 | − }) | |
| 175 | + create_matching_runs( | |
| 176 | + repo, | |
| 177 | + workflows, | |
| 178 | + "pull_request", | |
| 179 | + ctx, | |
| 180 | + %{ | |
| 181 | + head_sha: pr.head_sha, | |
| 182 | + head_ref: head_ref, | |
| 183 | + triggered_by_id: triggered_by_id, | |
| 184 | + pull_request_number: pr.number, | |
| 185 | + pr_head_ref: Ref.name(branch_ref(pr.source_ref)), | |
| 186 | + pr_base_ref: Ref.name(target_ref) | |
| 187 | + }, | |
| 188 | + fn -> | |
| 189 | + EventPayload.pull_request( | |
| 190 | + Repo.preload(repo, [:owner, :organization]), | |
| 191 | + Repo.preload(pr, :author), | |
| 192 | + action, | |
| 193 | + %{target_sha: target_sha, sender: triggered_by_id && Repo.get(User, triggered_by_id)} | |
| 194 | + ) | |
| 195 | + end | |
| 196 | + ) | |
| 140 | 197 | |
| 141 | 198 | {:ok, runs} |
| 142 | 199 | end |
| 144 | 201 | defp branch_ref("refs/" <> _ = ref), do: ref |
| 145 | 202 | defp branch_ref(name), do: "refs/heads/" <> name |
| 146 | 203 | |
| 147 | − defp create_matching_runs(repo, workflows, trigger, ctx, run_attrs) do | |
| 148 | − for {path, doc} <- workflows, Yaml.matches?(doc, trigger, ctx) do | |
| 149 | − {:ok, run} = | |
| 150 | − create_run( | |
| 151 | − repo, | |
| 152 | − Map.merge(run_attrs, %{ | |
| 153 | − workflow_path: path, | |
| 154 | − workflow_name: doc.name, | |
| 155 | − trigger: trigger, | |
| 156 | − workflow_doc: doc.raw, | |
| 157 | − jobs: doc.jobs | |
| 158 | − }) | |
| 159 | − ) | |
| 204 | + # `payload_fun` builds the event payload, once, and only when some | |
| 205 | + # workflow matches: a push to a repository without CI shouldn't walk | |
| 206 | + # its history. | |
| 207 | + defp create_matching_runs(repo, workflows, trigger, ctx, run_attrs, payload_fun) do | |
| 208 | + case Enum.filter(workflows, fn {_path, doc} -> Yaml.matches?(doc, trigger, ctx) end) do | |
| 209 | + [] -> | |
| 210 | + [] | |
| 160 | 211 | |
| 161 | − run | |
| 212 | + matching -> | |
| 213 | + event_payload = payload_fun.() | |
| 214 | + | |
| 215 | + for {path, doc} <- matching do | |
| 216 | + {:ok, run} = | |
| 217 | + create_run( | |
| 218 | + repo, | |
| 219 | + Map.merge(run_attrs, %{ | |
| 220 | + workflow_path: path, | |
| 221 | + workflow_name: doc.name, | |
| 222 | + trigger: trigger, | |
| 223 | + workflow_doc: doc.raw, | |
| 224 | + event_payload: event_payload, | |
| 225 | + jobs: doc.jobs | |
| 226 | + }) | |
| 227 | + ) | |
| 228 | + | |
| 229 | + run | |
| 230 | + end | |
| 162 | 231 | end |
| 163 | 232 | end |
| 164 | 233 |
| 169 | 238 | (`.forgejo/workflows/build.yml`); `ref` is a branch or tag, short |
| 170 | 239 | (`main`, `v1.2`) or full (`refs/heads/main`). The workflow has to opt in |
| 171 | 240 | with `on: workflow_dispatch`, as on GitHub, so a file can't be run by |
| 172 | − someone who merely knows its name. | |
| 241 | + someone who merely knows its name. `inputs` (string → string) reach the | |
| 242 | + job as `github.event.inputs` / `inputs`. | |
| 173 | 243 | |
| 174 | 244 | Returns `{:ok, run}`, or `{:error, :unknown_ref | :unknown_workflow | |
| 175 | 245 | :not_dispatchable}`. |
| 176 | 246 | """ |
| 177 | − def dispatch_manual(%Repository{} = repo, workflow, ref, triggered_by_id \\ nil) | |
| 178 | − when is_binary(workflow) and is_binary(ref) do | |
| 247 | + def dispatch_manual(%Repository{} = repo, workflow, ref, triggered_by_id \\ nil, inputs \\ %{}) | |
| 248 | + when is_binary(workflow) and is_binary(ref) and is_map(inputs) do | |
| 179 | 249 | with {:ok, full_ref, sha} <- resolve_ref(repo, ref), |
| 180 | 250 | {:ok, path, doc} <- find_workflow(repo, sha, workflow), |
| 181 | 251 | true <- |
| 188 | 258 | head_sha: sha, |
| 189 | 259 | head_ref: full_ref, |
| 190 | 260 | workflow_doc: doc.raw, |
| 261 | + event_payload: | |
| 262 | + EventPayload.workflow_dispatch( | |
| 263 | + Repo.preload(repo, [:owner, :organization]), | |
| 264 | + full_ref, | |
| 265 | + path, | |
| 266 | + inputs, | |
| 267 | + triggered_by_id && Repo.get(User, triggered_by_id) | |
| 268 | + ), | |
| 191 | 269 | triggered_by_id: triggered_by_id, |
| 192 | 270 | jobs: doc.jobs |
| 193 | 271 | }) |
| 234 | 312 | # Where `head_sha` left `ref`: their merge-base, or nil when there's no |
| 235 | 313 | # such ref, no common ancestor, or nothing between them (`head_sha` is |
| 236 | 314 | # already on `ref` — the default branch itself, pushed new). |
| 237 | − defp merge_base_with(_repo, _head_sha, nil), do: nil | |
| 315 | + defp merge_base_with(repo, head_sha, ref), | |
| 316 | + do: merge_base_of(repo, head_sha, ref_head(repo, ref)) | |
| 238 | 317 | |
| 239 | − defp merge_base_with(%Repository{disk_path: path}, head_sha, ref) do | |
| 318 | + defp merge_base_of(_repo, _head_sha, nil), do: nil | |
| 319 | + | |
| 320 | + defp merge_base_of(%Repository{disk_path: path}, head_sha, ref_head) do | |
| 321 | + case Git.merge_base(path, head_sha, ref_head) do | |
| 322 | + {:ok, base} when base != head_sha -> base | |
| 323 | + _ -> nil | |
| 324 | + end | |
| 325 | + end | |
| 326 | + | |
| 327 | + # What `ref` points at, or nil when there's no such ref. | |
| 328 | + defp ref_head(_repo, nil), do: nil | |
| 329 | + | |
| 330 | + defp ref_head(%Repository{disk_path: path}, ref) do | |
| 240 | 331 | with {:ok, refs} <- Git.list_refs(path), |
| 241 | − %{target_sha: ref_head} <- Enum.find(refs, &(&1.name == ref)), | |
| 242 | − {:ok, base} when base != head_sha <- Git.merge_base(path, head_sha, ref_head) do | |
| 243 | − base | |
| 332 | + %{target_sha: sha} <- Enum.find(refs, &(&1.name == ref)) do | |
| 333 | + sha | |
| 244 | 334 | else |
| 245 | 335 | _ -> nil |
| 246 | 336 | end |
| 1568 | 1658 | |
| 1569 | 1659 | @doc """ |
| 1570 | 1660 | Re-run a previous workflow run. Creates a fresh `WorkflowRun` with |
| 1571 | − the same workflow_doc + head_sha + head_ref; jobs + steps are | |
| 1572 | − re-seeded from the stored `workflow_doc` so they start in the | |
| 1661 | + the same workflow_doc + head_sha + head_ref + event payload; jobs + | |
| 1662 | + steps are re-seeded from the stored `workflow_doc` so they start in the | |
| 1573 | 1663 | `queued` state for runners to pick up. |
| 1664 | + | |
| 1665 | + Unlike GitHub, which keeps the run id and bumps `github.run_attempt`, | |
| 1666 | + this is a new run: a new `run_id`, and its jobs start at attempt 1. | |
| 1667 | + Only `rerun_job/2` (in place) bumps the attempt. | |
| 1574 | 1668 | """ |
| 1575 | 1669 | def rerun(%WorkflowRun{} = old) do |
| 1576 | 1670 | repo = Repositories.get_repository!(old.repository_id) |
| 1583 | 1677 | head_sha: old.head_sha, |
| 1584 | 1678 | head_ref: old.head_ref, |
| 1585 | 1679 | workflow_doc: old.workflow_doc, |
| 1680 | + event_payload: old.event_payload, | |
| 1586 | 1681 | pull_request_number: old.pull_request_number, |
| 1587 | 1682 | pr_head_ref: old.pr_head_ref, |
| 1588 | 1683 | pr_base_ref: old.pr_base_ref, |
| 1655 | 1750 | conclusion: nil, |
| 1656 | 1751 | started_at: nil, |
| 1657 | 1752 | completed_at: nil, |
| 1658 | − runner_id: nil | |
| 1753 | + runner_id: nil, | |
| 1754 | + attempt: job.attempt + 1 | |
| 1659 | 1755 | ) |
| 1660 | 1756 | |> Repo.update() |
| 1661 | 1757 |
added
lib/git_gud/workflows/event_payload.ex
+170
−0
| 1 | +defmodule GitGud.Workflows.EventPayload do | |
| 2 | + @moduledoc """ | |
| 3 | + The event payload a job reads as `github.event`: GitHub's webhook shapes, | |
| 4 | + trimmed to the fields workflows actually read | |
| 5 | + (`github.event.head_commit.message`, `github.event.pull_request.number`, | |
| 6 | + `github.event.inputs.*`, …). | |
| 7 | + | |
| 8 | + Built once, when a run is created, and stored on it | |
| 9 | + (`WorkflowRun.event_payload`); `RunnerController.build_task/1` sends it | |
| 10 | + as the `event` object of the task's context. Keys are strings, shas are | |
| 11 | + lowercase hex. Pure: the repository must have `:owner` and | |
| 12 | + `:organization` preloaded, and users are passed in already loaded. | |
| 13 | + | |
| 14 | + `sender` is the triggering user's handle, or the repository owner's when | |
| 15 | + the trigger isn't attributable — the same rule as `github.actor`. | |
| 16 | + """ | |
| 17 | + | |
| 18 | + alias GitGud.Accounts.User | |
| 19 | + alias GitGud.Git | |
| 20 | + alias GitGud.Repositories.Repository | |
| 21 | + alias GitGud.Repositories.Storage | |
| 22 | + | |
| 23 | + @zero_sha String.duplicate("0", 40) | |
| 24 | + | |
| 25 | + @doc """ | |
| 26 | + A `push`. `before`/`after` are binary shas (`before` nil or all zeros | |
| 27 | + for a new ref); `commits` are `Git.commit()` maps, newest first, as | |
| 28 | + `Git.log/3` returns them; `pusher`/`sender` are `%User{}` or nil. | |
| 29 | + """ | |
| 30 | + def push(%Repository{} = repo, %{ref: ref, before: before, after: after_sha} = event) do | |
| 31 | + commits = Enum.map(Map.get(event, :commits, []), &commit(repo, &1)) | |
| 32 | + after_hex = Git.to_hex(after_sha) | |
| 33 | + before_hex = sha_hex(before) | |
| 34 | + created = before_hex == @zero_sha | |
| 35 | + | |
| 36 | + %{ | |
| 37 | + "ref" => ref, | |
| 38 | + "before" => before_hex, | |
| 39 | + "after" => after_hex, | |
| 40 | + "created" => created, | |
| 41 | + "deleted" => false, | |
| 42 | + # Not tracked: the post-receive hook doesn't say whether the update | |
| 43 | + # was a fast-forward, and working it out costs a merge-base per push. | |
| 44 | + "forced" => false, | |
| 45 | + "base_ref" => nil, | |
| 46 | + "compare" => | |
| 47 | + if(created, | |
| 48 | + do: commit_url(repo, after_hex), | |
| 49 | + else: repo_url(repo) <> "/compare?base=#{before_hex}&head=#{after_hex}" | |
| 50 | + ), | |
| 51 | + # GitHub lists oldest first. | |
| 52 | + "commits" => Enum.reverse(commits), | |
| 53 | + "head_commit" => List.first(commits), | |
| 54 | + "pusher" => pusher(Map.get(event, :pusher)), | |
| 55 | + "repository" => repository(repo), | |
| 56 | + "sender" => sender(repo, Map.get(event, :sender)) | |
| 57 | + } | |
| 58 | + end | |
| 59 | + | |
| 60 | + @doc """ | |
| 61 | + A `pull_request`. `pr` is the `%PullRequest{}` (its `author` preloaded | |
| 62 | + or nil), `action` the activity type; `target_sha` is the target branch's | |
| 63 | + head (binary sha or nil), `sender` a `%User{}` or nil. | |
| 64 | + | |
| 65 | + `head.repo` is the target repository: fork and federated pull requests | |
| 66 | + aren't dispatched (`Workflows.dispatch_pull_request/3`). | |
| 67 | + """ | |
| 68 | + def pull_request(%Repository{} = repo, pr, action, %{} = opts) do | |
| 69 | + repository = repository(repo) | |
| 70 | + owner = repository["owner"]["login"] | |
| 71 | + source = short_branch(pr.source_ref) | |
| 72 | + target = short_branch(pr.target_ref) | |
| 73 | + | |
| 74 | + %{ | |
| 75 | + "action" => action, | |
| 76 | + "number" => pr.number, | |
| 77 | + "pull_request" => %{ | |
| 78 | + "number" => pr.number, | |
| 79 | + "title" => pr.title || "", | |
| 80 | + "body" => pr.body || "", | |
| 81 | + "state" => "open", | |
| 82 | + "html_url" => repo_url(repo) <> "/pulls/#{pr.number}", | |
| 83 | + "draft" => false, | |
| 84 | + "merged" => false, | |
| 85 | + "user" => %{"login" => author_login(repo, Map.get(pr, :author))}, | |
| 86 | + "head" => %{ | |
| 87 | + "ref" => source, | |
| 88 | + "sha" => sha_hex(pr.head_sha), | |
| 89 | + "label" => owner <> ":" <> source, | |
| 90 | + "repo" => repository | |
| 91 | + }, | |
| 92 | + "base" => %{ | |
| 93 | + "ref" => target, | |
| 94 | + "sha" => sha_hex(Map.get(opts, :target_sha)), | |
| 95 | + "label" => owner <> ":" <> target, | |
| 96 | + "repo" => repository | |
| 97 | + } | |
| 98 | + }, | |
| 99 | + "repository" => repository, | |
| 100 | + "sender" => sender(repo, Map.get(opts, :sender)) | |
| 101 | + } | |
| 102 | + end | |
| 103 | + | |
| 104 | + @doc """ | |
| 105 | + A `workflow_dispatch`. `inputs` is a string → string map; `ref` the full | |
| 106 | + ref; `workflow_path` the file's path in the repository. | |
| 107 | + """ | |
| 108 | + def workflow_dispatch(%Repository{} = repo, ref, workflow_path, inputs, sender) do | |
| 109 | + %{ | |
| 110 | + "inputs" => Map.new(inputs || %{}, fn {k, v} -> {to_string(k), to_string(v)} end), | |
| 111 | + "ref" => ref, | |
| 112 | + "workflow" => workflow_path, | |
| 113 | + "repository" => repository(repo), | |
| 114 | + "sender" => sender(repo, sender) | |
| 115 | + } | |
| 116 | + end | |
| 117 | + | |
| 118 | + @doc "The `repository` object every payload carries." | |
| 119 | + def repository(%Repository{} = repo) do | |
| 120 | + owner = Storage.repo_handle(repo) | |
| 121 | + | |
| 122 | + %{ | |
| 123 | + "id" => repo.id, | |
| 124 | + "name" => repo.name, | |
| 125 | + "full_name" => owner <> "/" <> repo.name, | |
| 126 | + "owner" => %{"login" => owner}, | |
| 127 | + "default_branch" => repo.default_branch, | |
| 128 | + "private" => repo.visibility != "public", | |
| 129 | + "html_url" => repo_url(repo), | |
| 130 | + "clone_url" => GitGudWeb.Endpoint.url() <> "/" <> owner <> "/" <> repo.name <> ".git" | |
| 131 | + } | |
| 132 | + end | |
| 133 | + | |
| 134 | + defp commit(repo, c) do | |
| 135 | + hex = Git.to_hex(c.sha) | |
| 136 | + | |
| 137 | + %{ | |
| 138 | + "id" => hex, | |
| 139 | + "message" => c.message, | |
| 140 | + "timestamp" => c.committed_at && DateTime.to_iso8601(c.committed_at), | |
| 141 | + "url" => commit_url(repo, hex), | |
| 142 | + "author" => %{"name" => c.author_name, "email" => c.author_email}, | |
| 143 | + "committer" => %{"name" => c.committer_name, "email" => c.committer_email} | |
| 144 | + } | |
| 145 | + end | |
| 146 | + | |
| 147 | + # The account's public email only: a job's payload ends up in logs. | |
| 148 | + defp pusher(%User{} = u), do: %{"name" => u.handle, "email" => u.public_email || ""} | |
| 149 | + defp pusher(_), do: nil | |
| 150 | + | |
| 151 | + defp sender(_repo, %User{handle: h, id: id}) when is_binary(h) and h != "", | |
| 152 | + do: %{"login" => h, "id" => id} | |
| 153 | + | |
| 154 | + defp sender(repo, _), do: %{"login" => Storage.repo_handle(repo)} | |
| 155 | + | |
| 156 | + defp author_login(_repo, %User{handle: h}) when is_binary(h) and h != "", do: h | |
| 157 | + defp author_login(repo, _), do: Storage.repo_handle(repo) | |
| 158 | + | |
| 159 | + defp repo_url(repo), | |
| 160 | + do: GitGudWeb.Endpoint.url() <> "/r/" <> Storage.repo_handle(repo) <> "/" <> repo.name | |
| 161 | + | |
| 162 | + defp commit_url(repo, hex), do: repo_url(repo) <> "/commit/" <> hex | |
| 163 | + | |
| 164 | + defp short_branch("refs/heads/" <> b), do: b | |
| 165 | + defp short_branch(b) when is_binary(b), do: b | |
| 166 | + defp short_branch(_), do: "" | |
| 167 | + | |
| 168 | + defp sha_hex(<<_::binary-size(20)>> = sha), do: Git.to_hex(sha) | |
| 169 | + defp sha_hex(_), do: @zero_sha | |
| 170 | +end |
modified
lib/git_gud/workflows/runner_payload.ex
+31
−8
| 12 | 12 | rejected the task with "yaml: control characters are not allowed". |
| 13 | 13 | |
| 14 | 14 | We snapshot the parsed YAML at run-creation time as |
| 15 | − `WorkflowJob.definition` (the raw map). Here we shrink it back to a | |
| 16 | − single-job workflow and re-emit YAML via `:ymlr`. | |
| 15 | + `WorkflowJob.definition` (the raw map), and the whole document as | |
| 16 | + `WorkflowRun.workflow_doc`. Here we shrink the document back to a | |
| 17 | + single-job workflow — keeping its top-level `env`, `defaults`, `on`, … — | |
| 18 | + and re-emit YAML via `:ymlr`. | |
| 17 | 19 | """ |
| 18 | 20 | |
| 19 | 21 | alias GitGud.Workflows.WorkflowJob |
| 20 | 22 | alias GitGud.Workflows.WorkflowRun |
| 21 | 23 | |
| 24 | + # Workflow-level keys the job needs: `env` and `defaults` apply to its | |
| 25 | + # steps, `permissions` and `run-name` are read by act, and the real `on` | |
| 26 | + # is where act finds `workflow_dispatch` input defaults. Everything else | |
| 27 | + # is left out — notably `concurrency`, which the server decides, not the | |
| 28 | + # runner. | |
| 29 | + @kept_keys ~w(env defaults permissions run-name on) | |
| 30 | + | |
| 22 | 31 | @doc "Return plain YAML bytes ready to hand to forgejo-runner." |
| 23 | 32 | def encode(%WorkflowJob{} = job) do |
| 24 | 33 | run = GitGud.Repo.get!(WorkflowRun, job.workflow_run_id) |
| 33 | 42 | # results/outputs reach the job via the Task `needs` field. |
| 34 | 43 | definition = Map.drop(job.definition, ["needs", :needs]) |
| 35 | 44 | |
| 36 | − workflow_doc = %{ | |
| 45 | + run | |
| 46 | + |> document(job.job_id, definition) | |
| 47 | + |> Ymlr.document!() | |
| 48 | + end | |
| 49 | + | |
| 50 | + @doc false | |
| 51 | + # The run's own document with `jobs` cut down to the one job. A run from | |
| 52 | + # before the document was stored gets a minimal one, with `on: push`. | |
| 53 | + def document(%WorkflowRun{workflow_doc: %{} = doc} = run, job_id, definition) | |
| 54 | + when map_size(doc) > 0 do | |
| 55 | + doc | |
| 56 | + |> Map.new(fn {k, v} -> {to_string(k), v} end) | |
| 57 | + |> Map.take(@kept_keys) | |
| 58 | + |> Map.put("name", run.workflow_name || doc["name"] || run.workflow_path) | |
| 59 | + |> Map.put("jobs", %{job_id => definition}) | |
| 60 | + end | |
| 61 | + | |
| 62 | + def document(%WorkflowRun{} = run, job_id, definition) do | |
| 63 | + %{ | |
| 37 | 64 | "name" => run.workflow_name || run.workflow_path, |
| 38 | 65 | "on" => "push", |
| 39 | − "jobs" => %{ | |
| 40 | − job.job_id => definition | |
| 41 | − } | |
| 66 | + "jobs" => %{job_id => definition} | |
| 42 | 67 | } |
| 43 | − | |
| 44 | − Ymlr.document!(workflow_doc) | |
| 45 | 68 | end |
| 46 | 69 | end |
modified
lib/git_gud/workflows/workflow_job.ex
+2
−0
| 41 | 41 | # one matrix share a `job_id` — `needs:` names the group, and this |
| 42 | 42 | # is what tells them apart. |
| 43 | 43 | field :matrix, :map |
| 44 | + # `github.run_attempt`. Starts at 1; `Workflows.rerun_job/2` bumps it. | |
| 45 | + field :attempt, :integer, default: 1 | |
| 44 | 46 | |
| 45 | 47 | belongs_to :workflow_run, WorkflowRun |
| 46 | 48 | belongs_to :runner, Runner |
modified
lib/git_gud/workflows/workflow_run.ex
+5
−0
Click to load diff…
modified
lib/git_gud_web/controllers/runner_controller.ex
+59
−19
Click to load diff…
added
priv/repo/migrations/20261017150026_add_event_payload_to_workflow_runs.exs
+18
−0
Click to load diff…
modified
test/git_gud/workflows/path_dispatch_test.exs
+32
−0
Click to load diff…
modified
test/git_gud/workflows/pull_request_dispatch_test.exs
+17
−0
Click to load diff…
modified
test/git_gud/workflows/rerun_cancel_test.exs
+31
−1
Click to load diff…
added
test/git_gud/workflows/runner_payload_test.exs
+97
−0
Click to load diff…
modified
test/git_gud_web/controllers/runner_controller_test.exs
+122
−6
Click to load diff…
Parents: a9f5a09