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"]) }) }