neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Actions: [skip ci] and skip-checks: true skip a push or pull request
2cafbbf · Gabriel Morell · 2026-10-17 17:52
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], " ", "e_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