From e8befe026853a90a9efca559ca9f9cac8b41fc88 Mon Sep 17 00:00:00 2001 From: bircni Date: Sat, 18 Jul 2026 17:08:09 +0200 Subject: [PATCH] fix(actions): coerce workflow_dispatch boolean inputs to native types (#38472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `workflow_dispatch` job that has `needs:` **and** an `if:` comparing a boolean input against a boolean literal never runs — it stays `Blocked` forever even though its needs succeed. Minimal repro: ```yaml on: workflow_dispatch: inputs: deploy: type: boolean default: true jobs: build: runs-on: ubuntu-latest steps: [{ run: echo build }] deploy: needs: build if: ${{ inputs.deploy == true }} # never true on Gitea runs-on: ubuntu-latest steps: [{ run: echo deploy }] ``` On GitHub this runs; on Gitea `deploy` is stuck. Jobs **without** `needs` are unaffected, which makes it look like a matrix/`needs` bug — it isn't. ## Root cause `workflow_dispatch` stores boolean inputs as the strings `"true"`/`"false"` (`ctx.FormBool` → `strconv.FormatBool` in the web path, plain strings in the API path). Since #37478 (shipped in **v1.27.0**), `evaluateJobIf` runs **server-side** as part of the job-emitter resolver passes. For a `needs`-gated job the server therefore evaluates `inputs.deploy == true` with `inputs.deploy` being the string `"true"`; comparing a string to a boolean coerces to `NaN == 1` → `false`, so the job is never dispatched. Jobs without `needs` skip this server-side gate and are evaluated by the runner, which coerces the string to a real bool — that's why they keep working, and why the same input yields opposite results in the two paths. ## Fix Normalize `type: boolean` dispatch inputs to native JSON booleans in the shared `DispatchActionWorkflow` path, so it covers both the web and API entry points in one place. The coerced value flows into both the run's `EventPayload` (read back by the server-side `if` evaluation and by the runner) and the creation-time parse, so all consumers agree. This matches GitHub, whose `inputs` context "preserves Boolean values as Booleans instead of converting them to strings", and mirrors the native JSON types Gitea already sends for `workflow_call`. Only booleans are coerced; other input types are left untouched, and a value that is already a bool (JSON API request) passes through. Note: this makes dispatch payloads carry native booleans, which the runner consumes correctly as of gitea/runner#1087 (it accepts a native bool and keeps the string fallback) — the same expectation `workflow_call` already relies on. Fixes https://github.com/go-gitea/gitea/issues/38466 --- services/actions/workflow.go | 22 ++++++++++++ services/actions/workflow_test.go | 41 +++++++++++++++++++++++ tests/integration/actions_trigger_test.go | 8 ++--- 3 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 services/actions/workflow_test.go diff --git a/services/actions/workflow.go b/services/actions/workflow.go index 73a805164b7..17fce34ba1a 100644 --- a/services/actions/workflow.go +++ b/services/actions/workflow.go @@ -144,6 +144,11 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re if err = processInputs(workflowDispatch, inputsWithDefaults); err != nil { return 0, err } + // The dispatch callbacks fill boolean inputs as the strings "true"/"false". Normalize them to + // native JSON booleans so `type: boolean` inputs match GitHub, whose `inputs` context preserves + // booleans as booleans. Without this, a server-side needs-gated job `if: inputs.flag == true` + // evaluates against the string "true" and never matches, leaving the job blocked forever. + coerceDispatchInputTypes(workflowDispatch, inputsWithDefaults) // ctx.Req.PostForm -> WorkflowDispatchPayload.Inputs -> ActionRun.EventPayload -> runner: ghc.Event // https://docs.github.com/en/actions/learn-github-actions/contexts#github-context @@ -169,6 +174,23 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re return run.ID, nil } +// coerceDispatchInputTypes normalizes workflow_dispatch input values to the JSON types declared by +// the workflow. Only booleans are coerced, matching GitHub, whose `inputs` context "preserves +// Boolean values as Booleans instead of converting them to strings" while every other type stays a +// string. workflow_dispatch has no `number` type (its input types are string, choice, boolean and +// environment), so booleans are the complete set to coerce here. +// A value that is already a bool is left untouched, so the coercion is idempotent. +func coerceDispatchInputTypes(dispatch *model.WorkflowDispatch, inputs map[string]any) { + for name, cfg := range dispatch.Inputs { + if cfg.Type != "boolean" { + continue + } + if s, ok := inputs[name].(string); ok { + inputs[name] = s == "true" + } + } +} + // resolveDispatchWorkflowContent returns the YAML for a dispatched workflow and records its source on the run. // - Repo-level: from the consumer's runTargetCommit. // - Scoped: from the source repo's default branch. diff --git a/services/actions/workflow_test.go b/services/actions/workflow_test.go new file mode 100644 index 00000000000..62f760b8db7 --- /dev/null +++ b/services/actions/workflow_test.go @@ -0,0 +1,41 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package actions + +import ( + "testing" + + "gitea.com/gitea/runner/act/model" + "github.com/stretchr/testify/assert" +) + +func TestCoerceDispatchInputTypes(t *testing.T) { + dispatch := &model.WorkflowDispatch{ + Inputs: map[string]model.WorkflowDispatchInput{ + "build_server": {Type: "boolean"}, + "dry_run": {Type: "boolean"}, + "already_bool": {Type: "boolean"}, + "version": {Type: "string"}, + }, + } + + inputs := map[string]any{ + // dispatch callbacks fill booleans as strconv.FormatBool(...) strings + "build_server": "true", + "dry_run": "false", + // already-native booleans are passed through unchanged (coercion is idempotent) + "already_bool": true, + // non-boolean inputs must be left untouched + "version": "1.2.3", + } + + coerceDispatchInputTypes(dispatch, inputs) + + // Regression: without coercion these stay strings, and a server-side needs-gated + // job `if: inputs.build_server == true` never matches, leaving the job blocked. + assert.Equal(t, true, inputs["build_server"]) + assert.Equal(t, false, inputs["dry_run"]) + assert.Equal(t, true, inputs["already_bool"]) + assert.Equal(t, "1.2.3", inputs["version"]) +} diff --git a/tests/integration/actions_trigger_test.go b/tests/integration/actions_trigger_test.go index aef0f3c2cc7..bea15994f40 100644 --- a/tests/integration/actions_trigger_test.go +++ b/tests/integration/actions_trigger_test.go @@ -1162,7 +1162,7 @@ jobs: assert.Contains(t, dispatchPayload.Inputs, "myinput3") assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"]) assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"]) - assert.Equal(t, "true", dispatchPayload.Inputs["myinput3"]) + assert.Equal(t, true, dispatchPayload.Inputs["myinput3"]) }) } @@ -1342,7 +1342,7 @@ jobs: assert.Contains(t, dispatchPayload.Inputs, "myinput3") assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"]) assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"]) - assert.Equal(t, "true", dispatchPayload.Inputs["myinput3"]) + assert.Equal(t, true, dispatchPayload.Inputs["myinput3"]) }) } @@ -1473,7 +1473,7 @@ jobs: assert.Contains(t, dispatchPayload.Inputs, "myinput3") assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"]) assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"]) - assert.Equal(t, "true", dispatchPayload.Inputs["myinput3"]) + assert.Equal(t, true, dispatchPayload.Inputs["myinput3"]) }) } @@ -1670,7 +1670,7 @@ jobs: assert.Contains(t, dispatchPayload.Inputs, "myinput3") assert.Equal(t, "val0", dispatchPayload.Inputs["myinput"]) assert.Equal(t, "def2", dispatchPayload.Inputs["myinput2"]) - assert.Equal(t, "true", dispatchPayload.Inputs["myinput3"]) + assert.Equal(t, true, dispatchPayload.Inputs["myinput3"]) }) }