fix: accept true as input in workflow_dispatch (#10089)

Previously, when triggering workflows with `workflow_dispatch`, Forgejo only interpreted `on` as boolean `true`. Everything else, including `true`, was treated as `false`. This behaviour does not match the [Forgejo documentation](https://forgejo.org/docs/v13.0/user/actions/reference/#onworkflow_dispatch) that states that `true` and `false` are permitted values. It is also outside the [YAML 1.2 specification of booleans](https://yaml.org/spec/1.2.2/#10212-boolean) that only permits `true` and `false`.

After this change, only `true` and `false` have the desired effect. `on` (converted to `true`) is kept for compatibility reasons to give people time to upgrade.

This problem only affected users of the Forgejo API, because the UI sent the expected values.

Resolves forgejo/forgejo#10070 by fixing the documentation mismatch.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10089
Reviewed-by: klausfyhn <klausfyhn@noreply.codeberg.org>
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: Andreas Ahlenstorf <andreas@ahlenstorf.ch>
Co-committed-by: Andreas Ahlenstorf <andreas@ahlenstorf.ch>
This commit is contained in:
Andreas Ahlenstorf 2025-11-13 15:57:55 +01:00 committed by Gusted
parent 35fa852406
commit 8fea4c5829
3 changed files with 161 additions and 21 deletions

View file

@ -8,7 +8,6 @@ import (
"context"
"errors"
"fmt"
"strconv"
actions_model "forgejo.org/models/actions"
"forgejo.org/models/perm"
@ -50,6 +49,28 @@ type Workflow struct {
type InputValueGetter func(key string) string
func resolveDispatchInput(key, value string, input act_model.WorkflowDispatchInput) (string, error) {
if len(value) == 0 {
value = input.Default
if len(value) == 0 {
if input.Required {
name := input.Description
if len(name) == 0 {
name = key
}
return "", InputRequiredErr{Name: name}
}
}
} else if input.Type == "boolean" {
// Temporary compatibility shim for people that upgrade to Forgejo 14. Can be removed with Forgejo 15.
if value == "on" {
value = "true"
}
}
return value, nil
}
func (entry *Workflow) Dispatch(ctx context.Context, inputGetter InputValueGetter, repo *repo_model.Repository, doer *user.User) (r *actions_model.ActionRun, j []string, err error) {
content, err := actions.GetContentFromEntry(entry.GitEntry)
if err != nil {
@ -72,25 +93,12 @@ func (entry *Workflow) Dispatch(ctx context.Context, inputGetter InputValueGette
inputsAny := make(map[string]any)
if workflowDispatch := wf.WorkflowDispatchConfig(); workflowDispatch != nil {
for key, input := range workflowDispatch.Inputs {
val := inputGetter(key)
if len(val) == 0 {
val = input.Default
if len(val) == 0 {
if input.Required {
name := input.Description
if len(name) == 0 {
name = key
}
return nil, nil, InputRequiredErr{Name: name}
}
continue
}
} else if input.Type == "boolean" {
// Since "boolean" inputs are rendered as a checkbox in html, the value inside the form is "on"
val = strconv.FormatBool(val == "on")
value, err := resolveDispatchInput(key, inputGetter(key), input)
if err != nil {
return nil, nil, err
}
inputs[key] = val
inputsAny[key] = val
inputs[key] = value
inputsAny[key] = value
}
}

View file

@ -121,3 +121,135 @@ func TestConfigureActionRunConcurrency(t *testing.T) {
})
}
}
func TestResolveDispatchInputAcceptsValidInput(t *testing.T) {
for _, tc := range []struct {
name string
key string
value string
input act_model.WorkflowDispatchInput
expected string
}{
{
name: "on_converted_to_true",
key: "my_boolean",
value: "on",
input: act_model.WorkflowDispatchInput{Description: "a boolean", Required: false, Type: "boolean", Options: []string{}},
expected: "true",
},
// It might make sense to validate booleans in the future and then turn it into an error.
{
name: "ON_stays_ON",
key: "my_boolean",
value: "ON",
input: act_model.WorkflowDispatchInput{Description: "a boolean", Required: false, Type: "boolean", Options: []string{}},
expected: "ON",
},
{
name: "true_stays_true",
key: "my_boolean",
value: "true",
input: act_model.WorkflowDispatchInput{Description: "a boolean", Required: false, Type: "boolean", Options: []string{}},
expected: "true",
},
{
name: "false_stays_false",
key: "my_boolean",
value: "false",
input: act_model.WorkflowDispatchInput{Description: "a boolean", Required: false, Type: "boolean", Options: []string{}},
expected: "false",
},
{
name: "empty_results_in_default_value_true",
key: "my_boolean",
value: "",
input: act_model.WorkflowDispatchInput{Description: "a boolean", Required: false, Default: "true", Type: "boolean", Options: []string{}},
expected: "true",
},
{
name: "empty_results_in_default_value_false",
key: "my_boolean",
value: "",
input: act_model.WorkflowDispatchInput{Description: "a boolean", Required: false, Default: "false", Type: "boolean", Options: []string{}},
expected: "false",
},
{
name: "string_results_in_input",
key: "my_string",
value: "hello",
input: act_model.WorkflowDispatchInput{Description: "a string", Required: false, Type: "string", Options: []string{}},
expected: "hello",
},
{
name: "string_option_results_in_input",
value: "a",
input: act_model.WorkflowDispatchInput{Description: "a string", Required: false, Type: "string", Options: []string{"a", "b"}},
expected: "a",
},
// Test ensures that the old behaviour (ignoring option mismatch) is retained. It might
// make sense to turn it into an error in the future.
{
name: "invalid_string_option_results_in_input",
key: "option",
value: "c",
input: act_model.WorkflowDispatchInput{Description: "a string", Required: false, Type: "string", Options: []string{"a", "b"}},
expected: "c",
},
{
name: "number_results_in_input",
key: "my_number",
value: "123",
input: act_model.WorkflowDispatchInput{Description: "a string", Required: false, Type: "number", Options: []string{}},
expected: "123",
},
} {
t.Run(tc.name, func(t *testing.T) {
actual, _ := resolveDispatchInput(tc.key, tc.value, tc.input)
assert.Equal(t, tc.expected, actual)
})
}
}
func TestResolveDispatchInputRejectsInvalidInput(t *testing.T) {
for _, tc := range []struct {
name string
key string
value string
input act_model.WorkflowDispatchInput
expected error
}{
{
name: "missing_required_boolean",
key: "missing_boolean",
value: "",
input: act_model.WorkflowDispatchInput{Description: "a boolean", Required: true, Type: "boolean", Options: []string{}},
expected: InputRequiredErr{Name: "a boolean"},
},
{
name: "missing_required_boolean_without_description",
key: "missing_boolean",
value: "",
input: act_model.WorkflowDispatchInput{Required: true, Type: "boolean", Options: []string{}},
expected: InputRequiredErr{Name: "missing_boolean"},
},
{
name: "missing_required_string",
key: "missing_string",
value: "",
input: act_model.WorkflowDispatchInput{Description: "a string", Required: true, Type: "string", Options: []string{}},
expected: InputRequiredErr{Name: "a string"},
},
{
name: "missing_required_string_without_description",
key: "missing_string",
value: "",
input: act_model.WorkflowDispatchInput{Required: true, Type: "string", Options: []string{}},
expected: InputRequiredErr{Name: "missing_string"},
},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := resolveDispatchInput(tc.key, tc.value, tc.input)
assert.Equal(t, tc.expected, err)
})
}
}

View file

@ -34,8 +34,8 @@
<div class="ui checkbox">
<label><strong>{{if $val.Description}}{{$val.Description}}{{else}}{{$key}}{{end}}</strong></label>
{{/* These two inputs need to stay in exactly this order (checkbox first, hidden second) or boolean fields won't work correctly! */}}
<input type="checkbox" name="inputs[{{$key}}]" value="on" {{if eq $val.Default "true"}}checked{{end}}>
<input type="hidden" name="inputs[{{$key}}]" value="off" autocomplete="off">
<input type="checkbox" name="inputs[{{$key}}]" value="true" {{if eq $val.Default "true"}}checked{{end}}>
<input type="hidden" name="inputs[{{$key}}]" value="false" autocomplete="off">
</div>
{{else}}
<label>{{if $val.Description}}{{$val.Description}}{{else}}{{$key}}{{end}}</label>