From 2e0aa7ec741dbfb778ff2db20d3a7954bd731712 Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Thu, 16 Jul 2026 14:28:53 -0600 Subject: [PATCH] fix(actions): make `cancelled()` work in job `if` evaluation (#38495) Fix #38485 #37478 moved `if` evaluation from runner to server. But `NewInterpeter` always provides a nil `JobContext` for the evaluator, which makes [`cancelled()`](https://gitea.com/gitea/runner/src/commit/ad967330a8788c9b8ab723abbc1a86d53c3bc5e6/act/exprparser/functions.go#L299) panic on `Job.Status` because `Job` is a nil pointer. --- modules/actions/jobparser/interpeter.go | 11 +++-- modules/actions/jobparser/model_test.go | 58 +++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/modules/actions/jobparser/interpeter.go b/modules/actions/jobparser/interpeter.go index 37defeff7a0..9294c717a13 100644 --- a/modules/actions/jobparser/interpeter.go +++ b/modules/actions/jobparser/interpeter.go @@ -57,9 +57,14 @@ func NewInterpeter( } ee := &exprparser.EvaluationEnvironment{ - Github: gitCtx, - Env: nil, // no need - Job: nil, // no need + Github: gitCtx, + Env: nil, // no need + // Job must be non-nil because cancelled() dereferences Job.Status unconditionally. + // See: https://gitea.com/gitea/runner/src/commit/ad967330a8788c9b8ab723abbc1a86d53c3bc5e6/act/exprparser/functions.go#L299 + // TODO: The empty JobContext.Status is right for now because Gitea never checks `if` condition when the workflow run is cancelled. + // This is an implementation gap in Gitea Actions. When a workflow run is cancelled, Gitea should check the job's `if` condition, + // and if the condition is met (e.g. `if: ${{ cancelled() }}` ), the job should be executed rather than cancelled. + Job: &model.JobContext{}, Steps: nil, // no need Runner: nil, // no need Secrets: nil, // no need diff --git a/modules/actions/jobparser/model_test.go b/modules/actions/jobparser/model_test.go index ff85e912afd..e3b2d2b95f9 100644 --- a/modules/actions/jobparser/model_test.go +++ b/modules/actions/jobparser/model_test.go @@ -464,3 +464,61 @@ func TestParseMappingNode(t *testing.T) { }) } } + +func TestEvaluateJobIfExpression(t *testing.T) { + kases := []struct { + name string + ifCond string + needResult string + expected bool + }{ + {name: "empty need success", ifCond: "${{ 1 == 1 }}", needResult: "success", expected: true}, + {name: "always", ifCond: "${{ always() }}", needResult: "failure", expected: true}, + {name: "failure true", ifCond: "${{ failure() }}", needResult: "failure", expected: true}, + {name: "failure false", ifCond: "${{ failure() }}", needResult: "success", expected: false}, + {name: "success true", ifCond: "${{ success() }}", needResult: "success", expected: true}, + // cancelled() is always false on the server: a cancelled run never evaluates a blocked job's `if:` + {name: "cancelled", ifCond: "${{ cancelled() }}", needResult: "success", expected: false}, + {name: "not cancelled or failure", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "success", expected: true}, + {name: "not cancelled or failure, need failed", ifCond: "${{ !(cancelled() || failure()) }}", needResult: "failure", expected: false}, + } + for _, kase := range kases { + t.Run(kase.name, func(t *testing.T) { + content := strings.ReplaceAll(` +name: test +on: push +jobs: + job1: + runs-on: ubuntu-latest + steps: + - run: echo job1 + job2: + runs-on: ubuntu-latest + needs: [job1] + if: IF_COND + steps: + - run: echo job2 +`, "IF_COND", kase.ifCond) + + workflows, err := Parse([]byte(content)) + require.NoError(t, err) + + var job2 *Job + for _, wf := range workflows { + if id, job := wf.Job(); id == "job2" { + job2 = job + } + } + require.NotNil(t, job2) + + // mirrors findJobNeedsAndFillJobResults: the needs' results plus a self entry carrying Needs + results := map[string]*JobResult{ + "job1": {Result: kase.needResult}, + "job2": {Needs: []string{"job1"}}, + } + got, err := EvaluateJobIfExpression("job2", job2, map[string]any{}, results, nil, nil) + require.NoError(t, err) + assert.Equal(t, kase.expected, got) + }) + } +}