11.0 KiB · text History 876aa0b

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:

  1. needs.<id>.outputs.* and needs.<id>.result are always empty. RunnerController.build_needs/1 is defp 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/1 writes needs_snapshot), then never sent. A deploy job reading ${{ needs.build.outputs.image }} gets "". A job-level if: 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‘s result and outputs field 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 result unspecified — and that alone makes success() false. Forgejo’s own server sends Task.needs with results and outputs (forgejo-runner internal/app/run/workflow.go, generateWorkflow: one mock job per need, Result = the enum name lowercased, RawNeeds set on the target job), and it works there. So the likely fix is simply to send the snapshot again.

  2. if: always() / failure() jobs never run after a failure. resolve_dependents/1 cancels every dependent of a failed or cancelled job. GitHub runs a dependent whose if: 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.

  3. 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.

  4. Dependents of a skipped job run. WorkflowJob.satisfies_dependency?/1 accepts skipped. On GitHub a skipped need makes success() false, so dependents are skipped unless their if: uses a status function.

  5. Job-level continue-on-error: true doesn’t stop a failure from failing the run or cancelling its dependents (maybe_complete_run/1, resolve_dependents/1 read only status).

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 own if: (default success()) is evaluated by the runner.
    • otherwise (some need is failure, cancelled or skipped): if the job’s if: contains a status-check function — always(), failure(), cancelled() (including negated, !cancelled()) — it is queued and the runner evaluates the if: with the real needs results; if not, the job is skipped (not cancelled).
  • needs.<id>.result is one of success, failure, cancelled, skipped. A matrix job’s result is the worst leg’s (failure > cancelled

    success > skipped); its outputs are the legs’ merged (GitHub: last-writer-wins, order undefined — keep today’s Map.merge fold).

  • A job whose runner reports RESULT_SKIPPED concluded skipped.
  • continue-on-error: true on a job (literal true or 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 as success and it does not make the run fail. The job itself still shows as failed. (GitHub docs, “jobs.<job_id>.continue-on-error”.)
  • Run conclusion: failure if any job failed without continue-on-error; else cancelled if the run was cancelled; else success (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" when job.status == "failure" and the job’s definition["continue-on-error"] in [true, "true"]; otherwise job.status.
  • status_function_if?(definition): whether definition["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):

  1. a referenced need doesn’t exist → cancelled (unchanged);
  2. every leg of every need has effective_result == "success" → snapshot and queued (as today);
  3. otherwise, status_function_if?(dep.definition) → snapshot and queued;
  4. otherwise → update_job_status(dep, "skipped") — which recurses through resolve_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_snapshot of %{"build" => %{"status" => "success", "outputs" => %{"x" => "1"}}} fetched through FetchTask has task.needs["build"].result == :RESULT_SUCCESS and outputs %{"x" => "1"}; encode the TaskNeed and check the bytes are laid out with outputs as field 1 and result as field 2 (copy the approach of the existing upstream-numbering tests in test/git_gud_web/protos/runner_wire_test.exs).
  • UpdateTask with RESULT_SKIPPED → job skipped.
  • Graph (drive with update_job_status/3, as rerun_cancel_test.exs does, on a run created by dispatching a YAML doc):
    • build fails → deploy (no if) skipped; notify (if: always()) queued with snapshot result failure; report (if: failure()) queued; run concludes failure;
    • build skipped → dependent without status function skipped;
    • chain a → b → c with a failed: b and c skipped;
    • build with continue-on-error: true fails → deploy queued, run success, build still failure;
    • a matrix need with one failed leg: snapshot status failure.

Done when

  • mix test passes; no new compile warnings (compare mix compile --force warning lists before/after); mix format only touched files.
  • One commit in the repository’s message style, stating whether step 1 was verified on a real runner.
  • Not pushed.