feat(requires): support variable references in enum constraints (#2678)

This commit is contained in:
Valentin Maerten
2026-03-21 11:32:02 +01:00
committed by GitHub
parent 19d8fae5f9
commit 8b6aca5722
16 changed files with 268 additions and 16 deletions

View File

@@ -351,6 +351,41 @@ func TestRequires(t *testing.T) {
), ),
WithTask("var-defined-in-task"), WithTask("var-defined-in-task"),
) )
NewExecutorTest(t,
WithName("enum ref - passes validation"),
WithExecutorOptions(
task.WithDir("testdata/requires"),
),
WithTask("validation-var-ref"),
WithVar("ENV", "dev"),
)
NewExecutorTest(t,
WithName("enum ref - fails validation"),
WithExecutorOptions(
task.WithDir("testdata/requires"),
),
WithTask("validation-var-ref"),
WithVar("ENV", "invalid"),
WithRunError(),
)
NewExecutorTest(t,
WithName("enum ref - ref to non-list"),
WithExecutorOptions(
task.WithDir("testdata/requires"),
),
WithTask("validation-var-ref-invalid"),
WithVar("VALUE", "test"),
WithRunError(),
)
NewExecutorTest(t,
WithName("enum ref - ref to nonexistent var"),
WithExecutorOptions(
task.WithDir("testdata/requires"),
),
WithTask("validation-var-ref-nonexistent"),
WithVar("ENV", "dev"),
WithRunError(),
)
} }
// TODO: mock fs // TODO: mock fs

View File

@@ -247,15 +247,17 @@ func printTaskRequires(l *logger.Logger, t *ast.Task) {
l.Outf(logger.Default, " vars:\n") l.Outf(logger.Default, " vars:\n")
for _, v := range t.Requires.Vars { for _, v := range t.Requires.Vars {
// If the variable has enum constraints, format accordingly if v.Enum != nil && len(v.Enum.Value) > 0 {
if len(v.Enum) > 0 {
l.Outf(logger.Yellow, " - %s:\n", v.Name) l.Outf(logger.Yellow, " - %s:\n", v.Name)
l.Outf(logger.Yellow, " enum:\n") l.Outf(logger.Yellow, " enum:\n")
for _, enumValue := range v.Enum { for _, enumValue := range v.Enum.Value {
l.Outf(logger.Yellow, " - %s\n", enumValue) l.Outf(logger.Yellow, " - %s\n", enumValue)
} }
} else if v.Enum != nil && v.Enum.Ref != "" {
l.Outf(logger.Yellow, " - %s:\n", v.Name)
l.Outf(logger.Yellow, " enum:\n")
l.Outf(logger.Yellow, " ref: %s\n", v.Enum.Ref)
} else { } else {
// Simple required variable
l.Outf(logger.Yellow, " - %s\n", v.Name) l.Outf(logger.Yellow, " - %s\n", v.Name)
} }
} }

View File

@@ -81,7 +81,7 @@ func (e *Executor) promptDepsVars(calls []*Call) error {
e.promptedVars = ast.NewVars() e.promptedVars = ast.NewVars()
for _, v := range varsMap { for _, v := range varsMap {
value, err := prompter.Prompt(v.Name, v.Enum) value, err := prompter.Prompt(v.Name, getEnumValues(v.Enum))
if err != nil { if err != nil {
if errors.Is(err, input.ErrCancelled) { if errors.Is(err, input.ErrCancelled) {
return &errors.TaskCancelledByUserError{TaskName: "interactive prompt"} return &errors.TaskCancelledByUserError{TaskName: "interactive prompt"}
@@ -120,7 +120,7 @@ func (e *Executor) promptTaskVars(t *ast.Task, call *Call) (bool, error) {
prompter := e.newPrompter() prompter := e.newPrompter()
for _, v := range missing { for _, v := range missing {
value, err := prompter.Prompt(v.Name, v.Enum) value, err := prompter.Prompt(v.Name, getEnumValues(v.Enum))
if err != nil { if err != nil {
if errors.Is(err, input.ErrCancelled) { if errors.Is(err, input.ErrCancelled) {
return false, &errors.TaskCancelledByUserError{TaskName: t.Name()} return false, &errors.TaskCancelledByUserError{TaskName: t.Name()}
@@ -168,7 +168,7 @@ func (e *Executor) areTaskRequiredVarsSet(t *ast.Task) error {
for i, v := range missing { for i, v := range missing {
missingVars[i] = errors.MissingVar{ missingVars[i] = errors.MissingVar{
Name: v.Name, Name: v.Name,
AllowedValues: v.Enum, AllowedValues: getEnumValues(v.Enum),
} }
} }
@@ -187,11 +187,12 @@ func (e *Executor) areTaskRequiredVarsAllowedValuesSet(t *ast.Task) error {
for _, requiredVar := range t.Requires.Vars { for _, requiredVar := range t.Requires.Vars {
varValue, _ := t.Vars.Get(requiredVar.Name) varValue, _ := t.Vars.Get(requiredVar.Name)
enumValues := getEnumValues(requiredVar.Enum)
value, isString := varValue.Value.(string) value, isString := varValue.Value.(string)
if isString && requiredVar.Enum != nil && !slices.Contains(requiredVar.Enum, value) { if isString && len(enumValues) > 0 && !slices.Contains(enumValues, value) {
notAllowedValuesVars = append(notAllowedValuesVars, errors.NotAllowedVar{ notAllowedValuesVars = append(notAllowedValuesVars, errors.NotAllowedVar{
Value: value, Value: value,
Enum: requiredVar.Enum, Enum: enumValues,
Name: requiredVar.Name, Name: requiredVar.Name,
}) })
} }
@@ -206,3 +207,10 @@ func (e *Executor) areTaskRequiredVarsAllowedValuesSet(t *ast.Task) error {
return nil return nil
} }
func getEnumValues(e *ast.Enum) []string {
if e == nil {
return nil
}
return e.Value
}

View File

@@ -22,9 +22,56 @@ func (r *Requires) DeepCopy() *Requires {
} }
} }
// Enum represents an enum constraint for a required variable.
// It can either be a static list of values or a reference to another variable.
type Enum struct {
Ref string
Value []string
}
func (e *Enum) DeepCopy() *Enum {
if e == nil {
return nil
}
return &Enum{
Ref: e.Ref,
Value: deepcopy.Slice(e.Value),
}
}
// UnmarshalYAML implements yaml.Unmarshaler interface.
func (e *Enum) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.SequenceNode:
// Static list of values: enum: ["a", "b"]
var values []string
if err := node.Decode(&values); err != nil {
return errors.NewTaskfileDecodeError(err, node)
}
e.Value = values
return nil
case yaml.MappingNode:
// Reference to another variable: enum: { ref: .VAR }
var refStruct struct {
Ref string
}
if err := node.Decode(&refStruct); err != nil {
return errors.NewTaskfileDecodeError(err, node)
}
if refStruct.Ref == "" {
return errors.NewTaskfileDecodeError(nil, node).WithTypeMessage("enum")
}
e.Ref = refStruct.Ref
return nil
}
return errors.NewTaskfileDecodeError(nil, node).WithTypeMessage("enum")
}
type VarsWithValidation struct { type VarsWithValidation struct {
Name string Name string
Enum []string Enum *Enum
} }
func (v *VarsWithValidation) DeepCopy() *VarsWithValidation { func (v *VarsWithValidation) DeepCopy() *VarsWithValidation {
@@ -33,7 +80,7 @@ func (v *VarsWithValidation) DeepCopy() *VarsWithValidation {
} }
return &VarsWithValidation{ return &VarsWithValidation{
Name: v.Name, Name: v.Name,
Enum: v.Enum, Enum: v.Enum.DeepCopy(),
} }
} }
@@ -53,7 +100,7 @@ func (v *VarsWithValidation) UnmarshalYAML(node *yaml.Node) error {
case yaml.MappingNode: case yaml.MappingNode:
var vv struct { var vv struct {
Name string Name string
Enum []string Enum *Enum
} }
if err := node.Decode(&vv); err != nil { if err := node.Decode(&vv); err != nil {
return errors.NewTaskfileDecodeError(err, node) return errors.NewTaskfileDecodeError(err, node)

View File

@@ -1,5 +1,9 @@
version: '3' version: '3'
vars:
ALLOWED_ENVS: ["dev", "staging", "prod"]
NOT_A_LIST: "this is a string"
tasks: tasks:
default: default:
- task: missing-var - task: missing-var
@@ -41,3 +45,27 @@ tasks:
{{range .MY_VAR | splitList " " }} {{range .MY_VAR | splitList " " }}
echo {{.}} echo {{.}}
{{end}} {{end}}
validation-var-ref:
requires:
vars:
- name: ENV
enum:
ref: .ALLOWED_ENVS
cmd: echo "{{.ENV}}"
validation-var-ref-invalid:
requires:
vars:
- name: VALUE
enum:
ref: .NOT_A_LIST
cmd: echo "{{.VALUE}}"
validation-var-ref-nonexistent:
requires:
vars:
- name: ENV
enum:
ref: .NONEXISTENT_VAR
cmd: echo "{{.ENV}}"

View File

@@ -0,0 +1,2 @@
task: Task "validation-var-ref" cancelled because it is missing required variables:
- ENV has an invalid value : 'invalid' (allowed values : [dev staging prod])

View File

@@ -0,0 +1,2 @@
task: [validation-var-ref] echo "dev"
dev

View File

@@ -0,0 +1 @@
enum reference ".NOT_A_LIST" must resolve to a list

View File

@@ -0,0 +1 @@
enum reference ".NONEXISTENT_VAR" must resolve to a list

View File

@@ -99,6 +99,17 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err
} }
cache := &templater.Cache{Vars: vars} cache := &templater.Cache{Vars: vars}
// Resolve enum refs only when dynamic variables have been evaluated,
// since enum refs may depend on shell-derived variables (e.g. fromJson)
requires := origTask.Requires
if evaluateShVars {
requires = origTask.Requires.DeepCopy()
if err := resolveEnumRefs(requires, cache); err != nil {
return nil, err
}
}
new := ast.Task{ new := ast.Task{
Task: origTask.Task, Task: origTask.Task,
Label: templater.Replace(origTask.Label, cache), Label: templater.Replace(origTask.Label, cache),
@@ -126,7 +137,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err
Platforms: origTask.Platforms, Platforms: origTask.Platforms,
If: templater.Replace(origTask.If, cache), If: templater.Replace(origTask.If, cache),
Location: origTask.Location, Location: origTask.Location,
Requires: origTask.Requires, Requires: requires,
Watch: origTask.Watch, Watch: origTask.Watch,
Failfast: origTask.Failfast, Failfast: origTask.Failfast,
Namespace: origTask.Namespace, Namespace: origTask.Namespace,
@@ -432,6 +443,35 @@ func resolveMatrixRefs(matrix *ast.Matrix, cache *templater.Cache) error {
return nil return nil
} }
func resolveEnumRefs(requires *ast.Requires, cache *templater.Cache) error {
if requires == nil || len(requires.Vars) == 0 {
return nil
}
for _, v := range requires.Vars {
if v.Enum == nil || v.Enum.Ref == "" {
continue
}
resolved := templater.ResolveRef(v.Enum.Ref, cache)
if cache.Err() != nil {
return cache.Err()
}
arr, ok := resolved.([]any)
if !ok {
return fmt.Errorf("enum reference %q must resolve to a list", v.Enum.Ref)
}
strValues := make([]string, 0, len(arr))
for _, item := range arr {
s, ok := item.(string)
if !ok {
return fmt.Errorf("enum reference %q must contain only strings", v.Enum.Ref)
}
strValues = append(strValues, s)
}
v.Enum.Value = strValues
}
return nil
}
// product generates the cartesian product of the input map of slices. // product generates the cartesian product of the input map of slices.
func product(matrix *ast.Matrix) []map[string]any { func product(matrix *ast.Matrix) []map[string]any {
if matrix.Len() == 0 { if matrix.Len() == 0 {

View File

@@ -1233,6 +1233,71 @@ This is supported only for string variables.
::: :::
### Using variable references for enum values
Instead of hardcoding enum values, you can reference a variable containing the
allowed values. This is useful when you want to define allowed values once and
reuse them, or when the values are computed dynamically.
Use the `ref` key to reference a variable:
```yaml
version: '3'
vars:
ALLOWED_ENVS: [dev, staging, prod]
tasks:
deploy:
requires:
vars:
- name: ENV
enum:
ref: .ALLOWED_ENVS
cmds:
- echo "Deploying to {{.ENV}}"
```
You can also use template expressions to transform the value:
```yaml
version: '3'
vars:
CONFIG:
sh: cat config.json
tasks:
deploy:
requires:
vars:
- name: ENV
enum:
ref: ( .CONFIG | fromJson ).allowed_environments
cmds:
- echo "Deploying to {{.ENV}}"
```
Or generate values dynamically from a shell command:
```yaml
version: '3'
vars:
AVAILABLE_SERVICES:
sh: ls services/
tasks:
deploy:
requires:
vars:
- name: SERVICE
enum:
ref: .AVAILABLE_SERVICES | splitLines | compact
cmds:
- echo "Deploying {{.SERVICE}}"
```
### Prompting for missing variables interactively ### Prompting for missing variables interactively
If you want Task to prompt users for missing required variables instead of If you want Task to prompt users for missing required variables instead of

View File

@@ -674,14 +674,12 @@ tasks:
```yaml ```yaml
tasks: tasks:
# Simple requirements
deploy: deploy:
requires: requires:
vars: [API_KEY, ENVIRONMENT] vars: [API_KEY, ENVIRONMENT]
cmds: cmds:
- ./deploy.sh - ./deploy.sh
# Requirements with enum validation
advanced-deploy: advanced-deploy:
requires: requires:
vars: vars:
@@ -693,6 +691,17 @@ tasks:
cmds: cmds:
- echo "Deploying to {{.ENVIRONMENT}} with log level {{.LOG_LEVEL}}" - echo "Deploying to {{.ENVIRONMENT}} with log level {{.LOG_LEVEL}}"
- ./deploy.sh - ./deploy.sh
# Requirements with enum from variable reference
reusable-deploy:
requires:
vars:
- name: ENVIRONMENT
enum:
ref: .ALLOWED_ENVS
cmds:
- ./deploy.sh
``` ```
See [Prompting for missing variables interactively](/docs/guide#prompting-for-missing-variables-interactively) See [Prompting for missing variables interactively](/docs/guide#prompting-for-missing-variables-interactively)

View File

@@ -633,7 +633,19 @@
"type": "object", "type": "object",
"properties": { "properties": {
"name": { "type": "string" }, "name": { "type": "string" },
"enum": { "type": "array", "items": { "type": "string" } } "enum": {
"oneOf": [
{ "type": "array", "items": { "type": "string" } },
{
"type": "object",
"properties": {
"ref": { "type": "string" }
},
"required": ["ref"],
"additionalProperties": false
}
]
}
}, },
"required": ["name"], "required": ["name"],
"additionalProperties": false "additionalProperties": false