Actions: on: create and on: delete fire

587faf4 · Gabriel Morell · 2026-10-17 18:01

14 files +208 -5
Message
{commit_body(@commit)}

Files changed

modified README.md
+1 −0
@@ -92,6 +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 - **`on: workflow_dispatch`** from the Actions page ("Run workflow", for anyone who can write to the repository: pick a branch and fill in the inputs) or the Gitea/GitHub API (`POST /api/v1/repos/:owner/:name/actions/workflows/:file/dispatches`, a `ci:write` token or a job token). Declared `inputs:` are checked as GitHub checks them — an undeclared input, a missing `required` one, a `choice` outside its `options`, a `boolean` that isn't true/false or a `number` that isn't numeric answers 422 — and every declared input, defaults applied, reaches the job as `inputs.*` and `github.event.inputs.*` (strings)
94 94 - **`on: schedule`** (`- cron:`, five-field cron in UTC): read from the **default branch's** latest commit and run there, whichever branch the workflow file was pushed to; `github.event.schedule` is the cron that fired. Crons that can fire more often than **every five minutes** (GitHub's minimum), `@reboot` and ones that don't parse are ignored with a warning in the log. Archived repositories don't run their schedules
95 + - **`on: create` / `on: delete`** when a branch or tag is created or deleted (`branches:` / `tags:` filters don't apply, as on GitHub). `create` runs the new ref's workflows at its commit, alongside its `push` run; `delete` runs the **default branch's** workflows at its head, since the ref is gone (nothing runs when the default branch itself is deleted). `github.event.ref` is the short name, `github.event.ref_type` `branch` or `tag`
95 96 - **`[skip ci]`**, `[ci skip]`, `[no ci]`, `[skip actions]` or `[actions skip]` in the head commit's message (any case), or a `skip-checks: true` trailer, starts no `push` or `pull_request` runs — the head commit only, as on GitHub; other events aren't skipped
96 97 - **Filter patterns** in `branches:`, `tags:`, `paths:` and their `-ignore` lists follow GitHub's cheat sheet: `*` is any run of characters but `/`, `**` any run at all (`**/` also no directory), `?` zero or one and `+` one or more of the **preceding** character, `[a-z0-9]` one listed character (alphanumerics and `a-z`/`A-Z`/`0-9` ranges only), `\x` a literal `x`. In `branches:`, `tags:` and `paths:` the last matching pattern decides, so `!pattern` excludes and a later positive pattern includes again; a list with only `!` patterns matches nothing. `*` no longer crosses `/` in `branches:` / `tags:` (`branches: ['*']` doesn't run for `feature/x`), and `!` works there now. Branch protection and required-check patterns are separate: their `*` still crosses `/`
97 98 - **`paths:` / `paths-ignore:`**: 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
modified lib/git_gud/repositories/workers/commit_backfill.ex
+18 −3
@@ -6,9 +6,9 @@ defmodule GitGud.Repositories.Workers.CommitBackfill do
6 6 Idempotent: rows are SHA-keyed and inserted with `on_conflict: :nothing`.
7 7
8 8 A push made with a CI job's token (`"job_token" => true`) starts no
9 − workflows — neither `push` runs nor a pull request's `synchronize` — as
10 − on GitHub, where pushes made with `GITHUB_TOKEN` don't, so a workflow
11 − that pushes can't trigger itself forever. Everything else (caches,
9 + workflows — no `push`, `create` or `delete` runs, nor a pull request's
10 + `synchronize` — as on GitHub, where pushes made with `GITHUB_TOKEN`
11 + don't, so a workflow that pushes can't trigger itself forever. Everything else (caches,
12 12 webhooks, pull request heads) happens as for any push.
13 13 """
14 14
@@ -44,12 +44,17 @@ defmodule GitGud.Repositories.Workers.CommitBackfill do
44 44 if zero?(new_sha) do
45 45 Webhooks.notify(repo, "push", push_payload(repo, ref, old_sha, new_sha, :delete))
46 46 sync_schedules(repo, ref)
47 + if dispatch?, do: dispatch_ref_event(repo, "delete", ref, nil, pusher_id)
47 48 :ok
48 49 else
49 50 :ok = walk_and_cache(repo, old_sha, new_sha)
50 51 Webhooks.notify(repo, "push", push_payload(repo, ref, old_sha, new_sha, :update))
51 52 sync_schedules(repo, ref)
52 53 if dispatch?, do: dispatch_workflows(repo, ref, old_sha, new_sha, pusher_id)
54 +
55 + if dispatch? and (old_sha == nil or zero?(old_sha)),
56 + do: dispatch_ref_event(repo, "create", ref, new_sha, pusher_id)
57 +
53 58 publish_push(repo, ref, old_sha, new_sha)
54 59 refresh_cross_repo_prs(repo, ref, new_sha, pusher_id, dispatch?)
55 60 enqueue_language_stats(repo, ref, new_sha)
@@ -165,6 +170,16 @@ defmodule GitGud.Repositories.Workers.CommitBackfill do
165 170 end
166 171 end
167 172
173 + # `on: create` / `on: delete`, for branches and tags only; a push that
174 + # creates a ref has already been dispatched as `push`.
175 + defp dispatch_ref_event(repo, event, ref, sha, pusher_id) do
176 + if String.starts_with?(ref, ["refs/heads/", "refs/tags/"]) do
177 + _ = Workflows.dispatch_ref_event(repo, event, ref, sha, triggered_by_id: pusher_id)
178 + end
179 +
180 + :ok
181 + end
182 +
168 183 defp maybe_hex(hex) when is_binary(hex) and byte_size(hex) == 40 do
169 184 case Git.from_hex(hex) do
170 185 {:ok, sha} -> {:ok, sha}
modified lib/git_gud/workflows.ex
+53 −0
@@ -172,6 +172,59 @@ defmodule GitGud.Workflows do
172 172 end
173 173 end
174 174
175 + @doc """
176 + Start the `on: create` or `on: delete` workflows for a branch or tag
177 + that was created or deleted (`ref` is the full ref).
178 +
179 + As on GitHub, `branches:` / `tags:` filters don't apply to these, and
180 + skip markers aren't read. `create` runs the new ref's workflows at
181 + `head_sha` (the ref's new value); `delete` runs the **default
182 + branch's** workflows at its head — the ref is gone — and does nothing
183 + when the default branch itself was deleted. `github.event.ref` is the
184 + short name, `github.event.ref_type` `branch` or `tag`.
185 +
186 + `opts`: `:triggered_by_id`. Returns `{:ok, [run]}`.
187 + """
188 + def dispatch_ref_event(repo, event, ref, head_sha \\ nil, opts \\ [])
189 +
190 + def dispatch_ref_event(%Repository{} = repo, "create", ref, head_sha, opts)
191 + when is_binary(head_sha) do
192 + head_sha = peel(repo, ref, head_sha)
193 + ref_runs(repo, "create", ref, ref, head_sha, opts)
194 + end
195 +
196 + def dispatch_ref_event(%Repository{} = repo, "delete", ref, _head_sha, opts) do
197 + case default_branch_head(repo) do
198 + {:ok, default_ref, sha} when default_ref != ref ->
199 + ref_runs(repo, "delete", ref, default_ref, sha, opts)
200 +
201 + _ ->
202 + {:ok, []}
203 + end
204 + end
205 +
206 + defp ref_runs(repo, event, ref, run_ref, sha, opts) do
207 + triggered_by_id = Keyword.get(opts, :triggered_by_id)
208 +
209 + runs =
210 + create_matching_runs(
211 + repo,
212 + discover_workflows(repo, sha),
213 + event,
214 + %{},
215 + %{head_sha: sha, head_ref: run_ref, triggered_by_id: triggered_by_id},
216 + fn ->
217 + EventPayload.ref_event(
218 + Repo.preload(repo, [:owner, :organization]),
219 + ref,
220 + triggered_by_id && Repo.get(User, triggered_by_id)
221 + )
222 + end
223 + )
224 +
225 + {:ok, runs}
226 + end
227 +
175 228 @doc """
176 229 Start the `on: pull_request` workflows for a pull request.
177 230
modified lib/git_gud/workflows/event_payload.ex
+16 −0
@@ -19,6 +19,7 @@ defmodule GitGud.Workflows.EventPayload do
19 19 alias GitGud.Git
20 20 alias GitGud.Repositories.Repository
21 21 alias GitGud.Repositories.Storage
22 + alias GitGud.Workflows.Ref
22 23
23 24 @zero_sha String.duplicate("0", 40)
24 25
@@ -115,6 +116,21 @@ defmodule GitGud.Workflows.EventPayload do
115 116 }
116 117 end
117 118
119 + @doc """
120 + A `create` or `delete`: `ref` is the full ref created or deleted; the
121 + payload carries its short name and `branch` / `tag`.
122 + """
123 + def ref_event(%Repository{} = repo, ref, sender) do
124 + %{
125 + "ref" => Ref.name(ref),
126 + "ref_type" => Ref.type(ref),
127 + "pusher_type" => "user",
128 + "master_branch" => repo.default_branch,
129 + "repository" => repository(repo),
130 + "sender" => sender(repo, sender)
131 + }
132 + end
133 +
118 134 @doc "A `schedule`: the cron that fired. Nobody triggered it, so `sender` is the owner."
119 135 def schedule(%Repository{} = repo, cron) do
120 136 %{
modified lib/git_gud/workflows/yaml.ex
+4 −2
@@ -100,9 +100,11 @@ defmodule GitGud.Workflows.Yaml do
100 100 @doc """
101 101 Decide whether the workflow's `on:` matches an incoming event.
102 102
103 − `event` is one of `"push"`, `"pull_request"`, `"workflow_dispatch"`.
103 + `event` is the event name (`"push"`, `"pull_request"`, `"create"`, …).
104 104 `ctx` is a map with optional keys: `:branch`, `:tag`, `:action`,
105 − `:changed_paths`.
105 + `:changed_paths`. Events dispatched with an empty `ctx` (`create`,
106 + `delete`, `schedule`) are filtered on nothing, as on GitHub, which
107 + ignores `branches:` / `tags:` for them.
106 108
107 109 * Ref filters (`branches:` / `branches-ignore:`, `tags:` /
108 110 `tags-ignore:`) follow GitHub: naming either kind excludes the other
added test/git_gud/workflows/ref_event_dispatch_test.exs
+116 −0
@@ -0,0 +1,116 @@
1 +defmodule GitGud.Workflows.RefEventDispatchTest do
2 + @moduledoc """
3 + `on: create` and `on: delete`: a branch or tag appearing or going away.
4 + """
5 + use GitGud.DataCase, async: false
6 +
7 + alias GitGud.Repositories.Repository
8 + alias GitGud.Workflows.WorkflowRun
9 +
10 + import GitGud.ForgeFixtures
11 + import GitGud.WorkflowGitHelpers
12 +
13 + @moduletag :capture_log
14 +
15 + defp workflow(name, on) do
16 + """
17 + name: #{name}
18 + on:
19 + #{on}
20 + jobs:
21 + build:
22 + runs-on: self-hosted
23 + steps:
24 + - run: echo hello
25 + """
26 + end
27 +
28 + @refs ".forgejo/workflows/refs.yml"
29 + @push ".forgejo/workflows/push.yml"
30 +
31 + setup do
32 + {user, repo} = repository_fixture()
33 + {:ok, repo: repo, user: user}
34 + end
35 +
36 + defp runs(%Repository{id: rid}, trigger) do
37 + from(r in WorkflowRun,
38 + where: r.repository_id == ^rid and r.trigger == ^trigger,
39 + order_by: r.id
40 + )
41 + |> Repo.all()
42 + end
43 +
44 + test "creating a branch fires create and push; filters don't apply", %{
45 + repo: repo,
46 + user: user
47 + } do
48 + files = %{
49 + @refs => workflow("refs", " create:\n branches: [nope]\n delete:\n"),
50 + @push => workflow("push", " push:\n")
51 + }
52 +
53 + head = commit!(repo, files, [])
54 + push_ref!(repo, "refs/heads/feature", nil, head, user.id)
55 +
56 + assert [create] = runs(repo, "create")
57 + assert create.workflow_path == @refs
58 + assert create.head_ref == "refs/heads/feature"
59 + assert create.head_sha == sha!(head)
60 + assert create.triggered_by_id == user.id
61 + assert create.event_payload["ref"] == "feature"
62 + assert create.event_payload["ref_type"] == "branch"
63 +
64 + assert [push] = runs(repo, "push")
65 + assert push.workflow_path == @push
66 +
67 + # Moving it again isn't a create.
68 + next = commit!(repo, Map.put(files, "a", "a\n"), [head])
69 + push_ref!(repo, "refs/heads/feature", head, next, user.id)
70 + assert [_create] = runs(repo, "create")
71 + end
72 +
73 + test "a tag is a create with ref_type tag", %{repo: repo} do
74 + head = commit!(repo, %{@refs => workflow("refs", " create:\n")}, [])
75 + push_ref!(repo, "refs/tags/v1", nil, head)
76 +
77 + assert [run] = runs(repo, "create")
78 + assert run.event_payload["ref_type"] == "tag"
79 + assert run.event_payload["ref"] == "v1"
80 + end
81 +
82 + test "deleting a branch runs the default branch's delete workflows", %{repo: repo} do
83 + main = commit!(repo, %{@refs => workflow("refs-main", " delete:\n")}, [])
84 + push_ref!(repo, "refs/heads/main", nil, main)
85 +
86 + # The branch's own file doesn't say `delete:`, and it's gone anyway.
87 + feature = commit!(repo, %{@push => workflow("push", " push:\n")}, [main])
88 + push_ref!(repo, "refs/heads/feature", nil, feature)
89 + push_ref!(repo, "refs/heads/feature", feature, nil)
90 +
91 + assert [run] = runs(repo, "delete")
92 + assert run.workflow_name == "refs-main"
93 + assert run.head_ref == "refs/heads/main"
94 + assert run.head_sha == sha!(main)
95 + assert run.event_payload["ref"] == "feature"
96 + assert run.event_payload["ref_type"] == "branch"
97 + end
98 +
99 + test "a workflow with only push: fires on neither", %{repo: repo} do
100 + main = commit!(repo, %{@push => workflow("push", " push:\n branches: [main]\n")}, [])
101 + push_ref!(repo, "refs/heads/main", nil, main)
102 + push_ref!(repo, "refs/heads/feature", nil, main)
103 + push_ref!(repo, "refs/heads/feature", main, nil)
104 +
105 + assert runs(repo, "create") == []
106 + assert runs(repo, "delete") == []
107 + end
108 +
109 + test "deleting the default branch itself runs nothing", %{repo: repo} do
110 + main = commit!(repo, %{@refs => workflow("refs", " delete:\n")}, [])
111 + push_ref!(repo, "refs/heads/main", nil, main)
112 + push_ref!(repo, "refs/heads/main", main, nil)
113 +
114 + assert runs(repo, "delete") == []
115 + end
116 +end

Parents: ad37081