neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Actions: on: create and on: delete fire
587faf4 · Gabriel Morell · 2026-10-17 18:01
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