diff --git a/pkg/container/docker_run_test.go b/pkg/container/docker_run_test.go index 54575c6..b68d329 100644 --- a/pkg/container/docker_run_test.go +++ b/pkg/container/docker_run_test.go @@ -33,7 +33,8 @@ func TestNewDockerRunExecutor(t *testing.T) { ctx := common.WithLogger(context.Background(), logger) runner := NewDockerRunExecutor(NewDockerRunExecutorInput{ - Image: "hello-world", + Image: "hello-world", + Stdout: buf, }) puller := NewDockerPullExecutor(NewDockerPullExecutorInput{ @@ -44,7 +45,8 @@ func TestNewDockerRunExecutor(t *testing.T) { err := pipeline(ctx) assert.NoError(t, err) - expected := `docker pull hello-worlddocker run image=hello-world entrypoint=[] cmd=[]Hello from Docker!` actual := buf.String() - assert.Equal(t, expected, actual[:len(expected)]) + assert.Contains(t, actual, `docker pull hello-world`) + assert.Contains(t, actual, `docker run image=hello-world entrypoint=[] cmd=[]`) + assert.Contains(t, actual, `Hello from Docker!`) } diff --git a/pkg/model/workflow.go b/pkg/model/workflow.go index da957eb..6989df9 100644 --- a/pkg/model/workflow.go +++ b/pkg/model/workflow.go @@ -62,6 +62,14 @@ type Job struct { TimeoutMinutes int64 `yaml:"timeout-minutes"` Container *ContainerSpec `yaml:"container"` Services map[string]*ContainerSpec `yaml:"services"` + Strategy *Strategy `yaml:"strategy"` +} + +// Strategy for the job +type Strategy struct { + FailFast bool `yaml:"fail-fast"` + MaxParallel int `yaml:"max-parallel"` + Matrix map[string][]interface{} `yaml:"matrix"` } // Needs list for Job diff --git a/pkg/runner/command.go b/pkg/runner/command.go index 8088a1a..08c1ac9 100644 --- a/pkg/runner/command.go +++ b/pkg/runner/command.go @@ -64,10 +64,7 @@ func (rc *RunContext) setEnv(ctx context.Context, kvPairs map[string]string, arg } func (rc *RunContext) setOutput(ctx context.Context, kvPairs map[string]string, arg string) { common.Logger(ctx).Infof(" \U00002699 ::set-output:: %s=%s", kvPairs["name"], arg) - if rc.Outputs == nil { - rc.Outputs = make(map[string]string) - } - rc.Outputs[kvPairs["name"]] = arg + rc.StepResults[rc.CurrentStep].Outputs[kvPairs["name"]] = arg } func (rc *RunContext) addPath(ctx context.Context, arg string) { common.Logger(ctx).Infof(" \U00002699 ::add-path:: %s", arg) diff --git a/pkg/runner/command_test.go b/pkg/runner/command_test.go index ec41afb..4279bc0 100644 --- a/pkg/runner/command_test.go +++ b/pkg/runner/command_test.go @@ -21,10 +21,15 @@ func TestSetOutput(t *testing.T) { assert := assert.New(t) ctx := context.Background() rc := new(RunContext) + rc.StepResults = make(map[string]*stepResult) handler := rc.commandHandler(ctx) + rc.CurrentStep = "my-step" + rc.StepResults[rc.CurrentStep] = &stepResult{ + Outputs: make(map[string]string), + } handler("::set-output name=x::valz\n") - assert.Equal("valz", rc.Outputs["x"]) + assert.Equal("valz", rc.StepResults["my-step"].Outputs["x"]) } func TestAddpath(t *testing.T) { diff --git a/pkg/runner/expression.go b/pkg/runner/expression.go index 7e3c99c..e9ffb3d 100644 --- a/pkg/runner/expression.go +++ b/pkg/runner/expression.go @@ -11,6 +11,7 @@ import ( "regexp" "strings" + "github.com/nektos/act/pkg/model" "github.com/robertkrimen/otto" "github.com/sirupsen/logrus" "gopkg.in/godo.v2/glob" @@ -33,10 +34,25 @@ func (rc *RunContext) NewExpressionEvaluator() ExpressionEvaluator { } } +// NewStepExpressionEvaluator creates a new evaluator +func (rc *RunContext) NewStepExpressionEvaluator(step *model.Step) ExpressionEvaluator { + vm := rc.newVM() + configers := []func(*otto.Otto){ + rc.vmEnv(step), + } + for _, configer := range configers { + configer(vm) + } + + return &expressionEvaluator{ + vm, + } +} + // ExpressionEvaluator is the interface for evaluating expressions type ExpressionEvaluator interface { Evaluate(string) (string, error) - Interpolate(string) (string, error) + Interpolate(string) string } type expressionEvaluator struct { @@ -51,7 +67,7 @@ func (ee *expressionEvaluator) Evaluate(in string) (string, error) { return val.ToString() } -func (ee *expressionEvaluator) Interpolate(in string) (string, error) { +func (ee *expressionEvaluator) Interpolate(in string) string { errList := make([]error, 0) out := pattern.ReplaceAllStringFunc(in, func(match string) string { expression := strings.TrimPrefix(strings.TrimSuffix(match, suffix), prefix) @@ -62,9 +78,10 @@ func (ee *expressionEvaluator) Interpolate(in string) (string, error) { return evaluated }) if len(errList) > 0 { - return "", fmt.Errorf("Unable to interpolate string '%s' - %v", in, errList) + logrus.Errorf("Unable to interpolate string '%s' - %v", in, errList) + return in } - return out, nil + return out } func (rc *RunContext) newVM() *otto.Otto { @@ -76,16 +93,19 @@ func (rc *RunContext) newVM() *otto.Otto { vmJoin, vmToJSON, vmAlways, - vmCancelled, - rc.vmHashFiles(), + rc.vmCancelled(), rc.vmSuccess(), rc.vmFailure(), + rc.vmHashFiles(), rc.vmGithub(), - rc.vmEnv(), rc.vmJob(), rc.vmSteps(), rc.vmRunner(), + + rc.vmSecrets(), + rc.vmStrategy(), + rc.vmMatrix(), } vm := otto.New() for _, configer := range configers { @@ -183,14 +203,14 @@ func (rc *RunContext) vmHashFiles() func(*otto.Otto) { func (rc *RunContext) vmSuccess() func(*otto.Otto) { return func(vm *otto.Otto) { _ = vm.Set("success", func() bool { - return !rc.PriorStepFailed + return rc.getJobContext().Status == "success" }) } } func (rc *RunContext) vmFailure() func(*otto.Otto) { return func(vm *otto.Otto) { _ = vm.Set("failure", func() bool { - return rc.PriorStepFailed + return rc.getJobContext().Status == "failure" }) } } @@ -200,55 +220,31 @@ func vmAlways(vm *otto.Otto) { return true }) } -func vmCancelled(vm *otto.Otto) { - _ = vm.Set("cancelled", func() bool { - return false - }) +func (rc *RunContext) vmCancelled() func(vm *otto.Otto) { + return func(vm *otto.Otto) { + _ = vm.Set("cancelled", func() bool { + return rc.getJobContext().Status == "cancelled" + }) + } } func (rc *RunContext) vmGithub() func(*otto.Otto) { - github := map[string]interface{}{ - "event": make(map[string]interface{}), - "event_path": "/github/workflow/event.json", - "workflow": rc.Run.Workflow.Name, - "run_id": "1", - "run_number": "1", - "actor": "nektos/act", - - // TODO - "repository": "", - "event_name": "", - "sha": "", - "ref": "", - "head_ref": "", - "base_ref": "", - "token": "", - "workspace": rc.Config.Workdir, - "action": "", - } - - err := json.Unmarshal([]byte(rc.EventJSON), github["event"]) - if err != nil { - logrus.Error(err) - } + github := rc.getGithubContext() return func(vm *otto.Otto) { _ = vm.Set("github", github) } } -func (rc *RunContext) vmEnv() func(*otto.Otto) { - env := map[string]interface{}{} - // TODO - +func (rc *RunContext) vmEnv(step *model.Step) func(*otto.Otto) { return func(vm *otto.Otto) { + env := rc.StepEnv(step) _ = vm.Set("env", env) } } func (rc *RunContext) vmJob() func(*otto.Otto) { - job := map[string]interface{}{} - // TODO + job := rc.getJobContext() return func(vm *otto.Otto) { _ = vm.Set("job", job) @@ -256,8 +252,7 @@ func (rc *RunContext) vmJob() func(*otto.Otto) { } func (rc *RunContext) vmSteps() func(*otto.Otto) { - steps := map[string]interface{}{} - // TODO + steps := rc.getStepsContext() return func(vm *otto.Otto) { _ = vm.Set("steps", steps) @@ -275,3 +270,28 @@ func (rc *RunContext) vmRunner() func(*otto.Otto) { _ = vm.Set("runner", runner) } } + +func (rc *RunContext) vmSecrets() func(*otto.Otto) { + secrets := make(map[string]string) + return func(vm *otto.Otto) { + _ = vm.Set("secrets", secrets) + } +} + +func (rc *RunContext) vmStrategy() func(*otto.Otto) { + job := rc.Run.Job() + strategy := make(map[string]interface{}) + if job.Strategy != nil { + strategy["fail-fast"] = job.Strategy.FailFast + strategy["max-parallel"] = job.Strategy.MaxParallel + } + return func(vm *otto.Otto) { + _ = vm.Set("strategy", strategy) + } +} + +func (rc *RunContext) vmMatrix() func(*otto.Otto) { + return func(vm *otto.Otto) { + _ = vm.Set("matrix", rc.Matrix) + } +} diff --git a/pkg/runner/expression_test.go b/pkg/runner/expression_test.go index bf085d4..ec47907 100644 --- a/pkg/runner/expression_test.go +++ b/pkg/runner/expression_test.go @@ -17,6 +17,28 @@ func TestEvaluate(t *testing.T) { JobID: "job1", Workflow: &model.Workflow{ Name: "test-workflow", + Jobs: map[string]*model.Job{ + "job1": &model.Job{ + Strategy: &model.Strategy{ + Matrix: map[string][]interface{}{ + "os": []interface{}{"Linux", "Windows"}, + "foo": []interface{}{"bar", "baz"}, + }, + }, + }, + }, + }, + }, + Matrix: map[string]interface{}{ + "os": "Linux", + "foo": "bar", + }, + StepResults: map[string]*stepResult{ + "id1": &stepResult{ + Outputs: map[string]string{ + "foo": "bar", + }, + Success: true, }, }, } @@ -51,7 +73,11 @@ func TestEvaluate(t *testing.T) { {"github.actor", "nektos/act", ""}, {"github.run_id", "1", ""}, {"github.run_number", "1", ""}, + {"job.status", "success", ""}, + {"steps.id1.outputs.foo", "bar", ""}, {"runner.os", "Linux", ""}, + {"matrix.os", "Linux", ""}, + {"matrix.foo", "bar", ""}, } for _, table := range tables { @@ -60,10 +86,10 @@ func TestEvaluate(t *testing.T) { out, err := ee.Evaluate(table.in) if table.errMesg == "" { assert.NoError(err, table.in) - assert.Equal(table.out, out) + assert.Equal(table.out, out, table.in) } else { - assert.Error(err) - assert.Equal(table.errMesg, err.Error()) + assert.Error(err, table.in) + assert.Equal(table.errMesg, err.Error(), table.in) } }) } @@ -79,13 +105,15 @@ func TestInterpolate(t *testing.T) { JobID: "job1", Workflow: &model.Workflow{ Name: "test-workflow", + Jobs: map[string]*model.Job{ + "job1": &model.Job{}, + }, }, }, } ee := rc.NewExpressionEvaluator() - out, err := ee.Interpolate(" ${{1}} to ${{2}} ") + out := ee.Interpolate(" ${{1}} to ${{2}} ") - assert.NoError(err) assert.Equal(" 1 to 2 ", out) } diff --git a/pkg/runner/run_context.go b/pkg/runner/run_context.go index aca86de..35d3482 100644 --- a/pkg/runner/run_context.go +++ b/pkg/runner/run_context.go @@ -4,6 +4,7 @@ import ( "archive/tar" "bytes" "context" + "encoding/json" "fmt" "io" "io/ioutil" @@ -17,19 +18,26 @@ import ( "github.com/nektos/act/pkg/common" "github.com/nektos/act/pkg/model" + "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus" ) // RunContext contains info about current job type RunContext struct { - Config *Config - Run *model.Run - EventJSON string - Env map[string]string - Outputs map[string]string - Tempdir string - PriorStepFailed bool - ExtraPath []string + Config *Config + Matrix map[string]interface{} + Run *model.Run + EventJSON string + Env map[string]string + Tempdir string + ExtraPath []string + CurrentStep string + StepResults map[string]*stepResult +} + +type stepResult struct { + Success bool `json:"success"` + Outputs map[string]string `json:"outputs"` } // GetEnv returns the env for the context @@ -59,14 +67,18 @@ func (rc *RunContext) Executor() common.Executor { } s := step steps = append(steps, func(ctx context.Context) error { + rc.CurrentStep = s.ID + rc.StepResults[rc.CurrentStep] = &stepResult{ + Success: true, + Outputs: make(map[string]string), + } common.Logger(ctx).Infof("\u2B50 Run %s", s) err := rc.newStepExecutor(s)(ctx) if err == nil { common.Logger(ctx).Infof(" \u2705 Success - %s", s) - rc.PriorStepFailed = false } else { common.Logger(ctx).Errorf(" \u274C Failure - %s", s) - rc.PriorStepFailed = true + rc.StepResults[rc.CurrentStep].Success = false } return err }) @@ -207,3 +219,110 @@ func trimToLen(s string, l int) string { } return s } + +type jobContext struct { + Status string `json:"status"` + Container struct { + ID string `json:"id"` + Network string `json:"network"` + } `json:"container"` + Services map[string]struct { + ID string `json:"id"` + } `json:"services"` +} + +func (rc *RunContext) getJobContext() *jobContext { + jobStatus := "success" + for _, stepStatus := range rc.StepResults { + if !stepStatus.Success { + jobStatus = "failure" + break + } + } + return &jobContext{ + Status: jobStatus, + } +} + +func (rc *RunContext) getStepsContext() map[string]*stepResult { + return rc.StepResults +} + +type githubContext struct { + Event map[string]interface{} `json:"event"` + EventPath string `json:"event_path"` + Workflow string `json:"workflow"` + RunID string `json:"run_id"` + RunNumber string `json:"run_number"` + Actor string `json:"actor"` + Repository string `json:"repository"` + EventName string `json:"event_name"` + Sha string `json:"sha"` + Ref string `json:"ref"` + HeadRef string `json:"head_ref"` + BaseRef string `json:"base_ref"` + Token string `json:"token"` + Workspace string `json:"workspace"` + Action string `json:"action"` +} + +func (rc *RunContext) getGithubContext() *githubContext { + ghc := &githubContext{ + Event: make(map[string]interface{}), + EventPath: "/github/workflow/event.json", + Workflow: rc.Run.Workflow.Name, + RunID: "1", + RunNumber: "1", + Actor: "nektos/act", + + EventName: rc.Config.EventName, + Token: os.Getenv("GITHUB_TOKEN"), + Workspace: "/github/workspace", + Action: rc.CurrentStep, + } + + repoPath := rc.Config.Workdir + repo, err := common.FindGithubRepo(repoPath) + if err != nil { + log.Warningf("unable to get git repo: %v", err) + } else { + ghc.Repository = repo + } + + _, sha, err := common.FindGitRevision(repoPath) + if err != nil { + log.Warningf("unable to get git revision: %v", err) + } else { + ghc.Sha = sha + } + + ref, err := common.FindGitRef(repoPath) + if err != nil { + log.Warningf("unable to get git ref: %v", err) + } else { + log.Debugf("using github ref: %s", ref) + ghc.Ref = ref + } + err = json.Unmarshal([]byte(rc.EventJSON), &ghc.Event) + if err != nil { + logrus.Error(err) + } + return ghc +} + +func (rc *RunContext) withGithubEnv(env map[string]string) map[string]string { + github := rc.getGithubContext() + env["HOME"] = "/github/home" + env["GITHUB_WORKFLOW"] = github.Workflow + env["GITHUB_RUN_ID"] = github.RunID + env["GITHUB_RUN_NUMBER"] = github.RunNumber + env["GITHUB_ACTION"] = github.Action + env["GITHUB_ACTOR"] = github.Actor + env["GITHUB_REPOSITORY"] = github.Repository + env["GITHUB_EVENT_NAME"] = github.EventName + env["GITHUB_EVENT_PATH"] = github.EventPath + env["GITHUB_WORKSPACE"] = github.Workspace + env["GITHUB_SHA"] = github.Sha + env["GITHUB_REF"] = github.Ref + return env +} diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go index a2b968b..c870ae4 100644 --- a/pkg/runner/runner.go +++ b/pkg/runner/runner.go @@ -12,7 +12,7 @@ import ( // Runner provides capabilities to run GitHub actions type Runner interface { NewPlanExecutor(plan *model.Plan) common.Executor - NewRunExecutor(run *model.Run) common.Executor + NewRunExecutor(run *model.Run, matrix map[string]interface{}) common.Executor } // Config contains the config for a new runner @@ -53,7 +53,18 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor { for _, stage := range plan.Stages { stageExecutor := make([]common.Executor, 0) for _, run := range stage.Runs { - stageExecutor = append(stageExecutor, runner.NewRunExecutor(run)) + // TODO - don't just grab first index of each dimension + matrix := make(map[string]interface{}) + if run.Job().Strategy != nil { + for mkey, mvals := range run.Job().Strategy.Matrix { + if mkey == "include" || mkey == "exclude" { + continue + } + matrix[mkey] = mvals[0] + } + } + + stageExecutor = append(stageExecutor, runner.NewRunExecutor(run, matrix)) } pipeline = append(pipeline, common.NewParallelExecutor(stageExecutor...)) } @@ -61,11 +72,13 @@ func (runner *runnerImpl) NewPlanExecutor(plan *model.Plan) common.Executor { return common.NewPipelineExecutor(pipeline...) } -func (runner *runnerImpl) NewRunExecutor(run *model.Run) common.Executor { +func (runner *runnerImpl) NewRunExecutor(run *model.Run, matrix map[string]interface{}) common.Executor { rc := new(RunContext) rc.Config = runner.config rc.Run = run rc.EventJSON = runner.eventJSON + rc.StepResults = make(map[string]*stepResult) + rc.Matrix = matrix return func(ctx context.Context) error { ctx = WithJobLogger(ctx, rc.Run.String()) return rc.Executor()(ctx) diff --git a/pkg/runner/step.go b/pkg/runner/step.go index ba7fa7a..2993621 100644 --- a/pkg/runner/step.go +++ b/pkg/runner/step.go @@ -16,11 +16,16 @@ import ( ) func (rc *RunContext) newStepExecutor(step *model.Step) common.Executor { + ee := rc.NewStepExpressionEvaluator(step) job := rc.Run.Job() containerSpec := new(model.ContainerSpec) - containerSpec.Env = rc.StepEnv(step) + containerSpec.Env = rc.withGithubEnv(rc.StepEnv(step)) containerSpec.Name = rc.createContainerName(step.ID) + for k, v := range containerSpec.Env { + containerSpec.Env[k] = ee.Interpolate(v) + } + switch step.Type() { case model.StepTypeRun: if job.Container != nil { @@ -29,7 +34,7 @@ func (rc *RunContext) newStepExecutor(step *model.Step) common.Executor { containerSpec.Volumes = job.Container.Volumes containerSpec.Options = job.Container.Options } else { - containerSpec.Image = platformImage(job.RunsOn) + containerSpec.Image = platformImage(ee.Interpolate(job.RunsOn)) } return common.NewPipelineExecutor( rc.setupShellCommand(containerSpec, step.Shell, step.Run), @@ -97,44 +102,11 @@ func applyWith(containerSpec *model.ContainerSpec, step *model.Step) common.Exec // StepEnv returns the env for a step func (rc *RunContext) StepEnv(step *model.Step) map[string]string { - env := make(map[string]string) - env["HOME"] = "/github/home" - env["GITHUB_WORKFLOW"] = rc.Run.Workflow.Name - env["GITHUB_RUN_ID"] = "1" - env["GITHUB_RUN_NUMBER"] = "1" - env["GITHUB_ACTION"] = step.ID - env["GITHUB_ACTOR"] = "nektos/act" - - repoPath := rc.Config.Workdir - repo, err := common.FindGithubRepo(repoPath) - if err != nil { - log.Warningf("unable to get git repo: %v", err) - } else { - env["GITHUB_REPOSITORY"] = repo - } - env["GITHUB_EVENT_NAME"] = rc.Config.EventName - env["GITHUB_EVENT_PATH"] = "/github/workflow/event.json" - env["GITHUB_WORKSPACE"] = "/github/workspace" - - _, rev, err := common.FindGitRevision(repoPath) - if err != nil { - log.Warningf("unable to get git revision: %v", err) - } else { - env["GITHUB_SHA"] = rev - } - - ref, err := common.FindGitRef(repoPath) - if err != nil { - log.Warningf("unable to get git ref: %v", err) - } else { - log.Debugf("using github ref: %s", ref) - env["GITHUB_REF"] = ref - } job := rc.Run.Job() if job.Container != nil { - return mergeMaps(rc.GetEnv(), job.Container.Env, step.GetEnv(), env) + return mergeMaps(rc.GetEnv(), job.Container.Env, step.GetEnv()) } - return mergeMaps(rc.GetEnv(), step.GetEnv(), env) + return mergeMaps(rc.GetEnv(), step.GetEnv()) } func (rc *RunContext) setupShellCommand(containerSpec *model.ContainerSpec, shell string, run string) common.Executor { diff --git a/pkg/runner/testdata/matrix/push.yml b/pkg/runner/testdata/matrix/push.yml new file mode 100644 index 0000000..c31d2ba --- /dev/null +++ b/pkg/runner/testdata/matrix/push.yml @@ -0,0 +1,14 @@ +name: matrix +on: push + +jobs: + build: + runs-on: ${{ matrix.os }} + steps: + - run: echo ${NODE_VERSION} | grep 4 + env: + NODE_VERSION: ${{ matrix.node }} + strategy: + matrix: + os: [ubuntu-18.04, macos-latest] + node: [4, 6, 8, 10]