Plan: the triggers that don’t fire yet
Status: ready to execute. Written to be carried out start to finish by
one agent without further questions. Execute
workflow-runner-context.md first: inputs and
the other per-event data reach a job through the event payload it adds
(workflow_runs.event_payload, GitGud.Workflows.EventPayload). One
commit per numbered part (A–F); each is independent of the others.
Why
Yaml.matches?/3 will say yes to any event name, but only three are ever
asked about: push (Repositories.Workers.CommitBackfill.dispatch_workflows/5),
pull_request (Workflows.dispatch_pull_request/3, types opened /
synchronize / reopened) and workflow_dispatch (Workflows.dispatch_manual/4,
API only). Everything below is accepted in a workflow file and then never
happens, with no error anywhere:
- A.
[skip ci]is ignored — a commit saying[skip ci]runs everything. - B.
workflow_dispatchinputs are thrown away. The API takesinputsand drops them (GitGudWeb.Api.V1.WorkflowController, module doc: “accepted for compatibility and not yet passed to the run”), so${{ inputs.environment }}is empty; declared defaults are lost too (the payload sent to the runner replaceson:, see the context plan). There is also no “Run workflow” button: the only way in is the API. - C.
schedulenever fires. Nothing evaluateson: schedule: - cron:. - D.
createanddeletenever fire.CommitBackfill.perform/1returns before dispatching when a ref is deleted, and a new ref is dispatched only aspush. - E.
pull_requestclosed,edited,labeled,unlabelednever fire — the commonest beingclosed(tear down a preview environment, or “on merged” jobs usinggithub.event.pull_request.merged). - F.
workflow_runnever fires, and theworkflow_runwebhook fires only for runs that succeed:Workflows.maybe_complete_run/1callsnotify_run_webhook/1in its success branch only (andcancel/1calls it), never for a failed run.
Not planned: release (gitgud has no releases), pull_request_target
(in workflow-fork-pull-requests.md),
issues / issue_comment / pull_request_review* (possible later, same
shape as E; not asked for).
Semantics to match (GitHub, “Events that trigger workflows”)
- Skip instructions: a
pushorpull_requestrun is not created when the head commit’s message contains any of[skip ci],[ci skip],[no ci],[skip actions],[actions skip](case-insensitive), or has a trailer lineskip-checks: true(alsoskip-checks:true). For a push with several commits GitHub looks at the head commit only. Not applied toworkflow_dispatch,schedule, or the other events. - workflow_dispatch inputs: declared under
on.workflow_dispatch.inputs.<id>withdescription,required,default,type(stringdefault,boolean,choicewithoptions,number,environment). The API (GitHub/Gitea) answers 422 for an input that isn’t declared, a missingrequiredinput with no default, achoicevalue not inoptions, a non-booleanboolean, a non-numericnumber. Undeclared-but-defaulted inputs take theirdefault. Values reach the job asinputs.<id>andgithub.event.inputs.<id>(strings;inputs.<id>of type boolean is a boolean in GitHub — keep strings in the payload, act converts per declared type). - schedule:
on: schedule: [{cron: "…"}, …], five-field POSIX cron, UTC. Runs on the default branch’s latest commit, using that commit’s workflow files;github.event.scheduleis the cron string that fired;github.refis the default branch. GitHub’s minimum interval is 5 minutes — enforce it (skip a schedule whose cron fires more often, log). - create / delete: fire when a branch or tag is created/deleted. No
branches/tagsfilters (GitHub ignores them for these events).createruns the created ref’s workflows at its commit;deleteruns the default branch’s workflows (the ref is gone);github.event.refis the short name,github.event.ref_typeisbranch/tag. A push that creates a ref also firespush. - pull_request closed / edited / labeled / unlabeled:
types:must name them (they aren’t defaults).closedfires on close and on merge;github.event.pull_request.mergedtells them apart.edited= title or body changed;labeled/unlabeledcarrygithub.event.label.name. Workflows are read from the PR head. A closed PR is dispatched once, at the moment it closes (dispatch_pull_request/3refuses non-open PRs today — theclosedaction is the exception). - workflow_run:
on: workflow_run: {workflows: [<name>…], types: [completed | requested | in_progress], branches: […]}. Fires when a run of a workflow named inworkflows(by itsname:) reaches that state;branchesfilters the triggering run’s head branch. Runs the default branch’s workflows at its head.github.event.workflow_runhasid,name,conclusion,head_branch,head_sha,event,run_number. GitHub limits chains to three levels: a run triggered byworkflow_runat depth 3 doesn’t trigger further.
Where things are
| Matching | lib/git_gud/workflows/yaml.ex matches?/3, type_match?/3 (pull_request types), ref_match?/4 |
| Dispatch | lib/git_gud/workflows.ex dispatch/2, dispatch_pull_request/3, dispatch_manual/4, create_matching_runs/5, discover_workflows/2, maybe_complete_run/1, notify_run_webhook/1, run_webhook_payload/2, cancel/1 |
| Push worker | lib/git_gud/repositories/workers/commit_backfill.ex perform/1, dispatch_workflows/5, maybe_hex/1, zero?/1 |
| PR lifecycle | lib/git_gud/pull_requests.ex set_state/2, merge!/3, update_pull_request/2, attach_label/2, detach_label/2; worker lib/git_gud/workflows/workers/pull_request_dispatch.ex (enqueue/3) |
| Dispatch API | lib/git_gud_web/controllers/api/v1/workflow_controller.ex |
| Workflow UI | lib/git_gud_web/live/workflow_live/index.ex (run list), show.ex |
| Cron | Oban 2.22.1 (config/config.exs: Oban.Plugins.Cron, queue ci); Oban.Cron.Expression has parse/1, now?/2, next_at/2 — it also accepts @daily-style nicknames and @reboot (refuse @reboot) |
| Event payloads | GitGud.Workflows.EventPayload (from the context plan) |
| Tests to copy | test/git_gud/workflows/pull_request_dispatch_test.exs (on-disk repo, Oban.drain_queue(queue: :ci), perform_job(CommitBackfill, …)), path_dispatch_test.exs, test/git_gud_web/controllers/api/ for the dispatch API |
Steps
A. Skip instructions
Workflows.skip_ci?(message) (public, pure). In dispatch/2, for
trigger in ["push", "pull_request"], read the head commit
(Repositories.get_commit/2 — discover_workflows/2 already loads it;
refactor so it’s read once) and return {:ok, []} when it says skip. Same
in dispatch_pull_request/3. Log at info. Tests: each marker, the
trailer, case-insensitivity, a marker in a non-head commit of the push
doesn’t skip, workflow_dispatch is not skipped.
B. workflow_dispatch inputs, and a button
GitGud.Workflows.DispatchInputs.resolve(doc, given :: map)→{:ok, inputs}(all declared inputs, defaults applied, values as strings: booleans"true"/"false") or{:error, [{name, reason}]}per the rules above.dispatch_manual/4→dispatch_manual(repo, workflow, ref, opts)withopts[:inputs],opts[:triggered_by_id](update both callers). Afterfind_workflow/3, resolve inputs; on error return{:error, {:invalid_inputs, errors}}. Put them in the event payload’sinputs.- API: pass
params["inputs"](must be a map of strings; anything else 422); map{:invalid_inputs, …}to 422 with the messages; update the module doc. - UI: on the workflow runs page, for users who may dispatch
(
Organizations.can?(user, repo, :write)), a “Run workflow” control per workflow file that declaresworkflow_dispatchon the default branch (list them withdiscover_workflows/2on the default head): a form with the ref (default branch preselected; list branches) and one<.input>per declared input (select forchoice, checkbox forboolean). Submit →dispatch_manual/4→ navigate to the new run. Follow the project’s LiveView/form rules in AGENTS.md (to_form,<.input>, DOM ids). - Tests:
DispatchInputsunit tests for every rule; API 422 cases and a 204 whose run’sevent_payload["inputs"]holds defaults + given values; LiveView: the button appears only for a dispatchable workflow, and submitting creates a run.
C. schedule
- Migration
create_workflow_schedules:repository_id(FK, on delete cascade),workflow_path,cron,last_fired_at :utc_datetime, unique(repository_id, workflow_path, cron). GitGud.Workflows.Schedules.sync(repo): discover workflows at the default branch head; upsert one row peron.schedule[].cron(parse withOban.Cron.Expression.parse/1; skip invalid crons,@reboot, and crons firing more often than every 5 minutes — check by takingnext_at/2twice over a day’s worth of steps and looking for a gap under 300 s — with a warning); delete rows no longer present. Call it fromdispatch/2when the pushed ref isrefs/heads/<default_branch>(any trigger), and wherever the default branch is changed (grepdefault_branchinlib/git_gud/repositories.exfor the update path).- Worker
GitGud.Workflows.Workers.ScheduleTick(queueci), added to the Oban cron plugin* * * * *: for each row whose cron is due at the current UTC minute (now?/2) and whoselast_fired_atisn’t this minute, setlast_fired_at(in the same statement as the check, to be safe with two nodes:UPDATE … WHERE last_fired_at IS DISTINCT FROM $minute RETURNING), then dispatch: re-discover the workflow at the default head, and if it still declares that cron, create a run withtrigger: "schedule",head_ref: refs/heads/<default>, payload{"schedule" => cron}. Archived repositories (repositories.archived) don’t run. Yaml.matches?/3needs nothing:scheduleconfig is a list, which already matches as “no filters”.- Tests: sync creates/removes rows on default-branch pushes and ignores
other branches; the tick fires a due schedule once (twice in the same
minute → once), not a non-due one; a workflow edited to drop the cron
no longer fires; a
* * * * *cron is refused (<5 min).
D. create and delete
In CommitBackfill.perform/1:
- deleted ref (
zero?(new_sha)): after the webhook, dispatch"delete"with the default branch head (resolve it; none → skip), ctx%{}, payload{ref: short, ref_type}. - created ref (
old_shanil or zero): after the push dispatch, also dispatch"create"at the new sha, ctx%{}.Yaml.matches?/3must not applybranches/tagsto these: with ctx%{}ref_match?/4already passes. Tests: creating a branch firescreate(andpush); deleting firesdeletefrom the default branch’s workflow file; a workflow with onlypush:doesn’t fire on either.
E. pull_request closed / edited / labeled / unlabeled
dispatch_pull_request/3: allow stateclosed/mergedwhen the action is"closed"; everything else still requiresopen. Cross-repo/federated still refused.Yaml.type_match?/3already compares againsttypes:; the default list stays opened/synchronize/reopened.- Call sites (each
PullRequestDispatch.enqueue/3, which never fails the caller):set_state(pr, "closed")on success →"closed";merge!/3on success →"closed"(payloadmerged: true);update_pull_request/2when title or body changed →"edited";attach_label/2/detach_label/2→"labeled"/"unlabeled"with the label in the payload (extendenqueue/3with an optionalextramap stored in the job args and merged into the payload). - The worker skips a job whose PR head moved since enqueue; keep that for
synchronizeonly — aclosed/labeledevent is still meaningful. - Tests: types
[closed]fires on close and on merge (payloadmergeddiffers); default types don’t fire on close;labeledcarries the label name; editing the title firesedited.
F. workflow_run, and the webhook
- Fix the webhook first:
maybe_complete_run/1callsnotify_run_webhook/1in its failure branch too (one commit-sized change with a test). - Add
workflow_runs.trigger_depth :integer default 0(migration). - When a run becomes terminal (
maybe_complete_run/1,cancel/1) and when it is created (requested) / first claimed (in_progress), enqueueGitGud.Workflows.Workers.WorkflowRunDispatch(queueci) with the run id and the type — unless the run’strigger_depth >= 3. - The worker: discover the default branch’s workflows; match with
Yaml.matches?(doc, "workflow_run", ctx)where ctx carries:action(the type) — add aworkflow_runclause totype_match?/3(default types:completedandrequested, as on GitHub) — and filter onworkflows:containing the triggering run’sworkflow_nameand onbranches:against its head branch (ref_match?/4with%{branch: …}). Create runs withtrigger: "workflow_run",trigger_depth: parent + 1, payload{action, workflow_run: {...}}. - Tests: a
cirun completing fires adeployworkflow withworkflows: [ci]; a failingcifires it too (conclusion in payload); a run of another workflow doesn’t; depth 3 stops; the failure webhook is delivered (assert the enqueuedDeliverWebhookjob).
Docs
README CI list: one line per trigger now supported, with the rules above
that differ from intuition (schedule runs the default branch; delete
runs the default branch’s workflows; 5-minute minimum; skip markers).
Done when
mix testpasses; no new compile warnings (comparemix compile --forcewarning lists before/after);mix formatonly touched files; migrations generated withmix ecto.gen.migrationand run on dev and test.- One commit per part A–F, in the repository’s message style.
- Not pushed.