14.1 KiB · text History b868907

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_dispatch inputs are thrown away. The API takes inputs and 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 replaces on:, see the context plan). There is also no “Run workflow” button: the only way in is the API.
  • C. schedule never fires. Nothing evaluates on: schedule: - cron:.
  • D. create and delete never fire. CommitBackfill.perform/1 returns before dispatching when a ref is deleted, and a new ref is dispatched only as push.
  • E. pull_request closed, edited, labeled, unlabeled never fire — the commonest being closed (tear down a preview environment, or “on merged” jobs using github.event.pull_request.merged).
  • F. workflow_run never fires, and the workflow_run webhook fires only for runs that succeed: Workflows.maybe_complete_run/1 calls notify_run_webhook/1 in its success branch only (and cancel/1 calls 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 push or pull_request run 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 line skip-checks: true (also skip-checks:true). For a push with several commits GitHub looks at the head commit only. Not applied to workflow_dispatch, schedule, or the other events.
  • workflow_dispatch inputs: declared under on.workflow_dispatch.inputs.<id> with description, required, default, type (string default, boolean, choice with options, number, environment). The API (GitHub/Gitea) answers 422 for an input that isn’t declared, a missing required input with no default, a choice value not in options, a non-boolean boolean, a non-numeric number. Undeclared-but-defaulted inputs take their default. Values reach the job as inputs.<id> and github.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.schedule is the cron string that fired; github.ref is 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/tags filters (GitHub ignores them for these events). create runs the created ref’s workflows at its commit; delete runs the default branch’s workflows (the ref is gone); github.event.ref is the short name, github.event.ref_type is branch/tag. A push that creates a ref also fires push.
  • pull_request closed / edited / labeled / unlabeled: types: must name them (they aren’t defaults). closed fires on close and on merge; github.event.pull_request.merged tells them apart. edited = title or body changed; labeled/unlabeled carry github.event.label.name. Workflows are read from the PR head. A closed PR is dispatched once, at the moment it closes (dispatch_pull_request/3 refuses non-open PRs today — the closed action is the exception).
  • workflow_run: on: workflow_run: {workflows: [<name>…], types: [completed | requested | in_progress], branches: […]}. Fires when a run of a workflow named in workflows (by its name:) reaches that state; branches filters the triggering run’s head branch. Runs the default branch’s workflows at its head. github.event.workflow_run has id, name, conclusion, head_branch, head_sha, event, run_number. GitHub limits chains to three levels: a run triggered by workflow_run at 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

  1. 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.
  2. dispatch_manual/4 → dispatch_manual(repo, workflow, ref, opts) with opts[:inputs], opts[:triggered_by_id] (update both callers). After find_workflow/3, resolve inputs; on error return {:error, {:invalid_inputs, errors}}. Put them in the event payload’s inputs.
  3. 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.
  4. UI: on the workflow runs page, for users who may dispatch (Organizations.can?(user, repo, :write)), a “Run workflow” control per workflow file that declares workflow_dispatch on the default branch (list them with discover_workflows/2 on the default head): a form with the ref (default branch preselected; list branches) and one <.input> per declared input (select for choice, checkbox for boolean). Submit → dispatch_manual/4 → navigate to the new run. Follow the project’s LiveView/form rules in AGENTS.md (to_form, <.input>, DOM ids).
  5. Tests: DispatchInputs unit tests for every rule; API 422 cases and a 204 whose run’s event_payload["inputs"] holds defaults + given values; LiveView: the button appears only for a dispatchable workflow, and submitting creates a run.

C. schedule

  1. Migration create_workflow_schedules: repository_id (FK, on delete cascade), workflow_path, cron, last_fired_at :utc_datetime, unique (repository_id, workflow_path, cron).
  2. GitGud.Workflows.Schedules.sync(repo): discover workflows at the default branch head; upsert one row per on.schedule[].cron (parse with Oban.Cron.Expression.parse/1; skip invalid crons, @reboot, and crons firing more often than every 5 minutes — check by taking next_at/2 twice 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 from dispatch/2 when the pushed ref is refs/heads/<default_branch> (any trigger), and wherever the default branch is changed (grep default_branch in lib/git_gud/repositories.ex for the update path).
  3. Worker GitGud.Workflows.Workers.ScheduleTick (queue ci), added to the Oban cron plugin * * * * *: for each row whose cron is due at the current UTC minute (now?/2) and whose last_fired_at isn’t this minute, set last_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 with trigger: "schedule", head_ref: refs/heads/<default>, payload {"schedule" => cron}. Archived repositories (repositories.archived) don’t run.
  4. Yaml.matches?/3 needs nothing: schedule config is a list, which already matches as “no filters”.
  5. 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_sha nil or zero): after the push dispatch, also dispatch "create" at the new sha, ctx %{}. Yaml.matches?/3 must not apply branches/tags to these: with ctx %{} ref_match?/4 already passes. Tests: creating a branch fires create (and push); deleting fires delete from the default branch’s workflow file; a workflow with only push: doesn’t fire on either.

E. pull_request closed / edited / labeled / unlabeled

  • dispatch_pull_request/3: allow state closed/merged when the action is "closed"; everything else still requires open. Cross-repo/federated still refused.
  • Yaml.type_match?/3 already compares against types:; 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!/3 on success → "closed" (payload merged: true); update_pull_request/2 when title or body changed → "edited"; attach_label/2 / detach_label/2 → "labeled" / "unlabeled" with the label in the payload (extend enqueue/3 with an optional extra map stored in the job args and merged into the payload).
  • The worker skips a job whose PR head moved since enqueue; keep that for synchronize only — a closed/labeled event is still meaningful.
  • Tests: types [closed] fires on close and on merge (payload merged differs); default types don’t fire on close; labeled carries the label name; editing the title fires edited.

F. workflow_run, and the webhook

  1. Fix the webhook first: maybe_complete_run/1 calls notify_run_webhook/1 in its failure branch too (one commit-sized change with a test).
  2. Add workflow_runs.trigger_depth :integer default 0 (migration).
  3. When a run becomes terminal (maybe_complete_run/1, cancel/1) and when it is created (requested) / first claimed (in_progress), enqueue GitGud.Workflows.Workers.WorkflowRunDispatch (queue ci) with the run id and the type — unless the run’s trigger_depth >= 3.
  4. The worker: discover the default branch’s workflows; match with Yaml.matches?(doc, "workflow_run", ctx) where ctx carries :action (the type) — add a workflow_run clause to type_match?/3 (default types: completed and requested, as on GitHub) — and filter on workflows: containing the triggering run’s workflow_name and on branches: against its head branch (ref_match?/4 with %{branch: …}). Create runs with trigger: "workflow_run", trigger_depth: parent + 1, payload {action, workflow_run: {...}}.
  5. Tests: a ci run completing fires a deploy workflow with workflows: [ci]; a failing ci fires it too (conclusion in payload); a run of another workflow doesn’t; depth 3 stops; the failure webhook is delivered (assert the enqueued DeliverWebhook job).

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 test passes; no new compile warnings (compare mix compile --force warning lists before/after); mix format only touched files; migrations generated with mix ecto.gen.migration and run on dev and test.
  • One commit per part A–F, in the repository’s message style.
  • Not pushed.