Plan: needs results and outputs, status functions, skipped jobs
Status: ready to execute. Written to be carried out start to finish by
one agent without further questions. Do it after
workflow-runner-context.md if that plan is
not yet executed (both edit RunnerController.build_task/1; neither
depends on the other’s content).
Why
Multi-job workflows run here in the right order, but the information that flows between jobs is lost, and the jobs GitHub would run after a failure are cancelled instead:
-
needs.<id>.outputs.*andneeds.<id>.resultare always empty.RunnerController.build_needs/1isdefp build_needs(_job), do: %{}. Job outputs are collected (update_task/2→Workflows.set_outputs/2) and snapshotted onto the dependent when it is promoted (Workflows.resolve_dependents/1writesneeds_snapshot), then never sent. A deploy job reading${{ needs.build.outputs.image }}gets"". A job-levelif: needs.test.outputs.changed == 'true'is always false.History, because it decides how to fix it: needs were sent from the snapshot (commit 3aa2fef), and then emptied (ed9a7cd, “generateWorkflow injects a runs-on-less mock per need, which act skips on if: success()”). The diagnosis then was that act can’t run beside a mock job. But at the time
TaskNeed‘sresultandoutputsfield numbers were swapped relative to forgejo-runner’s proto (fixed later in c4d7080, “Match the runner protocol’s field numbers to forgejo-runner’s”), so every mock need arrived with resultunspecified— and that alone makessuccess()false. Forgejo’s own server sendsTask.needswith results and outputs (forgejo-runnerinternal/app/run/workflow.go,generateWorkflow: one mock job per need,Result= the enum name lowercased,RawNeedsset on the target job), and it works there. So the likely fix is simply to send the snapshot again. -
if: always()/failure()jobs never run after a failure.resolve_dependents/1cancels every dependent of a failed or cancelled job. GitHub runs a dependent whoseif:uses a status-check function (always(),failure(),cancelled()) and skips the others — that is how cleanup, notification and “report failure” jobs work. Here they show as cancelled and never execute. -
A job skipped by its own
if:is recorded as success.RunnerController.map_result(:RESULT_SKIPPED)returns"success". Its dependents then run as though it had produced its outputs, where GitHub would skip them; the UI shows a green job that did nothing. -
Dependents of a skipped job run.
WorkflowJob.satisfies_dependency?/1acceptsskipped. On GitHub a skipped need makessuccess()false, so dependents are skipped unless theirif:uses a status function. -
Job-level
continue-on-error: truedoesn’t stop a failure from failing the run or cancelling its dependents (maybe_complete_run/1,resolve_dependents/1read onlystatus).
Semantics to match (GitHub)
- A job with
needs:waits until every need is terminal. Then:- if every need concluded
success: the job is queued; its ownif:(defaultsuccess()) is evaluated by the runner. - otherwise (some need is
failure,cancelledorskipped): if the job’sif:contains a status-check function —always(),failure(),cancelled()(including negated,!cancelled()) — it is queued and the runner evaluates theif:with the realneedsresults; if not, the job is skipped (not cancelled).
- if every need concluded
needs.<id>.resultis one ofsuccess,failure,cancelled,skipped. A matrix job’s result is the worst leg’s (failure > cancelledsuccess > skipped); its outputs are the legs’ merged (GitHub: last-writer-wins, order undefined — keep today’s
Map.mergefold).- A job whose runner reports
RESULT_SKIPPEDconcludedskipped. continue-on-error: trueon a job (literaltrueor the string"true"; an expression is evaluated by the runner and can’t be seen here — treat any other value as false): if it fails, its dependents see it assuccessand it does not make the run fail. The job itself still shows as failed. (GitHub docs, “jobs.<job_id>.continue-on-error”.)- Run conclusion:
failureif any job failed without continue-on-error; elsecancelledif the run was cancelled; elsesuccess(skipped jobs don’t fail a run).
Where things are
| Needs to the runner | lib/git_gud_web/controllers/runner_controller.ex: build_needs/1 (and the comment above it), build_task/1; proto Runner.V1.TaskNeed (generated, lib/git_gud_web/protos/runner/v1/) |
| Results from the runner | same file, update_task/2, map_result/1, result_atom/1 |
| Dependency resolution | lib/git_gud/workflows.ex: update_job_status/3, resolve_dependents/1, aggregate_status/1, maybe_complete_run/1, maybe_fail_fast/1 |
| Job schema | lib/git_gud/workflows/workflow_job.ex: satisfies_dependency?/1, terminal?/1, needs_snapshot, definition (the job’s raw YAML, where if and continue-on-error are) |
| Payload comment that is now false | lib/git_gud/workflows/runner_payload.ex (says results reach the job via Task.needs) |
| Tests | test/git_gud/workflows/matrix_dispatch_test.exs, rerun_cancel_test.exs (driving update_job_status/3 and resolve_dependents/1 directly), test/git_gud_web/controllers/runner_controller_test.exs (fetch_context/3 — extend or add a variant returning the whole Task) |
Steps
1. Verify the needs fix against a real runner first
Restore build_needs/1 to send the snapshot:
defp build_needs(%WorkflowJob{needs_snapshot: snap}) when map_size(snap) > 0 do
Map.new(snap, fn {jid, %{"status" => s, "outputs" => o}} ->
{jid, %Runner.V1.TaskNeed{result: status_to_result_enum(s), outputs: o || %{}}}
end)
end
defp build_needs(_), do: %{}
with status_to_result_enum/1: success → :RESULT_SUCCESS, failure →
:RESULT_FAILURE, cancelled → :RESULT_CANCELLED, skipped →
:RESULT_SKIPPED. Check the TaskNeed field numbers in the generated
module against upstream (code.forgejo.org/forgejo/actions-proto-def,
runner/v1/messages.proto: outputs = 1, result = 2) — c4d7080 says
they match now; confirm.
Then run a two-job workflow (build sets an output; deploy has needs: build and echoes ${{ needs.build.outputs.x }} and
${{ needs.build.result }}) on a real forgejo-runner against a local or
dev gitgud. If deploy is still skipped, stop: revert build_needs/1
to %{}, write down what the runner logged, and report back instead of
continuing — the rest of this plan depends on needs reaching the runner.
If no runner is reachable from where you work, say so in the report and
continue: the protocol tests below still pin the bytes.
Fix the comment in runner_payload.ex and above build_needs/1 to say
what is true.
2. Record skipped as skipped
map_result(:RESULT_SKIPPED) → "skipped"; result_atom("skipped") →
:RESULT_SKIPPED. WorkflowJob.status_changeset/3 already accepts
skipped (@valid_statuses); check terminal?/1 (it includes skipped)
and finalize_steps/1 (a skipped job’s pending steps → skipped: add the
clause). Check the UI badge helpers (status_class/1 in
lib/git_gud_web/live/workflow_live/show.ex and index.ex,
lib/git_gud_web/live/ci_components.ex) render skipped neutrally.
3. Effective result and the dependency rule
In GitGud.Workflows add:
effective_result(job):"success"whenjob.status == "failure"and the job’sdefinition["continue-on-error"] in [true, "true"]; otherwisejob.status.status_function_if?(definition): whetherdefinition["if"]is a string matching~r/\b(always|failure|cancelled)\s*\(\s*\)/.
Rewrite the cond in resolve_dependents/1 per dependent, once every
need’s legs are terminal (WorkflowJob.terminal?/1):
- a referenced need doesn’t exist →
cancelled(unchanged); - every leg of every need has
effective_result == "success"→ snapshot andqueued(as today); - otherwise,
status_function_if?(dep.definition)→ snapshot andqueued; - otherwise →
update_job_status(dep, "skipped")— which recurses throughresolve_dependents/1, so a chain is skipped in turn.
While some need is not terminal, leave the dependent blocked (as today).
aggregate_status/1 becomes the worst effective_result of the legs
(failure > cancelled > success > skipped; all skipped → skipped). Remove
WorkflowJob.satisfies_dependency?/1 if nothing else uses it (grep).
4. Run conclusion
maybe_complete_run/1: a job counts as failed when status == "failure"
and not continue-on-error. Then: any failed → failure; else any
cancelled → cancelled if the run itself was cancelled, else failure
(a job cancelled by fail-fast still fails the run, as on GitHub); else
success. Today’s notify_run_webhook/1 is only called on success — leave
that to workflow-triggers.md, which fixes it.
5. Fail-fast and cancellation still behave
maybe_fail_fast/1 triggers on failure/cancelled: exclude a failure
whose job has continue-on-error (GitHub: continue-on-error legs don’t
trigger fail-fast). When a whole run is cancelled (Workflows.cancel/1),
blocked jobs become cancelled, not queued, even if they say always() —
GitHub would run them; note that as a known difference in a comment rather
than building it.
6. Docs
README CI list: needs outputs and results reach jobs; status-check
functions in a job’s if: work after a failure; skipped jobs and
continue-on-error behave as on GitHub; an always() job does not run
after a manual cancel.
Tests
- Protocol: a job with a
needs_snapshotof%{"build" => %{"status" => "success", "outputs" => %{"x" => "1"}}}fetched through FetchTask hastask.needs["build"].result == :RESULT_SUCCESSand outputs%{"x" => "1"}; encode theTaskNeedand check the bytes are laid out withoutputsas field 1 andresultas field 2 (copy the approach of the existing upstream-numbering tests intest/git_gud_web/protos/runner_wire_test.exs). UpdateTaskwithRESULT_SKIPPED→ jobskipped.- Graph (drive with
update_job_status/3, asrerun_cancel_test.exsdoes, on a run created by dispatching a YAML doc):buildfails →deploy(noif) skipped;notify(if: always()) queued with snapshot resultfailure;report(if: failure()) queued; run concludesfailure;buildskipped → dependent without status function skipped;- chain a → b → c with a failed: b and c skipped;
buildwithcontinue-on-error: truefails →deployqueued, runsuccess,buildstillfailure;- a matrix need with one failed leg: snapshot status
failure.
Done when
mix testpasses; no new compile warnings (comparemix compile --forcewarning lists before/after);mix formatonly touched files.- One commit in the repository’s message style, stating whether step 1 was verified on a real runner.
- Not pushed.