Simplify Matrix decode, add defaults for fail-fast and max-parallel, add test (#763)

* fix[workflow]: multiple fixes for workflow/matrix

fix[workflow]: default `max-parallel`
fix[workflow]: default `fail-fast`, it's `true`, not `false`
fix[workflow]: skipping over the job when `strategy:` is defined but `matrix:` isn't (fixes #625)
fix[workflow]: skip non-existing includes keys and hard fail on non-existing excludes keys
fix[workflow]: simplify Matrix decode (because I "think" I know how `yaml` works) (fixes #760)
fix[tests]: add test for planner and runner

* fix(workflow): use yaml node for env key

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
This commit is contained in:
Ryan 2021-08-09 15:35:05 +00:00 committed by GitHub
parent 43d46aa62f
commit 94fd0ac899
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 237 additions and 73 deletions

50
pkg/model/testdata/strategy/push.yml vendored Normal file
View file

@ -0,0 +1,50 @@
---
jobs:
strategy-all:
name: ${{ matrix.node-version }} | ${{ matrix.site }} | ${{ matrix.datacenter }}
runs-on: ubuntu-latest
steps:
- run: echo 'Hello!'
strategy:
fail-fast: false
matrix:
datacenter:
- site-c
- site-d
exclude:
- datacenter: site-d
node-version: 14.x
site: staging
include:
- php-version: 5.4
- datacenter: site-a
node-version: 10.x
site: prod
- datacenter: site-b
node-version: 12.x
site: dev
node-version: [14.x, 16.x]
site:
- staging
max-parallel: 2
strategy-no-matrix:
runs-on: ubuntu-latest
steps:
- run: echo 'Hello!'
strategy:
fail-fast: false
max-parallel: 2
strategy-only-fail-fast:
runs-on: ubuntu-latest
steps:
- run: echo 'Hello!'
strategy:
fail-fast: false
strategy-only-max-parallel:
runs-on: ubuntu-latest
steps:
- run: echo 'Hello!'
strategy:
max-parallel: 2
'on':
push: null

View file

@ -5,6 +5,7 @@ import (
"io" "io"
"reflect" "reflect"
"regexp" "regexp"
"strconv"
"strings" "strings"
"github.com/nektos/act/pkg/common" "github.com/nektos/act/pkg/common"
@ -58,7 +59,7 @@ type Job struct {
Name string `yaml:"name"` Name string `yaml:"name"`
RawNeeds yaml.Node `yaml:"needs"` RawNeeds yaml.Node `yaml:"needs"`
RawRunsOn yaml.Node `yaml:"runs-on"` RawRunsOn yaml.Node `yaml:"runs-on"`
Env interface{} `yaml:"env"` Env yaml.Node `yaml:"env"`
If yaml.Node `yaml:"if"` If yaml.Node `yaml:"if"`
Steps []*Step `yaml:"steps"` Steps []*Step `yaml:"steps"`
TimeoutMinutes int64 `yaml:"timeout-minutes"` TimeoutMinutes int64 `yaml:"timeout-minutes"`
@ -71,9 +72,11 @@ type Job struct {
// Strategy for the job // Strategy for the job
type Strategy struct { type Strategy struct {
FailFast bool `yaml:"fail-fast"` FailFast bool
MaxParallel int `yaml:"max-parallel"` MaxParallel int
Matrix interface{} `yaml:"matrix"` FailFastString string `yaml:"fail-fast"`
MaxParallelString string `yaml:"max-parallel"`
RawMatrix yaml.Node `yaml:"matrix"`
} }
// Default settings that will apply to all steps in the job or workflow // Default settings that will apply to all steps in the job or workflow
@ -87,6 +90,37 @@ type RunDefaults struct {
WorkingDirectory string `yaml:"working-directory"` WorkingDirectory string `yaml:"working-directory"`
} }
// GetMaxParallel sets default and returns value for `max-parallel`
func (s Strategy) GetMaxParallel() int {
// MaxParallel default value is `GitHub will maximize the number of jobs run in parallel depending on the available runners on GitHub-hosted virtual machines`
// So I take the liberty to hardcode default limit to 4 and this is because:
// 1: tl;dr: self-hosted does only 1 parallel job - https://github.com/actions/runner/issues/639#issuecomment-825212735
// 2: GH has 20 parallel job limit (for free tier) - https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/usage-limits-billing-and-administration.md?plain=1#L45
// 3: I want to add support for MaxParallel to act and 20! parallel jobs is a bit overkill IMHO
maxParallel := 4
if s.MaxParallelString != "" {
var err error
if maxParallel, err = strconv.Atoi(s.MaxParallelString); err != nil {
log.Errorf("Failed to parse 'max-parallel' option: %v", err)
}
}
return maxParallel
}
// GetFailFast sets default and returns value for `fail-fast`
func (s Strategy) GetFailFast() bool {
// FailFast option is true by default: https://github.com/github/docs/blob/3ae84420bd10997bb5f35f629ebb7160fe776eae/content/actions/reference/workflow-syntax-for-github-actions.md?plain=1#L1107
failFast := true
log.Debug(s.FailFastString)
if s.FailFastString != "" {
var err error
if failFast, err = strconv.ParseBool(s.FailFastString); err != nil {
log.Errorf("Failed to parse 'fail-fast' option: %v", err)
}
}
return failFast
}
// Container details for the job // Container details for the job
func (j *Job) Container() *ContainerSpec { func (j *Job) Container() *ContainerSpec {
var val *ContainerSpec var val *ContainerSpec
@ -149,76 +183,83 @@ func (j *Job) RunsOn() []string {
return nil return nil
} }
func environment(e interface{}) map[string]string { func environment(yml yaml.Node) map[string]string {
env := make(map[string]string) env := make(map[string]string)
switch t := e.(type) { if yml.Kind == yaml.MappingNode {
case map[string]interface{}: if err := yml.Decode(&env); err != nil {
for k, v := range t { log.Fatal(err)
switch t := v.(type) {
case string:
env[k] = t
case interface{}:
env[k] = ""
}
}
case map[string]string:
for k, v := range e.(map[string]string) {
env[k] = v
} }
} }
return env return env
} }
// Environments returns string-based key=value map for a job
func (j *Job) Environment() map[string]string { func (j *Job) Environment() map[string]string {
return environment(j.Env) return environment(j.Env)
} }
// Matrix decodes RawMatrix YAML node
func (j *Job) Matrix() map[string][]interface{} { func (j *Job) Matrix() map[string][]interface{} {
a := reflect.ValueOf(j.Strategy.Matrix) if j.Strategy.RawMatrix.Kind == yaml.MappingNode {
if a.Type().Kind() == reflect.Map { var val map[string][]interface{}
output := make(map[string][]interface{}) if err := j.Strategy.RawMatrix.Decode(&val); err != nil {
for _, e := range a.MapKeys() { log.Fatal(err)
v := a.MapIndex(e)
switch t := v.Interface().(type) {
case []interface{}:
output[e.String()] = t
case interface{}:
var in []interface{}
in = append(in, t)
output[e.String()] = in
} }
} return val
return output
} }
return nil return nil
} }
// GetMatrixes returns the matrix cross product // GetMatrixes returns the matrix cross product
// It skips includes and hard fails excludes for non-existing keys
// nolint:gocyclo
func (j *Job) GetMatrixes() []map[string]interface{} { func (j *Job) GetMatrixes() []map[string]interface{} {
matrixes := make([]map[string]interface{}, 0) matrixes := make([]map[string]interface{}, 0)
if j.Strategy != nil { if j.Strategy != nil {
m := j.Matrix() j.Strategy.FailFast = j.Strategy.GetFailFast()
j.Strategy.MaxParallel = j.Strategy.GetMaxParallel()
if m := j.Matrix(); m != nil {
includes := make([]map[string]interface{}, 0) includes := make([]map[string]interface{}, 0)
for _, v := range m["include"] { for _, v := range m["include"] {
switch t := v.(type) { switch t := v.(type) {
case []interface{}: case []interface{}:
for _, i := range t { for _, i := range t {
includes = append(includes, i.(map[string]interface{})) i := i.(map[string]interface{})
for k := range i {
if _, ok := m[k]; ok {
includes = append(includes, i)
break
}
}
} }
case interface{}: case interface{}:
includes = append(includes, v.(map[string]interface{})) v := v.(map[string]interface{})
for k := range v {
if _, ok := m[k]; ok {
includes = append(includes, v)
break
}
}
} }
} }
delete(m, "include") delete(m, "include")
excludes := make([]map[string]interface{}, 0) excludes := make([]map[string]interface{}, 0)
for _, v := range m["exclude"] { for _, e := range m["exclude"] {
excludes = append(excludes, v.(map[string]interface{})) e := e.(map[string]interface{})
for k := range e {
if _, ok := m[k]; ok {
excludes = append(excludes, e)
} else {
// We fail completely here because that's what GitHub does for non-existing matrix keys, fail on exclude, silent skip on include
log.Fatalf("The workflow is not valid. Matrix exclude key '%s' does not match any key within the matrix", k)
}
}
} }
delete(m, "exclude") delete(m, "exclude")
matrixProduct := common.CartesianProduct(m) matrixProduct := common.CartesianProduct(m)
MATRIX: MATRIX:
for _, matrix := range matrixProduct { for _, matrix := range matrixProduct {
for _, exclude := range excludes { for _, exclude := range excludes {
@ -236,6 +277,9 @@ func (j *Job) GetMatrixes() []map[string]interface{} {
} else { } else {
matrixes = append(matrixes, make(map[string]interface{})) matrixes = append(matrixes, make(map[string]interface{}))
} }
} else {
matrixes = append(matrixes, make(map[string]interface{}))
}
return matrixes return matrixes
} }
@ -270,7 +314,7 @@ type Step struct {
Run string `yaml:"run"` Run string `yaml:"run"`
WorkingDirectory string `yaml:"working-directory"` WorkingDirectory string `yaml:"working-directory"`
Shell string `yaml:"shell"` Shell string `yaml:"shell"`
Env interface{} `yaml:"env"` Env yaml.Node `yaml:"env"`
With map[string]string `yaml:"with"` With map[string]string `yaml:"with"`
ContinueOnError bool `yaml:"continue-on-error"` ContinueOnError bool `yaml:"continue-on-error"`
TimeoutMinutes int64 `yaml:"timeout-minutes"` TimeoutMinutes int64 `yaml:"timeout-minutes"`
@ -288,6 +332,7 @@ func (s *Step) String() string {
return s.ID return s.ID
} }
// Environments returns string-based key=value map for a step
func (s *Step) Environment() map[string]string { func (s *Step) Environment() map[string]string {
return environment(s.Env) return environment(s.Env)
} }

View file

@ -173,6 +173,64 @@ jobs:
assert.Equal(t, "${{ steps.test1_1.outputs.b-key }}", workflow.Jobs["test1"].Outputs["some-b-key"]) assert.Equal(t, "${{ steps.test1_1.outputs.b-key }}", workflow.Jobs["test1"].Outputs["some-b-key"])
} }
func TestReadWorkflow_Strategy(t *testing.T) {
w, err := NewWorkflowPlanner("testdata/strategy/push.yml", true)
assert.NoError(t, err)
p := w.PlanJob("strategy-only-max-parallel")
assert.Equal(t, len(p.Stages), 1)
assert.Equal(t, len(p.Stages[0].Runs), 1)
wf := p.Stages[0].Runs[0].Workflow
job := wf.Jobs["strategy-only-max-parallel"]
assert.Equal(t, job.GetMatrixes(), []map[string]interface{}{{}})
assert.Equal(t, job.Matrix(), map[string][]interface{}(nil))
assert.Equal(t, job.Strategy.MaxParallel, 2)
assert.Equal(t, job.Strategy.FailFast, true)
job = wf.Jobs["strategy-only-fail-fast"]
assert.Equal(t, job.GetMatrixes(), []map[string]interface{}{{}})
assert.Equal(t, job.Matrix(), map[string][]interface{}(nil))
assert.Equal(t, job.Strategy.MaxParallel, 4)
assert.Equal(t, job.Strategy.FailFast, false)
job = wf.Jobs["strategy-no-matrix"]
assert.Equal(t, job.GetMatrixes(), []map[string]interface{}{{}})
assert.Equal(t, job.Matrix(), map[string][]interface{}(nil))
assert.Equal(t, job.Strategy.MaxParallel, 2)
assert.Equal(t, job.Strategy.FailFast, false)
job = wf.Jobs["strategy-all"]
assert.Equal(t, job.GetMatrixes(),
[]map[string]interface{}{
{"datacenter": "site-c", "node-version": "14.x", "site": "staging"},
{"datacenter": "site-c", "node-version": "16.x", "site": "staging"},
{"datacenter": "site-d", "node-version": "16.x", "site": "staging"},
{"datacenter": "site-a", "node-version": "10.x", "site": "prod"},
{"datacenter": "site-b", "node-version": "12.x", "site": "dev"},
},
)
assert.Equal(t, job.Matrix(),
map[string][]interface{}{
"datacenter": {"site-c", "site-d"},
"exclude": {
map[string]interface{}{"datacenter": "site-d", "node-version": "14.x", "site": "staging"},
},
"include": {
map[string]interface{}{"php-version": 5.4},
map[string]interface{}{"datacenter": "site-a", "node-version": "10.x", "site": "prod"},
map[string]interface{}{"datacenter": "site-b", "node-version": "12.x", "site": "dev"},
},
"node-version": {"14.x", "16.x"},
"site": {"staging"},
},
)
assert.Equal(t, job.Strategy.MaxParallel, 2)
assert.Equal(t, job.Strategy.FailFast, false)
}
func TestStep_ShellCommand(t *testing.T) { func TestStep_ShellCommand(t *testing.T) {
tests := []struct { tests := []struct {
shell string shell string

View file

@ -9,9 +9,17 @@ import (
"github.com/nektos/act/pkg/model" "github.com/nektos/act/pkg/model"
assert "github.com/stretchr/testify/assert" assert "github.com/stretchr/testify/assert"
yaml "gopkg.in/yaml.v3"
) )
func TestEvaluate(t *testing.T) { func TestEvaluate(t *testing.T) {
var yml yaml.Node
err := yml.Encode(map[string][]interface{}{
"os": {"Linux", "Windows"},
"foo": {"bar", "baz"},
})
assert.NoError(t, err)
rc := &RunContext{ rc := &RunContext{
Config: &Config{ Config: &Config{
Workdir: ".", Workdir: ".",
@ -29,10 +37,7 @@ func TestEvaluate(t *testing.T) {
Jobs: map[string]*model.Job{ Jobs: map[string]*model.Job{
"job1": { "job1": {
Strategy: &model.Strategy{ Strategy: &model.Strategy{
Matrix: map[string][]interface{}{ RawMatrix: yml,
"os": {"Linux", "Windows"},
"foo": {"bar", "baz"},
},
}, },
}, },
}, },

View file

@ -14,9 +14,17 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/sirupsen/logrus/hooks/test" "github.com/sirupsen/logrus/hooks/test"
assert "github.com/stretchr/testify/assert" assert "github.com/stretchr/testify/assert"
yaml "gopkg.in/yaml.v3"
) )
func TestRunContext_EvalBool(t *testing.T) { func TestRunContext_EvalBool(t *testing.T) {
var yml yaml.Node
err := yml.Encode(map[string][]interface{}{
"os": {"Linux", "Windows"},
"foo": {"bar", "baz"},
})
assert.NoError(t, err)
hook := test.NewGlobal() hook := test.NewGlobal()
rc := &RunContext{ rc := &RunContext{
Config: &Config{ Config: &Config{
@ -34,10 +42,7 @@ func TestRunContext_EvalBool(t *testing.T) {
Jobs: map[string]*model.Job{ Jobs: map[string]*model.Job{
"job1": { "job1": {
Strategy: &model.Strategy{ Strategy: &model.Strategy{
Matrix: map[string][]interface{}{ RawMatrix: yml,
"os": {"Linux", "Windows"},
"foo": {"bar", "baz"},
},
}, },
}, },
}, },

View file

@ -118,6 +118,7 @@ func TestRunEvent(t *testing.T) {
{"testdata", "issue-597", "push", "", platforms, ""}, {"testdata", "issue-597", "push", "", platforms, ""},
{"testdata", "issue-598", "push", "", platforms, ""}, {"testdata", "issue-598", "push", "", platforms, ""},
{"testdata", "env-and-path", "push", "", platforms, ""}, {"testdata", "env-and-path", "push", "", platforms, ""},
{"../model/testdata", "strategy", "push", "", platforms, ""}, // TODO: move all testdata into pkg so we can validate it with planner and runner
// {"testdata", "issue-228", "push", "", platforms, ""}, // TODO [igni]: Remove this once everything passes // {"testdata", "issue-228", "push", "", platforms, ""}, // TODO [igni]: Remove this once everything passes
// single test for different architecture: linux/arm64 // single test for different architecture: linux/arm64