Actions: give a job the github.event, ids and secrets.GITHUB_TOKEN it's promised

e9900fa · Gabriel Morell · 2026-10-17 15:09

26 files +721 -74
Message
{commit_body(@commit)}

Files changed

modified README.md
+1 −0
@@ -91,6 +91,7 @@ reinvent them.
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,11 +13,13 @@ defmodule GitGud.Workflows do
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,7 +40,8 @@ defmodule GitGud.Workflows do
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,16 +67,54 @@ defmodule GitGud.Workflows do
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,22 +162,38 @@ defmodule GitGud.Workflows do
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,21 +201,33 @@ defmodule GitGud.Workflows do
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,13 +238,14 @@ defmodule GitGud.Workflows do
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,6 +258,14 @@ defmodule GitGud.Workflows do
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,13 +312,25 @@ defmodule GitGud.Workflows do
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,9 +1658,13 @@ defmodule GitGud.Workflows do
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,6 +1677,7 @@ defmodule GitGud.Workflows do
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,7 +1750,8 @@ defmodule GitGud.Workflows do
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
@@ -0,0 +1,170 @@
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,13 +12,22 @@ defmodule GitGud.Workflows.RunnerPayload do
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,14 +42,28 @@ defmodule GitGud.Workflows.RunnerPayload do
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,6 +41,8 @@ defmodule GitGud.Workflows.WorkflowJob do
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