Actions: workflow_dispatch inputs reach the job, and a Run workflow button

9b97d32 · Gabriel Morell · 2026-10-17 17:56

25 files +796 -10
Message
{commit_body(@commit)}

Files changed

modified README.md
+1 −0
@@ -90,6 +90,7 @@ reinvent them.
90 90 - **Five deployment styles** offered in the registration UI: Native binary, Docker, Podman, Podman Quadlet, Kubernetes (Deployment + dind sidecar) — copy-pasteable `register` + `daemon` snippets per style
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 + - **`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)
93 94 - **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 `/`
94 95 - **`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
95 96 - **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
modified lib/git_gud/workflows.ex
+44 −6
@@ -22,6 +22,7 @@ defmodule GitGud.Workflows do
22 22 alias GitGud.Repositories.Storage
23 23 alias GitGud.Workflows.Concurrency
24 24 alias GitGud.Workflows.Context
25 + alias GitGud.Workflows.DispatchInputs
25 26 alias GitGud.Workflows.EventPayload
26 27 alias GitGud.Workflows.Matrix
27 28 alias GitGud.Workflows.Ref
@@ -301,19 +302,29 @@ defmodule GitGud.Workflows do
301 302 (`.forgejo/workflows/build.yml`); `ref` is a branch or tag, short
302 303 (`main`, `v1.2`) or full (`refs/heads/main`). The workflow has to opt in
303 304 with `on: workflow_dispatch`, as on GitHub, so a file can't be run by
304 − someone who merely knows its name. `inputs` (string → string) reach the
305 − job as `github.event.inputs` / `inputs`.
305 + someone who merely knows its name.
306 +
307 + `opts`:
308 +
309 + * `:inputs` — name → value, checked against the workflow's declared
310 + inputs (`GitGud.Workflows.DispatchInputs`); every declared input,
311 + defaults applied, reaches the job as `github.event.inputs` /
312 + `inputs` (strings)
313 + * `:triggered_by_id` — who asked
306 314
307 315 Returns `{:ok, run}`, or `{:error, :unknown_ref | :unknown_workflow |
308 − :not_dispatchable}`.
316 + :not_dispatchable | {:invalid_inputs, [{name, reason}]}}`.
309 317 """
310 − def dispatch_manual(%Repository{} = repo, workflow, ref, triggered_by_id \\ nil, inputs \\ %{})
311 − when is_binary(workflow) and is_binary(ref) and is_map(inputs) do
318 + def dispatch_manual(%Repository{} = repo, workflow, ref, opts \\ [])
319 + when is_binary(workflow) and is_binary(ref) and is_list(opts) do
320 + triggered_by_id = Keyword.get(opts, :triggered_by_id)
321 +
312 322 with {:ok, full_ref, sha} <- resolve_ref(repo, ref),
313 323 {:ok, path, doc} <- find_workflow(repo, sha, workflow),
314 324 true <-
315 325 Yaml.matches?(doc, "workflow_dispatch", Ref.context(full_ref)) ||
316 − {:error, :not_dispatchable} do
326 + {:error, :not_dispatchable},
327 + {:ok, inputs} <- resolve_inputs(doc, Keyword.get(opts, :inputs, %{})) do
317 328 create_run(repo, %{
318 329 workflow_path: path,
319 330 workflow_name: doc.name,
@@ -335,6 +346,33 @@ defmodule GitGud.Workflows do
335 346 end
336 347 end
337 348
349 + defp resolve_inputs(doc, given) do
350 + case DispatchInputs.resolve(doc, given || %{}) do
351 + {:ok, inputs} -> {:ok, inputs}
352 + {:error, errors} -> {:error, {:invalid_inputs, errors}}
353 + end
354 + end
355 +
356 + @doc """
357 + The workflows at the default branch's head that can be run by hand
358 + (`on: workflow_dispatch`), as `[{path, doc}]` sorted by path. Empty
359 + when the default branch doesn't exist yet.
360 + """
361 + def dispatchable_workflows(%Repository{} = repo) do
362 + case default_branch_ref(repo) && resolve_ref(repo, default_branch_ref(repo)) do
363 + {:ok, full_ref, sha} ->
364 + repo
365 + |> discover_workflows(sha)
366 + |> Enum.filter(fn {_path, doc} ->
367 + Yaml.matches?(doc, "workflow_dispatch", Ref.context(full_ref))
368 + end)
369 + |> Enum.sort_by(&elem(&1, 0))
370 +
371 + _ ->
372 + []
373 + end
374 + end
375 +
338 376 # Beyond this many changed files the filter isn't worth deciding: the push
339 377 # is big enough that running is the safe answer, and GitHub draws a
340 378 # similar line.
added lib/git_gud/workflows/dispatch_inputs.ex
+136 −0
@@ -0,0 +1,136 @@
1 +defmodule GitGud.Workflows.DispatchInputs do
2 + @moduledoc """
3 + A `workflow_dispatch`'s inputs, checked against what the workflow
4 + declares under `on.workflow_dispatch.inputs` — GitHub's (and Gitea's)
5 + rules, which answer 422 for:
6 +
7 + * an input the workflow doesn't declare;
8 + * a `required` input that is neither given (nor blank) nor defaulted;
9 + * a `choice` whose value isn't one of its `options`;
10 + * a `boolean` that isn't `true` / `false`;
11 + * a `number` that isn't numeric.
12 +
13 + `type` defaults to `string`; `environment` is taken as a string (there
14 + are no environments here). An input that isn't given takes its
15 + `default`, or `""` (`"false"` for a boolean) when it has none.
16 +
17 + Values come out as strings — booleans `"true"` / `"false"` — which is
18 + how GitHub puts them in `github.event.inputs`; the runner converts
19 + `inputs.<id>` per the declared type.
20 + """
21 +
22 + @type errors :: [{String.t(), String.t()}]
23 +
24 + @doc """
25 + `{:ok, inputs}` — every declared input, defaults applied — or
26 + `{:error, [{name, reason}]}`, sorted by name.
27 + """
28 + @spec resolve(map(), map()) :: {:ok, %{String.t() => String.t()}} | {:error, errors()}
29 + def resolve(doc, given) when is_map(given) do
30 + declared = declared(doc)
31 + given = Map.new(given, fn {k, v} -> {to_string(k), v} end)
32 +
33 + unknown =
34 + for name <- Map.keys(given),
35 + not Map.has_key?(declared, name),
36 + do: {name, "is not an input of this workflow"}
37 +
38 + {values, errors} =
39 + Enum.reduce(declared, {%{}, unknown}, fn {name, spec}, {values, errors} ->
40 + case value(spec, Map.get(given, name)) do
41 + {:ok, value} -> {Map.put(values, name, value), errors}
42 + {:error, reason} -> {values, [{name, reason} | errors]}
43 + end
44 + end)
45 +
46 + case errors do
47 + [] -> {:ok, values}
48 + errors -> {:error, Enum.sort(errors)}
49 + end
50 + end
51 +
52 + @doc """
53 + The declared inputs, `name => spec` (`spec` always a map), from a
54 + normalized workflow or its raw document.
55 + """
56 + @spec declared(map()) :: %{String.t() => map()}
57 + def declared(%{triggers: triggers}), do: declared_in(triggers)
58 + def declared(%{"on" => on}) when is_map(on), do: declared_in(on)
59 + def declared(_doc), do: %{}
60 +
61 + defp declared_in(%{"workflow_dispatch" => %{"inputs" => %{} = inputs}}),
62 + do: Map.new(inputs, fn {name, spec} -> {to_string(name), spec_map(spec)} end)
63 +
64 + defp declared_in(_), do: %{}
65 +
66 + defp spec_map(%{} = spec), do: spec
67 + defp spec_map(_), do: %{}
68 +
69 + @doc "An input's `type`, `\"string\"` when it doesn't say."
70 + @spec type(map()) :: String.t()
71 + def type(spec), do: to_string(Map.get(spec, "type") || "string")
72 +
73 + @doc "A `choice` input's options, as strings."
74 + @spec options(map()) :: [String.t()]
75 + def options(spec), do: spec |> Map.get("options") |> List.wrap() |> Enum.map(&to_string/1)
76 +
77 + defp value(spec, nil) do
78 + case Map.get(spec, "default") do
79 + nil ->
80 + if Map.get(spec, "required") in [true, "true"],
81 + do: {:error, "is required"},
82 + else: {:ok, if(type(spec) == "boolean", do: "false", else: "")}
83 +
84 + default ->
85 + check(type(spec), spec, default)
86 + end
87 + end
88 +
89 + # A required input left blank (an emptied form field) is a missing one.
90 + defp value(%{"required" => required} = spec, "") when required in [true, "true"],
91 + do: value(spec, nil)
92 +
93 + defp value(spec, given), do: check(type(spec), spec, given)
94 +
95 + defp check("boolean", _spec, v) when is_boolean(v), do: {:ok, to_string(v)}
96 +
97 + defp check("boolean", _spec, v) when is_binary(v) do
98 + case String.downcase(v) do
99 + b when b in ["true", "false"] -> {:ok, b}
100 + _ -> {:error, "must be true or false"}
101 + end
102 + end
103 +
104 + defp check("boolean", _spec, _v), do: {:error, "must be true or false"}
105 +
106 + defp check("number", _spec, v) when is_number(v), do: {:ok, to_string(v)}
107 +
108 + defp check("number", _spec, v) when is_binary(v) do
109 + trimmed = String.trim(v)
110 +
111 + case Float.parse(trimmed) do
112 + {_n, ""} -> {:ok, trimmed}
113 + _ -> {:error, "must be a number"}
114 + end
115 + end
116 +
117 + defp check("number", _spec, _v), do: {:error, "must be a number"}
118 +
119 + defp check("choice", spec, v) when is_binary(v) or is_number(v) or is_boolean(v) do
120 + v = to_string(v)
121 + options = options(spec)
122 +
123 + if v in options,
124 + do: {:ok, v},
125 + else: {:error, "must be one of: " <> Enum.join(options, ", ")}
126 + end
127 +
128 + defp check(_type, _spec, v) when is_binary(v), do: {:ok, v}
129 + defp check(_type, _spec, v) when is_number(v) or is_boolean(v), do: {:ok, to_string(v)}
130 + defp check(_type, _spec, _v), do: {:error, "must be a string"}
131 +
132 + @doc "`[{name, reason}]` as one sentence per input, for an API reply or a flash."
133 + @spec describe(errors()) :: String.t()
134 + def describe(errors),
135 + do: Enum.map_join(errors, "; ", fn {name, reason} -> "input #{name} #{reason}" end)
136 +end
modified lib/git_gud_web/controllers/api/v1/workflow_controller.ex
+30 −3
@@ -4,7 +4,7 @@ defmodule GitGudWeb.Api.V1.WorkflowController do
4 4
5 5 POST /api/v1/repos/:owner/:name/actions/workflows/:workflow/dispatches
6 6 Authorization: token <credential>
7 − { "ref": "main" }
7 + { "ref": "main", "inputs": { "environment": "staging" } }
8 8
9 9 `:workflow` is the file's basename (`build.yml`) or its path with the
10 10 slashes percent-encoded (`.forgejo%2Fworkflows%2Fbuild.yml`); the workflow must declare
@@ -20,7 +20,12 @@ defmodule GitGudWeb.Api.V1.WorkflowController do
20 20 * a personal access token with `ci:write`, whose account may write to
21 21 the repository.
22 22
23 − `inputs` are accepted for compatibility and not yet passed to the run.
23 + `inputs` is an object of strings, checked against the workflow's
24 + `on.workflow_dispatch.inputs` (`GitGud.Workflows.DispatchInputs`): an
25 + undeclared input, a missing required one, a `choice` outside its
26 + options, a `boolean` that isn't true/false or a `number` that isn't
27 + numeric answers 422, as on GitHub. Declared inputs that aren't given
28 + take their defaults.
24 29 """
25 30 use GitGudWeb, :controller
26 31
@@ -28,6 +33,7 @@ defmodule GitGudWeb.Api.V1.WorkflowController do
28 33 alias GitGud.Organizations
29 34 alias GitGud.Repositories
30 35 alias GitGud.Workflows
36 + alias GitGud.Workflows.DispatchInputs
31 37 alias GitGud.Workflows.JobTokenGrants
32 38 alias GitGud.Workflows.RunnerJwt
33 39
@@ -36,7 +42,12 @@ defmodule GitGudWeb.Api.V1.WorkflowController do
36 42 {:ok, actor_id} <- authorize(conn, repo),
37 43 ref when is_binary(ref) and ref != "" <-
38 44 params["ref"] || repo.default_branch || {:error, :no_ref},
39 − {:ok, _run} <- Workflows.dispatch_manual(repo, workflow, ref, actor_id) do
45 + {:ok, inputs} <- inputs_param(params["inputs"]),
46 + {:ok, _run} <-
47 + Workflows.dispatch_manual(repo, workflow, ref,
48 + inputs: inputs,
49 + triggered_by_id: actor_id
50 + ) do
40 51 send_resp(conn, 204, "")
41 52 else
42 53 {:error, :not_found} ->
@@ -60,11 +71,27 @@ defmodule GitGudWeb.Api.V1.WorkflowController do
60 71 {:error, :not_dispatchable} ->
61 72 send_error(conn, 422, "#{workflow} has no workflow_dispatch trigger")
62 73
74 + {:error, :bad_inputs} ->
75 + send_error(conn, 422, "inputs must be an object of strings")
76 +
77 + {:error, {:invalid_inputs, errors}} ->
78 + send_error(conn, 422, DispatchInputs.describe(errors))
79 +
63 80 {:error, reason} ->
64 81 send_error(conn, 500, inspect(reason))
65 82 end
66 83 end
67 84
85 + defp inputs_param(nil), do: {:ok, %{}}
86 +
87 + defp inputs_param(%{} = inputs) do
88 + if Enum.all?(inputs, fn {_k, v} -> is_binary(v) end),
89 + do: {:ok, inputs},
90 + else: {:error, :bad_inputs}
91 + end
92 +
93 + defp inputs_param(_), do: {:error, :bad_inputs}
94 +
68 95 defp fetch_repo(owner, name) do
69 96 {:ok, Repositories.get_repository_by_path!(owner, name)}
70 97 rescue
modified lib/git_gud_web/live/workflow_live/index.ex
+224 −0
@@ -2,9 +2,11 @@ defmodule GitGudWeb.WorkflowLive.Index do
2 2 use GitGudWeb, :live_view
3 3
4 4 alias GitGud.Git
5 + alias GitGud.Organizations
5 6 alias GitGud.Repositories
6 7 alias GitGud.Repositories.Storage
7 8 alias GitGud.Workflows
9 + alias GitGud.Workflows.DispatchInputs
8 10
9 11 @impl true
10 12 def mount(%{"owner" => owner, "name" => name}, _session, socket) do
@@ -26,9 +28,138 @@ defmodule GitGudWeb.WorkflowLive.Index do
26 28 |> GitGudWeb.RepoLive.Header.assign_chrome(repo)
27 29 |> stream(:runs, runs)
28 30 |> assign_durations()
31 + |> assign_dispatch()
29 32 |> assign(:page_title, "Actions — #{owner}/#{name}")}
30 33 end
31 34
35 + # "Run workflow": the default branch's `on: workflow_dispatch` files,
36 + # for someone who may write to the repository — the same rule as the
37 + # dispatch API's personal tokens.
38 + defp assign_dispatch(socket) do
39 + repo = socket.assigns.repo
40 + user = socket.assigns.current_scope && socket.assigns.current_scope.user
41 + can? = user != nil and Organizations.can?(user, repo, :write) == true
42 +
43 + workflows =
44 + if can?,
45 + do:
46 + for(
47 + {path, doc} <- Workflows.dispatchable_workflows(repo),
48 + do: %{path: path, name: doc.name || Path.basename(path), inputs: input_specs(doc)}
49 + ),
50 + else: []
51 +
52 + socket
53 + |> assign(:dispatchable, workflows)
54 + |> assign(:dispatching, nil)
55 + |> assign(:branches, [])
56 + |> assign(:dispatch_form, nil)
57 + |> assign(:inputs_form, nil)
58 + end
59 +
60 + defp input_specs(doc) do
61 + doc
62 + |> DispatchInputs.declared()
63 + |> Enum.sort_by(&elem(&1, 0))
64 + |> Enum.map(fn {name, spec} ->
65 + %{
66 + name: name,
67 + type: DispatchInputs.type(spec),
68 + label: spec["description"] || name,
69 + required: spec["required"] in [true, "true"],
70 + options: DispatchInputs.options(spec),
71 + default: spec["default"]
72 + }
73 + end)
74 + end
75 +
76 + defp branches(repo) do
77 + case Git.list_refs(repo.disk_path) do
78 + {:ok, refs} ->
79 + refs
80 + |> Enum.flat_map(fn
81 + %{name: "refs/heads/" <> b} -> [b]
82 + _ -> []
83 + end)
84 + |> Enum.sort()
85 +
86 + _ ->
87 + []
88 + end
89 + end
90 +
91 + @impl true
92 + def handle_event("open_dispatch", %{"path" => path}, socket) do
93 + case Enum.find(socket.assigns.dispatchable, &(&1.path == path)) do
94 + nil ->
95 + {:noreply, socket}
96 +
97 + workflow ->
98 + repo = socket.assigns.repo
99 +
100 + defaults =
101 + Map.new(workflow.inputs, fn input ->
102 + {input.name, if(input.default == nil, do: nil, else: to_string(input.default))}
103 + end)
104 +
105 + {:noreply,
106 + socket
107 + |> assign(:dispatching, workflow)
108 + |> assign(:branches, branches(repo))
109 + |> assign(:dispatch_form, to_form(%{"ref" => repo.default_branch}, as: :dispatch))
110 + |> assign(:inputs_form, to_form(defaults, as: :inputs))}
111 + end
112 + end
113 +
114 + def handle_event("close_dispatch", _params, socket) do
115 + {:noreply, assign(socket, dispatching: nil, dispatch_form: nil, inputs_form: nil)}
116 + end
117 +
118 + def handle_event("dispatch", params, socket) do
119 + %{dispatching: workflow, repo: repo, current_scope: scope} = socket.assigns
120 + ref = get_in(params, ["dispatch", "ref"]) || repo.default_branch
121 + given = Map.get(params, "inputs", %{})
122 +
123 + cond do
124 + workflow == nil ->
125 + {:noreply, socket}
126 +
127 + # Write access is re-checked: it may have been revoked since mount.
128 + Organizations.can?(scope.user, repo, :write) != true ->
129 + {:noreply, put_flash(socket, :error, "You can't run this repository's workflows.")}
130 +
131 + true ->
132 + case Workflows.dispatch_manual(repo, workflow.path, ref,
133 + inputs: given,
134 + triggered_by_id: scope.user.id
135 + ) do
136 + {:ok, run} ->
137 + {:noreply,
138 + push_navigate(socket,
139 + to: ~p"/r/#{socket.assigns.handle}/#{repo.name}/actions/#{run.number}"
140 + )}
141 +
142 + {:error, reason} ->
143 + {:noreply,
144 + socket
145 + |> assign(:dispatch_form, to_form(%{"ref" => ref}, as: :dispatch))
146 + |> assign(:inputs_form, to_form(given, as: :inputs))
147 + |> put_flash(:error, dispatch_error(reason, ref))}
148 + end
149 + end
150 + end
151 +
152 + defp dispatch_error({:invalid_inputs, errors}, _ref),
153 + do: "Could not run the workflow: " <> DispatchInputs.describe(errors) <> "."
154 +
155 + defp dispatch_error(:unknown_ref, ref), do: "No such branch: #{ref}."
156 +
157 + defp dispatch_error(:not_dispatchable, ref),
158 + do: "That workflow has no workflow_dispatch trigger on #{ref}."
159 +
160 + defp dispatch_error(:unknown_workflow, ref), do: "That workflow doesn't exist on #{ref}."
161 + defp dispatch_error(_reason, _ref), do: "Could not run the workflow."
162 +
32 163 @impl true
33 164 def handle_info({:ci_run_changed, _run_id}, socket) do
34 165 repo = socket.assigns.repo
@@ -68,6 +199,99 @@ defmodule GitGudWeb.WorkflowLive.Index do
68 199 />
69 200 <h1 class="text-xl font-semibold">Actions</h1>
70 201
202 + <section
203 + :if={@dispatchable != []}
204 + id="dispatchable-workflows"
205 + class="rounded-box border border-base-300 divide-y divide-base-300"
206 + >
207 + <div
208 + :for={{workflow, i} <- Enum.with_index(@dispatchable)}
209 + id={"dispatchable-#{i}"}
210 + class="p-3"
211 + >
212 + <div class="flex items-center justify-between gap-3">
213 + <div class="min-w-0">
214 + <p class="font-medium truncate">{workflow.name}</p>
215 + <p class="text-xs opacity-60 font-mono truncate">{workflow.path}</p>
216 + </div>
217 + <button
218 + :if={@dispatching == nil or @dispatching.path != workflow.path}
219 + type="button"
220 + id={"run-workflow-#{i}"}
221 + class="btn btn-sm btn-primary transition hover:-translate-y-px"
222 + phx-click="open_dispatch"
223 + phx-value-path={workflow.path}
224 + >
225 + <.icon name="hero-play" class="size-4" /> Run workflow
226 + </button>
227 + </div>
228 +
229 + <.form
230 + :if={@dispatching != nil and @dispatching.path == workflow.path}
231 + for={@dispatch_form}
232 + id="dispatch-form"
233 + phx-submit="dispatch"
234 + class="mt-3 space-y-1 max-w-md"
235 + >
236 + <.input
237 + field={@dispatch_form[:ref]}
238 + type="select"
239 + label="Branch"
240 + options={@branches}
241 + />
242 + <%= for input <- workflow.inputs do %>
243 + <%= case input.type do %>
244 + <% "boolean" -> %>
245 + <.input
246 + field={@inputs_form[input.name]}
247 + id={"dispatch-input-#{input.name}"}
248 + type="checkbox"
249 + label={input.label}
250 + />
251 + <% "choice" -> %>
252 + <.input
253 + field={@inputs_form[input.name]}
254 + id={"dispatch-input-#{input.name}"}
255 + type="select"
256 + label={input.label}
257 + options={input.options}
258 + />
259 + <% "number" -> %>
260 + <.input
261 + field={@inputs_form[input.name]}
262 + id={"dispatch-input-#{input.name}"}
263 + type="number"
264 + step="any"
265 + label={input.label}
266 + required={input.required}
267 + />
268 + <% _ -> %>
269 + <.input
270 + field={@inputs_form[input.name]}
271 + id={"dispatch-input-#{input.name}"}
272 + type="text"
273 + label={input.label}
274 + required={input.required}
275 + />
276 + <% end %>
277 + <% end %>
278 + <div class="flex gap-2 pt-2">
279 + <button type="submit" id="dispatch-submit" class="btn btn-sm btn-primary">
280 + Run workflow
281 + </button>
282 + <button
283 + type="button"
284 + id="dispatch-cancel"
285 + class="btn btn-sm btn-ghost"
286 + phx-click="close_dispatch"
287 + >
288 + Cancel
289 + </button>
290 + </div>
291 + </.form>
292 + </div>
293 + </section>
294 +
71 295 <section :if={@durations != []} class="rounded-box border border-base-300 p-3">
72 296 <h2 class="text-sm font-medium mb-2">Run times</h2>
73 297 <GitGudWeb.CiComponents.duration_chart
added test/git_gud/workflows/dispatch_inputs_test.exs
+124 −0
@@ -0,0 +1,124 @@
1 +defmodule GitGud.Workflows.DispatchInputsTest do
2 + use ExUnit.Case, async: true
3 +
4 + alias GitGud.Workflows.DispatchInputs
5 + alias GitGud.Workflows.Yaml
6 +
7 + defp doc(inputs_yaml) do
8 + {:ok, doc} =
9 + Yaml.parse("""
10 + on:
11 + workflow_dispatch:
12 + inputs:
13 + #{inputs_yaml}
14 + jobs:
15 + a:
16 + steps: [{run: "true"}]
17 + """)
18 +
19 + doc
20 + end
21 +
22 + @inputs """
23 + environment:
24 + description: Where to
25 + type: choice
26 + options: [staging, production]
27 + default: staging
28 + dry_run:
29 + type: boolean
30 + default: true
31 + verbose:
32 + type: boolean
33 + replicas:
34 + type: number
35 + reason:
36 + required: true
37 + note:
38 + description: free text
39 + target:
40 + type: environment
41 + """
42 +
43 + test "defaults fill what isn't given; values are strings" do
44 + assert {:ok, inputs} = DispatchInputs.resolve(doc(@inputs), %{"reason" => "x"})
45 +
46 + assert inputs == %{
47 + "environment" => "staging",
48 + "dry_run" => "true",
49 + "verbose" => "false",
50 + "replicas" => "",
51 + "reason" => "x",
52 + "note" => "",
53 + "target" => ""
54 + }
55 + end
56 +
57 + test "given values win, booleans and numbers normalised" do
58 + given = %{
59 + "reason" => "x",
60 + "environment" => "production",
61 + "dry_run" => "FALSE",
62 + "verbose" => true,
63 + "replicas" => " 3 ",
64 + "target" => "prod"
65 + }
66 +
67 + assert {:ok, inputs} = DispatchInputs.resolve(doc(@inputs), given)
68 + assert inputs["environment"] == "production"
69 + assert inputs["dry_run"] == "false"
70 + assert inputs["verbose"] == "true"
71 + assert inputs["replicas"] == "3"
72 + assert inputs["target"] == "prod"
73 + end
74 +
75 + test "an undeclared input is refused" do
76 + assert {:error, [{"colour", "is not an input of this workflow"}]} =
77 + DispatchInputs.resolve(doc(@inputs), %{"reason" => "x", "colour" => "red"})
78 + end
79 +
80 + test "a required input with no default must be given" do
81 + assert {:error, [{"reason", "is required"}]} = DispatchInputs.resolve(doc(@inputs), %{})
82 + end
83 +
84 + test "a required input with a default needn't be" do
85 + doc = doc(" level:\n required: true\n default: info\n")
86 + assert {:ok, %{"level" => "info"}} = DispatchInputs.resolve(doc, %{})
87 + end
88 +
89 + test "a choice must be one of its options" do
90 + assert {:error, [{"environment", "must be one of: staging, production"}]} =
91 + DispatchInputs.resolve(doc(@inputs), %{"reason" => "x", "environment" => "dev"})
92 + end
93 +
94 + test "a boolean must be true or false" do
95 + assert {:error, [{"dry_run", "must be true or false"}]} =
96 + DispatchInputs.resolve(doc(@inputs), %{"reason" => "x", "dry_run" => "yes"})
97 + end
98 +
99 + test "a number must be numeric" do
100 + assert {:error, [{"replicas", "must be a number"}]} =
101 + DispatchInputs.resolve(doc(@inputs), %{"reason" => "x", "replicas" => "3x"})
102 +
103 + assert {:ok, %{"replicas" => "2.5"}} =
104 + DispatchInputs.resolve(doc(@inputs), %{"reason" => "x", "replicas" => "2.5"})
105 + end
106 +
107 + test "every error at once, by name" do
108 + assert {:error, errors} =
109 + DispatchInputs.resolve(doc(@inputs), %{"dry_run" => "no", "zz" => "1"})
110 +
111 + assert Enum.map(errors, &elem(&1, 0)) == ["dry_run", "reason", "zz"]
112 + assert DispatchInputs.describe(errors) =~ "input reason is required"
113 + end
114 +
115 + test "no declared inputs: nothing comes out, and nothing may go in" do
116 + {:ok, doc} = Yaml.parse("on: workflow_dispatch\njobs: {a: {steps: []}}\n")
117 + assert {:ok, %{}} = DispatchInputs.resolve(doc, %{})
118 + assert {:error, [{"x", _}]} = DispatchInputs.resolve(doc, %{"x" => "1"})
119 + end
120 +
121 + test "declared/1 reads a stored raw doc too" do
122 + assert %{"reason" => %{"required" => true}} = DispatchInputs.declared(doc(@inputs).raw)
123 + end
124 +end
modified test/git_gud_web/controllers/api/v1/workflow_controller_test.exs
+97 −1
@@ -42,12 +42,42 @@ defmodule GitGudWeb.Api.V1.WorkflowControllerTest do
42 42 - run: echo hi
43 43 """
44 44
45 + @with_inputs """
46 + name: deploy
47 + on:
48 + workflow_dispatch:
49 + inputs:
50 + environment:
51 + type: choice
52 + options: [staging, production]
53 + default: staging
54 + dry_run:
55 + type: boolean
56 + default: true
57 + replicas:
58 + type: number
59 + reason:
60 + required: true
61 + jobs:
62 + deploy:
63 + runs-on: ubuntu-latest
64 + steps:
65 + - run: echo ${{ inputs.environment }}
66 + """
67 +
45 68 setup do
46 69 if System.find_executable("git") == nil do
47 70 {:skip, "git not on PATH"}
48 71 else
49 72 {owner, repo} = repository_fixture()
50 − commit = commit_workflows(repo, [{"build.yml", @dispatchable}, {"push.yml", @push_only}])
73 +
74 + commit =
75 + commit_workflows(repo, [
76 + {"build.yml", @dispatchable},
77 + {"deploy.yml", @with_inputs},
78 + {"push.yml", @push_only}
79 + ])
80 +
51 81 git!(repo.disk_path, ["update-ref", "refs/heads/main", commit])
52 82 {:ok, owner: owner, repo: repo}
53 83 end
@@ -208,6 +238,72 @@ defmodule GitGudWeb.Api.V1.WorkflowControllerTest do
208 238 end
209 239 end
210 240
241 + describe "inputs" do
242 + defp dispatch_inputs(conn, repo, token, inputs) do
243 + conn
244 + |> put_req_header("content-type", "application/json")
245 + |> put_req_header("authorization", "token " <> token)
246 + |> post(dispatch_url(repo, "deploy.yml"), Jason.encode!(%{ref: "main", inputs: inputs}))
247 + end
248 +
249 + test "given values and defaults reach the run's event payload", %{
250 + conn: conn,
251 + owner: owner,
252 + repo: repo
253 + } do
254 + conn =
255 + dispatch_inputs(conn, repo, pat(owner, ["ci:write"]), %{
256 + "reason" => "hotfix",
257 + "replicas" => "3"
258 + })
259 +
260 + assert response(conn, 204) == ""
261 +
262 + assert [run] = dispatched_runs(repo)
263 +
264 + assert run.event_payload["inputs"] == %{
265 + "environment" => "staging",
266 + "dry_run" => "true",
267 + "replicas" => "3",
268 + "reason" => "hotfix"
269 + }
270 + end
271 +
272 + test "each rule answers 422 and starts nothing", %{conn: conn, owner: owner, repo: repo} do
273 + token = pat(owner, ["ci:write"])
274 +
275 + for {inputs, message} <- [
276 + {%{"reason" => "x", "colour" => "red"}, "colour"},
277 + {%{}, "reason is required"},
278 + {%{"reason" => "x", "environment" => "dev"}, "staging, production"},
279 + {%{"reason" => "x", "dry_run" => "maybe"}, "true or false"},
280 + {%{"reason" => "x", "replicas" => "many"}, "number"},
281 + {%{"reason" => 1}, "object of strings"},
282 + {["reason"], "object of strings"}
283 + ] do
284 + conn = dispatch_inputs(build_conn(), repo, token, inputs)
285 + assert json_response(conn, 422)["message"] =~ message
286 + end
287 +
288 + _ = conn
289 + assert dispatched_runs(repo) == []
290 + end
291 +
292 + test "a workflow without declared inputs refuses any", %{
293 + conn: conn,
294 + owner: owner,
295 + repo: repo
296 + } do
297 + conn =
298 + request_dispatch(conn, repo, "build.yml", pat(owner, ["ci:write"]), %{
299 + "ref" => "main",
300 + "inputs" => %{"x" => "1"}
301 + })
302 +
303 + assert json_response(conn, 422)["message"] =~ "input x"
304 + end
305 + end
306 +
211 307 # ── git plumbing (the repository is bare) ─────────────────────────────
212 308
213 309 defp commit_workflows(%Repository{disk_path: path}, files) do
added test/git_gud_web/live/workflow_dispatch_live_test.exs
+140 −0
@@ -0,0 +1,140 @@
1 +defmodule GitGudWeb.WorkflowDispatchLiveTest do
2 + @moduledoc """
3 + "Run workflow" on the Actions page: offered to writers for the default
4 + branch's `on: workflow_dispatch` files, and it starts a run with the
5 + inputs filled in.
6 + """
7 + use GitGudWeb.ConnCase, async: false
8 +
9 + import Phoenix.LiveViewTest
10 + import GitGud.AccountsFixtures
11 + import GitGud.ForgeFixtures
12 + import GitGud.WorkflowGitHelpers
13 + import Ecto.Query
14 +
15 + alias GitGud.Repo
16 + alias GitGud.Repositories.Storage
17 + alias GitGud.Workflows.WorkflowRun
18 +
19 + @moduletag :capture_log
20 +
21 + @deploy """
22 + name: deploy
23 + on:
24 + workflow_dispatch:
25 + inputs:
26 + environment:
27 + type: choice
28 + options: [staging, production]
29 + default: staging
30 + dry_run:
31 + type: boolean
32 + reason:
33 + required: true
34 + jobs:
35 + deploy:
36 + runs-on: self-hosted
37 + steps:
38 + - run: echo hi
39 + """
40 +
41 + @push_only """
42 + name: ci
43 + on: push
44 + jobs:
45 + build:
46 + runs-on: self-hosted
47 + steps:
48 + - run: echo hi
49 + """
50 +
51 + setup %{conn: conn} do
52 + {owner, repo} = repository_fixture(%{visibility: "public"})
53 +
54 + head =
55 + commit!(
56 + repo,
57 + %{
58 + ".forgejo/workflows/deploy.yml" => @deploy,
59 + ".forgejo/workflows/ci.yml" => @push_only
60 + },
61 + []
62 + )
63 +
64 + git!(repo.disk_path, ["update-ref", "refs/heads/main", head])
65 + repo = Repo.preload(repo, [:owner, :organization])
66 + path = "/r/#{Storage.repo_handle(repo)}/#{repo.name}/actions"
67 + {:ok, conn: conn, owner: owner, repo: repo, path: path}
68 + end
69 +
70 + test "only the dispatchable workflow gets a button, and only for a writer", %{
71 + conn: conn,
72 + owner: owner,
73 + path: path
74 + } do
75 + {:ok, view, _html} = live(log_in_user(conn, owner), path)
76 + assert has_element?(view, "#dispatchable-0 #run-workflow-0")
77 + refute has_element?(view, "#dispatchable-1")
78 +
79 + {:ok, anon, _html} = live(build_conn(), path)
80 + refute has_element?(anon, "#dispatchable-workflows")
81 +
82 + {:ok, other, _html} = live(log_in_user(build_conn(), user_fixture()), path)
83 + refute has_element?(other, "#dispatchable-workflows")
84 + end
85 +
86 + test "submitting the form starts a run with its inputs", %{
87 + conn: conn,
88 + owner: owner,
89 + repo: repo,
90 + path: path
91 + } do
92 + {:ok, view, _html} = live(log_in_user(conn, owner), path)
93 + view |> element("#run-workflow-0") |> render_click()
94 +
95 + assert has_element?(view, "#dispatch-form select#dispatch-input-environment")
96 + assert has_element?(view, "#dispatch-form input[type=checkbox]#dispatch-input-dry_run")
97 +
98 + view
99 + |> form("#dispatch-form", %{
100 + "dispatch" => %{"ref" => "main"},
101 + "inputs" => %{"environment" => "production", "dry_run" => "true", "reason" => "go"}
102 + })
103 + |> render_submit()
104 +
105 + assert [run] =
106 + Repo.all(
107 + from r in WorkflowRun,
108 + where: r.repository_id == ^repo.id and r.trigger == "workflow_dispatch"
109 + )
110 +
111 + assert run.triggered_by_id == owner.id
112 +
113 + assert run.event_payload["inputs"] == %{
114 + "environment" => "production",
115 + "dry_run" => "true",
116 + "reason" => "go"
117 + }
118 +
119 + assert_redirect(view, "#{path}/#{run.number}")
120 + end
121 +
122 + test "a missing required input is a flash, not a run", %{
123 + conn: conn,
124 + owner: owner,
125 + repo: repo,
126 + path: path
127 + } do
128 + {:ok, view, _html} = live(log_in_user(conn, owner), path)
129 + view |> element("#run-workflow-0") |> render_click()
130 +
131 + html =
132 + view
133 + |> form("#dispatch-form", %{"inputs" => %{"reason" => ""}})
134 + |> render_submit()
135 +
136 + assert html =~ "reason"
137 + assert has_element?(view, "#dispatch-form")
138 + assert Repo.aggregate(where(WorkflowRun, repository_id: ^repo.id), :count) == 0
139 + end
140 +end

Parents: 2cafbbf