feat(requires): support variable references in enum constraints

Add support for referencing variables in enum validation:

```yaml
vars:
  ALLOWED_ENVS: ["dev", "staging", "prod"]

tasks:
  deploy:
    requires:
      vars:
        - name: ENV
          enum:
            ref: .ALLOWED_ENVS
```

This allows enum values to be defined once and reused, or computed
dynamically using template expressions like `| fromYaml`.

Changes:
- Add Enum type with Ref/Value fields in taskfile/ast/requires.go
- Add resolveEnumRefs() to resolve refs at task compilation time
- Add getEnumValues() helper in requires.go
- Only resolve enum refs when shell variables are evaluated
This commit is contained in:
Valentin Maerten
2026-01-25 15:17:40 +01:00
parent c3fd3c4b5e
commit f0f61b41ac
10 changed files with 150 additions and 9 deletions

View File

@@ -351,6 +351,32 @@ func TestRequires(t *testing.T) {
),
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(),
)
}
// TODO: mock fs

View File

@@ -81,7 +81,7 @@ func (e *Executor) promptDepsVars(calls []*Call) error {
e.promptedVars = ast.NewVars()
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 errors.Is(err, input.ErrCancelled) {
return &errors.TaskCancelledByUserError{TaskName: "interactive prompt"}
@@ -120,7 +120,7 @@ func (e *Executor) promptTaskVars(t *ast.Task, call *Call) (bool, error) {
prompter := e.newPrompter()
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 errors.Is(err, input.ErrCancelled) {
return false, &errors.TaskCancelledByUserError{TaskName: t.Name()}
@@ -168,7 +168,7 @@ func (e *Executor) areTaskRequiredVarsSet(t *ast.Task) error {
for i, v := range missing {
missingVars[i] = errors.MissingVar{
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 {
varValue, _ := t.Vars.Get(requiredVar.Name)
enumValues := getEnumValues(requiredVar.Enum)
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{
Value: value,
Enum: requiredVar.Enum,
Enum: enumValues,
Name: requiredVar.Name,
})
}
@@ -206,3 +207,11 @@ func (e *Executor) areTaskRequiredVarsAllowedValuesSet(t *ast.Task) error {
return nil
}
// getEnumValues returns the enum values from an Enum struct, or nil if the enum is nil.
func getEnumValues(e *ast.Enum) []string {
if e == nil {
return nil
}
return e.Value
}

View File

@@ -22,9 +22,53 @@ 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 // Reference to a variable containing the allowed values
Value []string // Static list of allowed values
}
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)
}
e.Ref = refStruct.Ref
return nil
}
return errors.NewTaskfileDecodeError(nil, node).WithTypeMessage("enum")
}
type VarsWithValidation struct {
Name string
Enum []string
Enum *Enum
}
func (v *VarsWithValidation) DeepCopy() *VarsWithValidation {
@@ -33,7 +77,7 @@ func (v *VarsWithValidation) DeepCopy() *VarsWithValidation {
}
return &VarsWithValidation{
Name: v.Name,
Enum: v.Enum,
Enum: v.Enum.DeepCopy(),
}
}
@@ -53,7 +97,7 @@ func (v *VarsWithValidation) UnmarshalYAML(node *yaml.Node) error {
case yaml.MappingNode:
var vv struct {
Name string
Enum []string
Enum *Enum
}
if err := node.Decode(&vv); err != nil {
return errors.NewTaskfileDecodeError(err, node)

View File

@@ -1,5 +1,9 @@
version: '3'
vars:
ALLOWED_ENVS: ["dev", "staging", "prod"]
NOT_A_LIST: "this is a string"
tasks:
default:
- task: missing-var
@@ -41,3 +45,19 @@ tasks:
{{range .MY_VAR | splitList " " }}
echo {{.}}
{{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}}"

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

@@ -98,6 +98,17 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err
}
cache := &templater.Cache{Vars: vars}
// Resolve enum refs in requires only when evaluateShVars is true,
// since enum refs may depend on shell variables
requires := origTask.Requires
if evaluateShVars {
requires = origTask.Requires.DeepCopy()
if err := resolveEnumRefs(requires, cache); err != nil {
return nil, err
}
}
new := ast.Task{
Task: origTask.Task,
Label: templater.Replace(origTask.Label, cache),
@@ -125,7 +136,7 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err
Platforms: origTask.Platforms,
If: templater.Replace(origTask.If, cache),
Location: origTask.Location,
Requires: origTask.Requires,
Requires: requires,
Watch: origTask.Watch,
Failfast: origTask.Failfast,
Namespace: origTask.Namespace,
@@ -431,6 +442,32 @@ func resolveMatrixRefs(matrix *ast.Matrix, cache *templater.Cache) error {
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)
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.
func product(matrix *ast.Matrix) []map[string]any {
if matrix.Len() == 0 {