Actions: [skip ci] and skip-checks: true skip a push or pull request

2cafbbf · Gabriel Morell · 2026-10-17 17:52

9 files +268 -20
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/workflows.ex
+82 −20
@@ -46,13 +46,23 @@ defmodule GitGud.Workflows do
46 46 `paths:` / `paths-ignore:` filters are decided against — and
47 47 `triggered_by_id:`, the pusher.
48 48
49 + A `push` or `pull_request` whose head commit asks to skip CI
50 + (`skip_ci?/1`) creates no runs.
51 +
49 52 Returns `{:ok, [run]}`.
50 53 """
51 54 def dispatch(%Repository{} = repo, event) do
52 55 %{trigger: trigger, head_sha: head_sha, ref: ref} = event
53 56 head_sha = peel(repo, ref, head_sha)
57 + commit = head_commit(repo, head_sha)
58 +
59 + if skip_requested?(trigger, commit, ref),
60 + do: {:ok, []},
61 + else: do_dispatch(repo, event, trigger, head_sha, ref, commit)
62 + end
54 63
55 − workflows = discover_workflows(repo, head_sha)
64 + defp do_dispatch(repo, event, trigger, head_sha, ref, commit) do
65 + workflows = workflows_in(repo, commit)
56 66
57 67 ctx =
58 68 ref
@@ -118,6 +128,48 @@ defmodule GitGud.Workflows do
118 128 end
119 129 end
120 130
131 + @skip_markers ["[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"]
132 +
133 + @doc """
134 + Whether a commit message asks for no CI, as GitHub reads it: one of
135 + `[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]`, `[actions skip]`
136 + anywhere in it (any case), or a `skip-checks: true` trailer line.
137 +
138 + Only a `push`'s or `pull_request`'s head commit is asked — an earlier
139 + commit of the same push saying it doesn't skip anything.
140 +
141 + iex> GitGud.Workflows.skip_ci?("Fix typo [CI SKIP]")
142 + true
143 + """
144 + @spec skip_ci?(String.t() | nil) :: boolean()
145 + def skip_ci?(message) when is_binary(message) do
146 + lower = String.downcase(message)
147 +
148 + Enum.any?(@skip_markers, &String.contains?(lower, &1)) or
149 + Regex.match?(~r/^skip-checks:\s*true\s*$/m, lower)
150 + end
151 +
152 + def skip_ci?(_message), do: false
153 +
154 + defp skip_requested?(trigger, %{message: message}, label)
155 + when trigger in ["push", "pull_request"] do
156 + if skip_ci?(message) do
157 + Logger.info("not dispatching #{trigger} workflows for #{label}: the head commit skips CI")
158 + true
159 + else
160 + false
161 + end
162 + end
163 +
164 + defp skip_requested?(_trigger, _commit, _label), do: false
165 +
166 + defp head_commit(repo, sha) do
167 + case Repositories.get_commit(repo, sha) do
168 + {:ok, commit} -> commit
169 + _ -> nil
170 + end
171 + end
172 +
121 173 @doc """
122 174 Start the `on: pull_request` workflows for a pull request.
123 175
@@ -164,7 +216,15 @@ defmodule GitGud.Workflows do
164 216 {:error, reason} -> Logger.warning("could not pin #{head_ref}: #{inspect(reason)}")
165 217 end
166 218
167 − workflows = discover_workflows(repo, pr.head_sha)
219 + commit = head_commit(repo, pr.head_sha)
220 +
221 + if skip_requested?("pull_request", commit, head_ref),
222 + do: {:ok, []},
223 + else: pull_request_runs(repo, pr, action, opts, head_ref, target_ref, commit)
224 + end
225 +
226 + defp pull_request_runs(repo, pr, action, opts, head_ref, target_ref, commit) do
227 + workflows = workflows_in(repo, commit)
168 228 target_sha = ref_head(repo, target_ref)
169 229 triggered_by_id = Keyword.get(opts, :triggered_by_id)
170 230
@@ -406,25 +466,27 @@ defmodule GitGud.Workflows do
406 466 Workflows that fail to parse are dropped silently for now; in a later
407 467 pass we'll surface them as failed runs with a parse-error annotation.
408 468 """
409 − def discover_workflows(%Repository{disk_path: path} = repo, head_sha) do
410 − with {:ok, commit} <- Repositories.get_commit(repo, head_sha) do
411 − @workflow_dirs
412 − |> Enum.flat_map(&list_yaml_files(path, commit.tree_sha, &1))
413 − |> Enum.flat_map(fn {workflow_path, blob_sha} ->
414 − case Git.blob_bytes(path, blob_sha) do
415 − {:ok, bytes} ->
416 − case Yaml.parse(bytes) do
417 − {:ok, doc} -> [{workflow_path, doc}]
418 − _ -> []
419 − end
469 + def discover_workflows(%Repository{} = repo, head_sha),
470 + do: workflows_in(repo, head_commit(repo, head_sha))
471 +
472 + # `discover_workflows/2` for a commit already read (nil: none).
473 + defp workflows_in(_repo, nil), do: []
474 +
475 + defp workflows_in(%Repository{disk_path: path}, %{tree_sha: tree_sha}) do
476 + @workflow_dirs
477 + |> Enum.flat_map(&list_yaml_files(path, tree_sha, &1))
478 + |> Enum.flat_map(fn {workflow_path, blob_sha} ->
479 + case Git.blob_bytes(path, blob_sha) do
480 + {:ok, bytes} ->
481 + case Yaml.parse(bytes) do
482 + {:ok, doc} -> [{workflow_path, doc}]
483 + _ -> []
484 + end
420 485
421 − _ ->
422 − []
423 − end
424 − end)
425 − else
426 − _ -> []
427 − end
486 + _ ->
487 + []
488 + end
489 + end)
428 490 end
429 491
430 492 defp list_yaml_files(repo_path, tree_sha, dir) do
added test/git_gud/workflows/skip_ci_test.exs
+109 −0
@@ -0,0 +1,109 @@
1 +defmodule GitGud.Workflows.SkipCiTest do
2 + @moduledoc """
3 + `[skip ci]` and friends: a `push` or `pull_request` whose head commit
4 + asks for no CI creates no runs; other events don't look.
5 + """
6 + use GitGud.DataCase, async: false
7 + use Oban.Testing, repo: GitGud.Repo
8 +
9 + alias GitGud.PullRequests
10 + alias GitGud.Repositories.Repository
11 + alias GitGud.Workflows
12 + alias GitGud.Workflows.WorkflowRun
13 +
14 + import GitGud.ForgeFixtures
15 + import GitGud.WorkflowGitHelpers
16 +
17 + doctest GitGud.Workflows, only: [skip_ci?: 1]
18 +
19 + @wf ".forgejo/workflows/ci.yml"
20 +
21 + @workflow """
22 + name: ci
23 + on:
24 + push:
25 + pull_request:
26 + workflow_dispatch:
27 + jobs:
28 + build:
29 + runs-on: self-hosted
30 + steps:
31 + - run: echo hello
32 + """
33 +
34 + setup do
35 + {user, repo} = repository_fixture()
36 + {:ok, repo: repo, user: user}
37 + end
38 +
39 + defp runs(%Repository{id: rid}, trigger) do
40 + from(r in WorkflowRun, where: r.repository_id == ^rid and r.trigger == ^trigger)
41 + |> Repo.all()
42 + end
43 +
44 + describe "skip_ci?/1" do
45 + test "each marker, in any case" do
46 + for marker <- ["[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"] do
47 + assert Workflows.skip_ci?("Fix it " <> marker)
48 + assert Workflows.skip_ci?(String.upcase(marker) <> " fix it")
49 + end
50 + end
51 +
52 + test "the skip-checks trailer, with or without the space" do
53 + assert Workflows.skip_ci?("Fix it\n\nskip-checks: true")
54 + assert Workflows.skip_ci?("Fix it\n\nSigned-off-by: a <a@b>\nskip-checks:true\n")
55 + refute Workflows.skip_ci?("Fix it\n\nskip-checks: false")
56 + refute Workflows.skip_ci?("mentions skip-checks: true inline")
57 + end
58 +
59 + test "an ordinary message, or none" do
60 + refute Workflows.skip_ci?("skip the ci step")
61 + refute Workflows.skip_ci?("[skip-ci]")
62 + refute Workflows.skip_ci?(nil)
63 + end
64 + end
65 +
66 + describe "push" do
67 + test "a head commit saying [skip ci] runs nothing", %{repo: repo, user: user} do
68 + head = commit!(repo, %{@wf => @workflow}, [], "Docs only [skip ci]")
69 + push_ref!(repo, "refs/heads/main", nil, head, user.id)
70 +
71 + assert runs(repo, "push") == []
72 + end
73 +
74 + test "a marker in an earlier commit of the push doesn't skip", %{repo: repo, user: user} do
75 + first = commit!(repo, %{@wf => @workflow}, [], "wip [skip ci]")
76 + head = commit!(repo, %{@wf => @workflow, "a" => "a\n"}, [first], "done")
77 + push_ref!(repo, "refs/heads/main", nil, head, user.id)
78 +
79 + assert [_run] = runs(repo, "push")
80 + end
81 +
82 + test "the trailer skips too", %{repo: repo, user: user} do
83 + head = commit!(repo, %{@wf => @workflow}, [], "Tidy\n\nskip-checks: true")
84 + push_ref!(repo, "refs/heads/main", nil, head, user.id)
85 +
86 + assert runs(repo, "push") == []
87 + end
88 + end
89 +
90 + test "a pull request whose head says [skip ci] runs nothing", %{repo: repo, user: user} do
91 + main = commit!(repo, %{@wf => @workflow}, [])
92 + git!(repo.disk_path, ["update-ref", "refs/heads/main", main])
93 + feature = commit!(repo, %{@wf => @workflow, "a" => "a\n"}, [main], "WIP [CI SKIP]")
94 + git!(repo.disk_path, ["update-ref", "refs/heads/feature", feature])
95 +
96 + {:ok, _pr} = PullRequests.create_pull_request(repo, user, pr_attrs("feature", "main"))
97 + Oban.drain_queue(queue: :ci)
98 +
99 + assert runs(repo, "pull_request") == []
100 + end
101 +
102 + test "workflow_dispatch is not skipped", %{repo: repo} do
103 + head = commit!(repo, %{@wf => @workflow}, [], "Release [skip ci]")
104 + git!(repo.disk_path, ["update-ref", "refs/heads/main", head])
105 +
106 + assert {:ok, %WorkflowRun{trigger: "workflow_dispatch"}} =
107 + Workflows.dispatch_manual(repo, "ci.yml", "main")
108 + end
109 +end
added test/support/workflow_git_helpers.ex
+77 −0
@@ -0,0 +1,77 @@
1 +defmodule GitGud.WorkflowGitHelpers do
2 + @moduledoc """
3 + Commits and refs on a test repository's disk, for the dispatch tests:
4 + real objects written with plumbing, no working tree.
5 + """
6 +
7 + alias GitGud.Repositories.Repository
8 + alias GitGud.Repositories.Workers.CommitBackfill
9 +
10 + @doc "A commit holding exactly `files` (path => contents) on top of `parents`."
11 + def commit!(%Repository{disk_path: path}, files, parents, message \\ "c") do
12 + index = Path.join(System.tmp_dir!(), "gitidx-#{System.unique_integer([:positive])}")
13 + env = [{"GIT_INDEX_FILE", index}]
14 +
15 + try do
16 + for {file, contents} <- files do
17 + blob = git!(path, ["hash-object", "-w", "--stdin"], contents)
18 + git!(path, ["update-index", "--add", "--cacheinfo", "100644,#{blob},#{file}"], nil, env)
19 + end
20 +
21 + tree = git!(path, ["write-tree"], nil, env)
22 + git!(path, ["commit-tree", tree | Enum.flat_map(parents, &["-p", &1])] ++ ["-m", message])
23 + after
24 + File.rm(index)
25 + end
26 + end
27 +
28 + def git!(path, args, input \\ nil, env \\ []) do
29 + {out, 0} =
30 + case input do
31 + nil ->
32 + System.cmd("git", args, cd: path, stderr_to_stdout: true, env: env)
33 +
34 + _ ->
35 + file = Path.join(System.tmp_dir!(), "gitin-#{System.unique_integer([:positive])}")
36 + File.write!(file, input)
37 +
38 + try do
39 + command = Enum.map_join(["git" | args] ++ ["<", file], " ", &quote_arg/1)
40 + System.cmd("sh", ["-c", command], cd: path, stderr_to_stdout: true, env: env)
41 + after
42 + File.rm(file)
43 + end
44 + end
45 +
46 + String.trim(out)
47 + end
48 +
49 + defp quote_arg("<"), do: "<"
50 + defp quote_arg(arg), do: "'" <> String.replace(arg, "'", "'\\''") <> "'"
51 +
52 + def sha!(hex), do: elem(GitGud.Git.from_hex(hex), 1)
53 +
54 + @zero String.duplicate("0", 40)
55 +
56 + @doc """
57 + Point `ref` at `new_hex` (nil deletes it) and run what the post-receive
58 + hook enqueues for that update. `old_hex` nil means a new ref.
59 + """
60 + def push_ref!(%Repository{} = repo, ref, old_hex, new_hex, pusher_id \\ nil) do
61 + if new_hex,
62 + do: git!(repo.disk_path, ["update-ref", ref, new_hex]),
63 + else: git!(repo.disk_path, ["update-ref", "-d", ref])
64 +
65 + :ok =
66 + CommitBackfill.perform(%Oban.Job{
67 + args: %{
68 + "repository_id" => repo.id,
69 + "ref" => ref,
70 + "old_sha_hex" => old_hex || @zero,
71 + "new_sha_hex" => new_hex || @zero,
72 + "pusher_id" => pusher_id,
73 + "job_token" => false
74 + }
75 + })
76 + end
77 +end

Parents: 6b0b893