From 9868e1377274a7657d4aac6bd2b66c0d564405cb Mon Sep 17 00:00:00 2001 From: ChristopherHX Date: Wed, 22 Dec 2021 20:19:50 +0100 Subject: [PATCH] Feature: uses in composite (#793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feature: uses in composite * Negate logic * Reduce complexity * Update step_context.go * Update step_context.go * Update step_context.go * Fix syntax error in test * Bump * Disable usage of actions/setup-node@v2 * Bump * Fix step id collision * Fix output command workaround * Make secrets context inaccessible in composite * Fix order after adding a workaround (needs tests) Fixes https://github.com/nektos/act/pull/793#issuecomment-922329838 * Evaluate env before passing one step deeper If env would contain any inputs, steps ctx or secrets there was undefined behaviour * [no ci] prepare secret test * Initial test pass inputs as env * Fix syntax error * extend test also for direct invoke * Fix passing provided env as composite output * Fix syntax error * toUpper 'no such secret', act has a bug * fix indent * Fix env outputs in composite * Test env outputs of composite * Fix inputs not defined in docker actions * Fix interpolate args input of docker actions * Fix lint * AllowCompositeIf now defaults to true see https://github.com/actions/runner/releases/tag/v2.284.0 * Fix lint * Fix env of docker action.yml * Test calling a local docker action from composite With input context hirachy * local-action-dockerfile Test pass on action/runner It seems action/runner ignores overrides of args, if the target docker action has the args property set. * Fix exec permissions of docker-local-noargs * Revert getStepsContext change * fix: handle composite action on error and continue This change is a follow up of https://github.com/nektos/act/pull/840 and integrates with https://github.com/nektos/act/pull/793 There are two things included here: - The default value for a step.if in an action need to be 'success()' - We need to hand the error from a composite action back to the calling executor Co-authored-by: Björn Brauer * Patch inputs can be bool, float64 and string for workflow_call Also inputs is now always defined, but may be null * Simplify cherry-picked commit * Minor style adjustments * Remove chmod +x from tests now fails on windows like before * Fix GITHUB_ACTION_PATH some action env vars Fixes GITHUB_ACTION_REPOSITORY, GITHUB_ACTION_REF. * Add comment to CompositeRestrictions Co-authored-by: Markus Wolf Co-authored-by: Björn Brauer Co-authored-by: Ryan Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- pkg/model/action.go | 13 +- pkg/model/workflow.go | 28 ++- pkg/runner/expression.go | 25 +- pkg/runner/run_context.go | 70 ++++-- pkg/runner/runner.go | 51 +++-- pkg/runner/runner_test.go | 3 + pkg/runner/step_context.go | 214 +++++++++++------- .../actions/docker-local-noargs/Dockerfile | 8 + .../actions/docker-local-noargs/action.yml | 15 ++ .../actions/docker-local-noargs/entrypoint.sh | 8 + .../testdata/local-action-dockerfile/push.yml | 9 + .../action.yml | 44 ++++ .../push.yml | 9 + .../composite_action2/action.yml | 11 + .../uses-composite-with-error/push.yml | 12 + .../composite_action/action.yml | 12 +- pkg/runner/testdata/uses-composite/push.yml | 14 +- .../composite_action2/action.yml | 63 ++++++ .../testdata/uses-nested-composite/push.yml | 15 ++ 19 files changed, 465 insertions(+), 159 deletions(-) create mode 100644 pkg/runner/testdata/actions/docker-local-noargs/Dockerfile create mode 100644 pkg/runner/testdata/actions/docker-local-noargs/action.yml create mode 100755 pkg/runner/testdata/actions/docker-local-noargs/entrypoint.sh create mode 100644 pkg/runner/testdata/local-action-via-composite-dockerfile/action.yml create mode 100644 pkg/runner/testdata/local-action-via-composite-dockerfile/push.yml create mode 100644 pkg/runner/testdata/uses-composite-with-error/composite_action2/action.yml create mode 100644 pkg/runner/testdata/uses-composite-with-error/push.yml create mode 100644 pkg/runner/testdata/uses-nested-composite/composite_action2/action.yml create mode 100644 pkg/runner/testdata/uses-nested-composite/push.yml diff --git a/pkg/model/action.go b/pkg/model/action.go index c57ed2c9..19ec0583 100644 --- a/pkg/model/action.go +++ b/pkg/model/action.go @@ -83,5 +83,16 @@ type Output struct { func ReadAction(in io.Reader) (*Action, error) { a := new(Action) err := yaml.NewDecoder(in).Decode(a) - return a, err + if err != nil { + return nil, err + } + + for i := range a.Runs.Steps { + step := &a.Runs.Steps[i] + if step.If.Value == "" { + step.If.Value = "success()" + } + } + + return a, nil } diff --git a/pkg/model/workflow.go b/pkg/model/workflow.go index bf06d3a3..21582b06 100644 --- a/pkg/model/workflow.go +++ b/pkg/model/workflow.go @@ -23,6 +23,21 @@ type Workflow struct { Defaults Defaults `yaml:"defaults"` } +// CompositeRestrictions is the structure to control what is allowed in composite actions +type CompositeRestrictions struct { + AllowCompositeUses bool + AllowCompositeIf bool + AllowCompositeContinueOnError bool +} + +func defaultCompositeRestrictions() *CompositeRestrictions { + return &CompositeRestrictions{ + AllowCompositeUses: true, + AllowCompositeIf: true, + AllowCompositeContinueOnError: false, + } +} + // On events for the workflow func (w *Workflow) On() []string { switch w.RawOn.Kind { @@ -411,11 +426,18 @@ func (s *Step) Type() StepType { return StepTypeUsesActionRemote } -func (s *Step) Validate() error { - if s.Type() != StepTypeRun { +func (s *Step) Validate(config *CompositeRestrictions) error { + if config == nil { + config = defaultCompositeRestrictions() + } + if s.Type() != StepTypeRun && !config.AllowCompositeUses { return fmt.Errorf("(StepID: %s): Unexpected value 'uses'", s.String()) - } else if s.Shell == "" { + } else if s.Type() == StepTypeRun && s.Shell == "" { return fmt.Errorf("(StepID: %s): Required property is missing: 'shell'", s.String()) + } else if !s.If.IsZero() && !config.AllowCompositeIf { + return fmt.Errorf("(StepID: %s): Property is not available: 'if'", s.String()) + } else if s.ContinueOnError && !config.AllowCompositeContinueOnError { + return fmt.Errorf("(StepID: %s): Property is not available: 'continue-on-error'", s.String()) } return nil } diff --git a/pkg/runner/expression.go b/pkg/runner/expression.go index 72ff56c3..e025f3ad 100644 --- a/pkg/runner/expression.go +++ b/pkg/runner/expression.go @@ -38,8 +38,6 @@ func (sc *StepContext) NewExpressionEvaluator() ExpressionEvaluator { vm := sc.RunContext.newVM() configers := []func(*otto.Otto){ sc.vmEnv(), - sc.vmInputs(), - sc.vmNeeds(), sc.vmSuccess(), sc.vmFailure(), @@ -237,6 +235,7 @@ func (rc *RunContext) newVM() *otto.Otto { rc.vmMatrix(), rc.vmEnv(), rc.vmNeeds(), + rc.vmInputs(), } vm := otto.New() for _, configer := range configers { @@ -447,22 +446,9 @@ func (sc *StepContext) vmEnv() func(*otto.Otto) { } } -func (sc *StepContext) vmInputs() func(*otto.Otto) { - inputs := make(map[string]string) - - // Set Defaults - if sc.Action != nil { - for k, input := range sc.Action.Inputs { - inputs[k] = sc.RunContext.NewExpressionEvaluator().Interpolate(input.Default) - } - } - - for k, v := range sc.Step.With { - inputs[k] = sc.RunContext.NewExpressionEvaluator().Interpolate(v) - } - +func (rc *RunContext) vmInputs() func(*otto.Otto) { return func(vm *otto.Otto) { - _ = vm.Set("inputs", inputs) + _ = vm.Set("inputs", rc.Inputs) } } @@ -587,7 +573,10 @@ func (rc *RunContext) vmRunner() func(*otto.Otto) { func (rc *RunContext) vmSecrets() func(*otto.Otto) { return func(vm *otto.Otto) { - _ = vm.Set("secrets", rc.Config.Secrets) + // Hide secrets from composite actions + if rc.Composite == nil { + _ = vm.Set("secrets", rc.Config.Secrets) + } } } diff --git a/pkg/runner/run_context.go b/pkg/runner/run_context.go index ea39dd01..fb9ae3e8 100644 --- a/pkg/runner/run_context.go +++ b/pkg/runner/run_context.go @@ -27,19 +27,35 @@ const ActPath string = "/var/run/act" // RunContext contains info about current job type RunContext struct { - Name string - Config *Config - Matrix map[string]interface{} - Run *model.Run - EventJSON string - Env map[string]string - ExtraPath []string - CurrentStep string - StepResults map[string]*stepResult - ExprEval ExpressionEvaluator - JobContainer container.Container - OutputMappings map[MappableOutput]MappableOutput - JobName string + Name string + Config *Config + Matrix map[string]interface{} + Run *model.Run + EventJSON string + Env map[string]string + ExtraPath []string + CurrentStep string + StepResults map[string]*stepResult + ExprEval ExpressionEvaluator + JobContainer container.Container + OutputMappings map[MappableOutput]MappableOutput + JobName string + ActionPath string + ActionRef string + ActionRepository string + Composite *model.Action + Inputs map[string]interface{} + Parent *RunContext +} + +func (rc *RunContext) Clone() *RunContext { + clone := *rc + clone.CurrentStep = "" + clone.Composite = nil + clone.Inputs = nil + clone.StepResults = make(map[string]*stepResult) + clone.Parent = rc + return &clone } type MappableOutput struct { @@ -310,6 +326,22 @@ func (rc *RunContext) Executor() common.Executor { }).If(rc.isEnabled) } +// Executor returns a pipeline executor for all the steps in the job +func (rc *RunContext) CompositeExecutor() common.Executor { + steps := make([]common.Executor, 0) + + for i, step := range rc.Composite.Runs.Steps { + if step.ID == "" { + step.ID = fmt.Sprintf("%d", i) + } + stepcopy := step + steps = append(steps, rc.newStepExecutor(&stepcopy)) + } + + steps = append(steps, common.JobError) + return common.NewPipelineExecutor(steps...) +} + func (rc *RunContext) newStepExecutor(step *model.Step) common.Executor { sc := &StepContext{ RunContext: rc, @@ -568,9 +600,9 @@ func (rc *RunContext) getGithubContext() *githubContext { Workspace: rc.Config.ContainerWorkdir(), Action: rc.CurrentStep, Token: rc.Config.Secrets["GITHUB_TOKEN"], - ActionPath: rc.Config.Env["GITHUB_ACTION_PATH"], - ActionRef: rc.Config.Env["RUNNER_ACTION_REF"], - ActionRepository: rc.Config.Env["RUNNER_ACTION_REPOSITORY"], + ActionPath: rc.ActionPath, + ActionRef: rc.ActionRef, + ActionRepository: rc.ActionRepository, RepositoryOwner: rc.Config.Env["GITHUB_REPOSITORY_OWNER"], RetentionDays: rc.Config.Env["GITHUB_RETENTION_DAYS"], RunnerPerflog: rc.Config.Env["RUNNER_PERFLOG"], @@ -737,9 +769,9 @@ func (rc *RunContext) withGithubEnv(env map[string]string) map[string]string { env["GITHUB_RUN_ID"] = github.RunID env["GITHUB_RUN_NUMBER"] = github.RunNumber env["GITHUB_ACTION"] = github.Action - if github.ActionPath != "" { - env["GITHUB_ACTION_PATH"] = github.ActionPath - } + env["GITHUB_ACTION_PATH"] = github.ActionPath + env["GITHUB_ACTION_REPOSITORY"] = github.ActionRepository + env["GITHUB_ACTION_REF"] = github.ActionRef env["GITHUB_ACTIONS"] = "true" env["GITHUB_ACTOR"] = github.Actor env["GITHUB_REPOSITORY"] = github.Repository diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go index 34fab71d..72c63411 100644 --- a/pkg/runner/runner.go +++ b/pkg/runner/runner.go @@ -21,31 +21,32 @@ type Runner interface { // Config contains the config for a new runner type Config struct { - Actor string // the user that triggered the event - Workdir string // path to working directory - BindWorkdir bool // bind the workdir to the job container - EventName string // name of event to run - EventPath string // path to JSON file to use for event.json in containers - DefaultBranch string // name of the main branch for this repository - ReuseContainers bool // reuse containers to maintain state - ForcePull bool // force pulling of the image, even if already present - ForceRebuild bool // force rebuilding local docker image action - LogOutput bool // log the output from docker run - Env map[string]string // env for containers - Secrets map[string]string // list of secrets - InsecureSecrets bool // switch hiding output when printing to terminal - Platforms map[string]string // list of platforms - Privileged bool // use privileged mode - UsernsMode string // user namespace to use - ContainerArchitecture string // Desired OS/architecture platform for running containers - ContainerDaemonSocket string // Path to Docker daemon socket - UseGitIgnore bool // controls if paths in .gitignore should not be copied into container, default true - GitHubInstance string // GitHub instance to use, default "github.com" - ContainerCapAdd []string // list of kernel capabilities to add to the containers - ContainerCapDrop []string // list of kernel capabilities to remove from the containers - AutoRemove bool // controls if the container is automatically removed upon workflow completion - ArtifactServerPath string // the path where the artifact server stores uploads - ArtifactServerPort string // the port the artifact server binds to + Actor string // the user that triggered the event + Workdir string // path to working directory + BindWorkdir bool // bind the workdir to the job container + EventName string // name of event to run + EventPath string // path to JSON file to use for event.json in containers + DefaultBranch string // name of the main branch for this repository + ReuseContainers bool // reuse containers to maintain state + ForcePull bool // force pulling of the image, even if already present + ForceRebuild bool // force rebuilding local docker image action + LogOutput bool // log the output from docker run + Env map[string]string // env for containers + Secrets map[string]string // list of secrets + InsecureSecrets bool // switch hiding output when printing to terminal + Platforms map[string]string // list of platforms + Privileged bool // use privileged mode + UsernsMode string // user namespace to use + ContainerArchitecture string // Desired OS/architecture platform for running containers + ContainerDaemonSocket string // Path to Docker daemon socket + UseGitIgnore bool // controls if paths in .gitignore should not be copied into container, default true + GitHubInstance string // GitHub instance to use, default "github.com" + ContainerCapAdd []string // list of kernel capabilities to add to the containers + ContainerCapDrop []string // list of kernel capabilities to remove from the containers + AutoRemove bool // controls if the container is automatically removed upon workflow completion + ArtifactServerPath string // the path where the artifact server stores uploads + ArtifactServerPort string // the port the artifact server binds to + CompositeRestrictions *model.CompositeRestrictions // describes which features are available in composite actions } // Resolves the equivalent host path inside the container diff --git a/pkg/runner/runner_test.go b/pkg/runner/runner_test.go index dc312573..37774e7d 100644 --- a/pkg/runner/runner_test.go +++ b/pkg/runner/runner_test.go @@ -112,6 +112,7 @@ func TestRunEvent(t *testing.T) { {"testdata", "remote-action-js", "push", "", platforms, ""}, {"testdata", "local-action-docker-url", "push", "", platforms, ""}, {"testdata", "local-action-dockerfile", "push", "", platforms, ""}, + {"testdata", "local-action-via-composite-dockerfile", "push", "", platforms, ""}, {"testdata", "local-action-js", "push", "", platforms, ""}, {"testdata", "matrix", "push", "", platforms, ""}, {"testdata", "matrix-include-exclude", "push", "", platforms, ""}, @@ -119,6 +120,8 @@ func TestRunEvent(t *testing.T) { {"testdata", "workdir", "push", "", platforms, ""}, {"testdata", "defaults-run", "push", "", platforms, ""}, {"testdata", "uses-composite", "push", "", platforms, ""}, + {"testdata", "uses-composite-with-error", "push", "Job 'failing-composite-action' failed", platforms, ""}, + {"testdata", "uses-nested-composite", "push", "", platforms, ""}, {"testdata", "issue-597", "push", "", platforms, ""}, {"testdata", "issue-598", "push", "", platforms, ""}, {"testdata", "env-and-path", "push", "", platforms, ""}, diff --git a/pkg/runner/step_context.go b/pkg/runner/step_context.go index 1485f697..6c15bcc1 100644 --- a/pkg/runner/step_context.go +++ b/pkg/runner/step_context.go @@ -66,7 +66,7 @@ func (sc *StepContext) Executor(ctx context.Context) common.Executor { actionDir := filepath.Join(rc.Config.Workdir, step.Uses) return common.NewPipelineExecutor( sc.setupAction(actionDir, "", true), - sc.runAction(actionDir, "", true), + sc.runAction(actionDir, "", "", "", true), ) case model.StepTypeUsesActionRemote: remoteAction := newRemoteAction(step.Uses) @@ -105,7 +105,7 @@ func (sc *StepContext) Executor(ctx context.Context) common.Executor { return common.NewPipelineExecutor( ntErr, sc.setupAction(actionDir, remoteAction.Path, false), - sc.runAction(actionDir, remoteAction.Path, false), + sc.runAction(actionDir, remoteAction.Path, remoteAction.Repo, remoteAction.Ref, false), ) case model.StepTypeInvalid: return common.NewErrorExecutor(fmt.Errorf("Invalid run/uses syntax for job:%s step:%+v", rc.Run, step)) @@ -228,6 +228,14 @@ func (sc *StepContext) setupShell() { } } +func getScriptName(rc *RunContext, step *model.Step) string { + scriptName := step.ID + for rcs := rc; rcs.Parent != nil; rcs = rcs.Parent { + scriptName = fmt.Sprintf("%s-composite-%s", rcs.Parent.CurrentStep, scriptName) + } + return fmt.Sprintf("workflow/%s", scriptName) +} + // TODO: Currently we just ignore top level keys, BUT we should return proper error on them // BUTx2 I leave this for when we rewrite act to use actionlint for workflow validation // so we return proper errors before any execution or spawning containers @@ -243,7 +251,7 @@ func (sc *StepContext) setupShellCommand() (name, script string, err error) { scCmd := step.ShellCommand() - name = fmt.Sprintf("workflow/%s", step.ID) + name = getScriptName(sc.RunContext, step) // Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L47-L64 // Reference: https://github.com/actions/runner/blob/8109c962f09d9acc473d92c595ff43afceddb347/src/Runner.Worker/Handlers/ScriptHandlerHelpers.cs#L19-L27 @@ -305,13 +313,6 @@ func (sc *StepContext) newStepContainer(ctx context.Context, image string, cmd [ for k, v := range sc.Env { envList = append(envList, fmt.Sprintf("%s=%s", k, v)) } - stepEE := sc.NewExpressionEvaluator() - for i, v := range cmd { - cmd[i] = stepEE.Interpolate(v) - } - for i, v := range entrypoint { - entrypoint[i] = stepEE.Interpolate(v) - } envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache")) envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_OS", "Linux")) @@ -345,11 +346,12 @@ func (sc *StepContext) runUsesContainer() common.Executor { step := sc.Step return func(ctx context.Context) error { image := strings.TrimPrefix(step.Uses, "docker://") - cmd, err := shellquote.Split(sc.RunContext.NewExpressionEvaluator().Interpolate(step.With["args"])) + eval := sc.RunContext.NewExpressionEvaluator() + cmd, err := shellquote.Split(eval.Interpolate(step.With["args"])) if err != nil { return err } - entrypoint := strings.Fields(step.With["entrypoint"]) + entrypoint := strings.Fields(eval.Interpolate(step.With["entrypoint"])) stepContainer := sc.newStepContainer(ctx, image, cmd, entrypoint) return common.NewPipelineExecutor( @@ -482,10 +484,21 @@ func (sc *StepContext) getContainerActionPaths(step *model.Step, actionDir strin return actionName, containerActionDir } -func (sc *StepContext) runAction(actionDir string, actionPath string, localAction bool) common.Executor { +func (sc *StepContext) runAction(actionDir string, actionPath string, actionRepository string, actionRef string, localAction bool) common.Executor { rc := sc.RunContext step := sc.Step return func(ctx context.Context) error { + // Backup the parent composite action path and restore it on continue + parentActionPath := rc.ActionPath + parentActionRepository := rc.ActionRepository + parentActionRef := rc.ActionRef + defer func() { + rc.ActionPath = parentActionPath + rc.ActionRef = parentActionRef + rc.ActionRepository = parentActionRepository + }() + rc.ActionRef = actionRef + rc.ActionRepository = actionRepository action := sc.Action log.Debugf("About to run action %v", action) sc.populateEnvsFromInput(action, rc) @@ -497,17 +510,10 @@ func (sc *StepContext) runAction(actionDir string, actionPath string, localActio } actionName, containerActionDir := sc.getContainerActionPaths(step, actionLocation, rc) - sc.Env = mergeMaps(sc.Env, action.Runs.Env) - - ee := sc.NewExpressionEvaluator() - for k, v := range sc.Env { - sc.Env[k] = ee.Interpolate(v) - } - log.Debugf("type=%v actionDir=%s actionPath=%s workdir=%s actionCacheDir=%s actionName=%s containerActionDir=%s", step.Type(), actionDir, actionPath, rc.Config.Workdir, rc.ActionCacheDir(), actionName, containerActionDir) maybeCopyToActionDir := func() error { - sc.Env["GITHUB_ACTION_PATH"] = containerActionDir + rc.ActionPath = containerActionDir if step.Type() != model.StepTypeUsesActionRemote { return nil } @@ -547,6 +553,37 @@ func (sc *StepContext) runAction(actionDir string, actionPath string, localActio } } +func (sc *StepContext) evalDockerArgs(action *model.Action, cmd *[]string) { + rc := sc.RunContext + step := sc.Step + oldInputs := rc.Inputs + defer func() { + rc.Inputs = oldInputs + }() + inputs := make(map[string]interface{}) + eval := sc.RunContext.NewExpressionEvaluator() + // Set Defaults + for k, input := range action.Inputs { + inputs[k] = eval.Interpolate(input.Default) + } + if step.With != nil { + for k, v := range step.With { + inputs[k] = eval.Interpolate(v) + } + } + rc.Inputs = inputs + stepEE := sc.NewExpressionEvaluator() + for i, v := range *cmd { + (*cmd)[i] = stepEE.Interpolate(v) + } + sc.Env = mergeMaps(sc.Env, action.Runs.Env) + + ee := sc.NewExpressionEvaluator() + for k, v := range sc.Env { + sc.Env[k] = ee.Interpolate(v) + } +} + // TODO: break out parts of function to reduce complexicity // nolint:gocyclo func (sc *StepContext) execAsDocker(ctx context.Context, action *model.Action, actionName string, containerLocation string, actionLocation string, rc *RunContext, step *model.Step, localAction bool) error { @@ -601,15 +638,16 @@ func (sc *StepContext) execAsDocker(ctx context.Context, action *model.Action, a log.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture) } } - - cmd, err := shellquote.Split(step.With["args"]) + eval := sc.NewExpressionEvaluator() + cmd, err := shellquote.Split(eval.Interpolate(step.With["args"])) if err != nil { return err } if len(cmd) == 0 { cmd = action.Runs.Args + sc.evalDockerArgs(action, &cmd) } - entrypoint := strings.Fields(step.With["entrypoint"]) + entrypoint := strings.Fields(eval.Interpolate(step.With["entrypoint"])) if len(entrypoint) == 0 { if action.Runs.Entrypoint != "" { entrypoint, err = shellquote.Split(action.Runs.Entrypoint) @@ -638,74 +676,74 @@ func (sc *StepContext) execAsComposite(ctx context.Context, step *model.Step, _ if err != nil { return err } - for outputName, output := range action.Outputs { - re := regexp.MustCompile(`\${{ steps\.([a-zA-Z_][a-zA-Z0-9_-]+)\.outputs\.([a-zA-Z_][a-zA-Z0-9_-]+) }}`) - matches := re.FindStringSubmatch(output.Value) - if len(matches) > 2 { - if sc.RunContext.OutputMappings == nil { - sc.RunContext.OutputMappings = make(map[MappableOutput]MappableOutput) - } - - k := MappableOutput{StepID: matches[1], OutputName: matches[2]} - v := MappableOutput{StepID: step.ID, OutputName: outputName} - sc.RunContext.OutputMappings[k] = v - } - } - - executors := make([]common.Executor, 0, len(action.Runs.Steps)) - stepID := 0 + // Disable some features of composite actions, only for feature parity with github for _, compositeStep := range action.Runs.Steps { - stepClone := compositeStep - // Take a copy of the run context structure (rc is a pointer) - // Then take the address of the new structure - rcCloneStr := *rc - rcClone := &rcCloneStr - if stepClone.ID == "" { - stepClone.ID = fmt.Sprintf("composite-%d", stepID) - stepID++ - } - rcClone.CurrentStep = stepClone.ID - - if err := compositeStep.Validate(); err != nil { + if err := compositeStep.Validate(rc.Config.CompositeRestrictions); err != nil { return err } - - // Setup the outputs for the composite steps - if _, ok := rcClone.StepResults[stepClone.ID]; !ok { - rcClone.StepResults[stepClone.ID] = &stepResult{ - Conclusion: stepStatusSuccess, - Outputs: make(map[string]string), - } - } - - env := stepClone.Environment() - stepContext := StepContext{ - RunContext: rcClone, - Step: step, - Env: mergeMaps(sc.Env, env), - Action: action, - } - - // Required to set github.action_path - if rcClone.Config.Env == nil { - // Workaround to get test working - rcClone.Config.Env = make(map[string]string) - } - rcClone.Config.Env["GITHUB_ACTION_PATH"] = sc.Env["GITHUB_ACTION_PATH"] - ev := stepContext.NewExpressionEvaluator() - // Required to interpolate inputs and github.action_path into the env map - stepContext.interpolateEnv(ev) - // Required to interpolate inputs, env and github.action_path into run steps - ev = stepContext.NewExpressionEvaluator() - stepClone.Run = ev.Interpolate(stepClone.Run) - stepClone.Shell = ev.Interpolate(stepClone.Shell) - stepClone.WorkingDirectory = ev.Interpolate(stepClone.WorkingDirectory) - - stepContext.Step = &stepClone - - executors = append(executors, stepContext.Executor(ctx)) } - return common.NewPipelineExecutor(executors...)(ctx) + inputs := make(map[string]interface{}) + eval := sc.RunContext.NewExpressionEvaluator() + // Set Defaults + for k, input := range action.Inputs { + inputs[k] = eval.Interpolate(input.Default) + } + if step.With != nil { + for k, v := range step.With { + inputs[k] = eval.Interpolate(v) + } + } + // Doesn't work with the command processor has a pointer to the original rc + // compositerc := rc.Clone() + // Workaround start + backup := *rc + defer func() { *rc = backup }() + *rc = *rc.Clone() + scriptName := backup.CurrentStep + for rcs := &backup; rcs.Parent != nil; rcs = rcs.Parent { + scriptName = fmt.Sprintf("%s-composite-%s", rcs.Parent.CurrentStep, scriptName) + } + compositerc := rc + compositerc.Parent = &RunContext{ + CurrentStep: scriptName, + } + // Workaround end + compositerc.Composite = action + envToEvaluate := mergeMaps(compositerc.Env, step.Environment()) + compositerc.Env = make(map[string]string) + // origEnvMap: is used to pass env changes back to parent runcontext + origEnvMap := make(map[string]string) + for k, v := range envToEvaluate { + ev := eval.Interpolate(v) + origEnvMap[k] = ev + compositerc.Env[k] = ev + } + compositerc.Inputs = inputs + compositerc.ExprEval = compositerc.NewExpressionEvaluator() + err = compositerc.CompositeExecutor()(ctx) + if err != nil { + return err + } + + // Map outputs to parent rc + eval = (&StepContext{ + Env: compositerc.Env, + RunContext: compositerc, + }).NewExpressionEvaluator() + for outputName, output := range action.Outputs { + backup.setOutput(ctx, map[string]string{ + "name": outputName, + }, eval.Interpolate(output.Value)) + } + // Test if evaluated parent env was altered by this composite step + // Known Issues: + // - you try to set an env variable to the same value as a scoped step env, will be discared + for k, v := range compositerc.Env { + if ov, ok := origEnvMap[k]; !ok || ov != v { + backup.Env[k] = v + } + } + return nil } func (sc *StepContext) populateEnvsFromInput(action *model.Action, rc *RunContext) { diff --git a/pkg/runner/testdata/actions/docker-local-noargs/Dockerfile b/pkg/runner/testdata/actions/docker-local-noargs/Dockerfile new file mode 100644 index 00000000..b53bb336 --- /dev/null +++ b/pkg/runner/testdata/actions/docker-local-noargs/Dockerfile @@ -0,0 +1,8 @@ +# Container image that runs your code +FROM node:12-buster-slim + +# Copies your code file from your action repository to the filesystem path `/` of the container +COPY entrypoint.sh /entrypoint.sh + +# Code file to execute when the docker container starts up (`entrypoint.sh`) +ENTRYPOINT ["/entrypoint.sh"] diff --git a/pkg/runner/testdata/actions/docker-local-noargs/action.yml b/pkg/runner/testdata/actions/docker-local-noargs/action.yml new file mode 100644 index 00000000..3a679a0e --- /dev/null +++ b/pkg/runner/testdata/actions/docker-local-noargs/action.yml @@ -0,0 +1,15 @@ +name: 'Hello World' +description: 'Greet someone and record the time' +inputs: + who-to-greet: # id of input + description: 'Who to greet' + required: true + default: 'World' +outputs: + time: # id of output + description: 'The time we greeted you' +runs: + using: 'docker' + image: 'Dockerfile' + env: + WHOAMI: ${{ inputs.who-to-greet }} \ No newline at end of file diff --git a/pkg/runner/testdata/actions/docker-local-noargs/entrypoint.sh b/pkg/runner/testdata/actions/docker-local-noargs/entrypoint.sh new file mode 100755 index 00000000..43ca2dd8 --- /dev/null +++ b/pkg/runner/testdata/actions/docker-local-noargs/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh -l + +echo "Hello $1" +time=$(date) +echo ::set-output name=time::$time +echo ::set-output name=whoami::$WHOAMI + +echo "SOMEVAR=$1" >>$GITHUB_ENV diff --git a/pkg/runner/testdata/local-action-dockerfile/push.yml b/pkg/runner/testdata/local-action-dockerfile/push.yml index 4940b98c..a4a5b06e 100644 --- a/pkg/runner/testdata/local-action-dockerfile/push.yml +++ b/pkg/runner/testdata/local-action-dockerfile/push.yml @@ -1,5 +1,8 @@ name: local-action-dockerfile on: push +defaults: + run: + shell: bash jobs: test: @@ -15,4 +18,10 @@ jobs: env: SOMEVAR: 'Not Mona' - run: '[[ "${{ steps.dockerlocal.outputs.whoami }}" == "Mona the Octocat" ]]' + # Test if overriding args doesn't leak inputs + - uses: ./actions/docker-local-noargs + with: + args: ${{format('"{0}"', 'Mona is not the Octocat') }} + who-to-greet: 'Mona the Octocat' + - run: '[[ "${{ env.SOMEVAR }}" == "Mona is not the Octocat" ]]' - uses: ./localdockerimagetest_ diff --git a/pkg/runner/testdata/local-action-via-composite-dockerfile/action.yml b/pkg/runner/testdata/local-action-via-composite-dockerfile/action.yml new file mode 100644 index 00000000..cbf43546 --- /dev/null +++ b/pkg/runner/testdata/local-action-via-composite-dockerfile/action.yml @@ -0,0 +1,44 @@ +inputs: + who-to-greet: + default: 'Mona the Octocat' +runs: + using: composite + steps: + # Test if GITHUB_ACTION_PATH is set correctly before all steps + - run: stat $GITHUB_ACTION_PATH/push.yml + shell: bash + - run: stat $GITHUB_ACTION_PATH/action.yml + shell: bash + - run: '[[ "$GITHUB_ACTION_REPOSITORY" == "" ]] && [[ "$GITHUB_ACTION_REF" == "" ]]' + shell: bash + - uses: ./actions/docker-local + id: dockerlocal + with: + who-to-greet: ${{inputs.who-to-greet}} + - run: '[[ "${{ env.SOMEVAR }}" == "${{inputs.who-to-greet}}" ]]' + shell: bash + - run: '[ "${SOMEVAR}" = "Not Mona" ] || exit 1' + shell: bash + env: + SOMEVAR: 'Not Mona' + - run: '[[ "${{ steps.dockerlocal.outputs.whoami }}" == "${{inputs.who-to-greet}}" ]]' + shell: bash + # Test if overriding args doesn't leak inputs + - uses: ./actions/docker-local-noargs + with: + args: ${{format('"{0}"', 'Mona is not the Octocat') }} + who-to-greet: ${{inputs.who-to-greet}} + - run: '[[ "${{ env.SOMEVAR }}" == "Mona is not the Octocat" ]]' + shell: bash + - uses: ./localdockerimagetest_ + # Also test a remote docker action here + - uses: actions/hello-world-docker-action@main + with: + who-to-greet: 'Mona the Octocat' + # Test if GITHUB_ACTION_PATH is set correctly after all steps + - run: stat $GITHUB_ACTION_PATH/push.yml + shell: bash + - run: stat $GITHUB_ACTION_PATH/action.yml + shell: bash + - run: '[[ "$GITHUB_ACTION_REPOSITORY" == "" ]] && [[ "$GITHUB_ACTION_REF" == "" ]]' + shell: bash \ No newline at end of file diff --git a/pkg/runner/testdata/local-action-via-composite-dockerfile/push.yml b/pkg/runner/testdata/local-action-via-composite-dockerfile/push.yml new file mode 100644 index 00000000..f1ed3612 --- /dev/null +++ b/pkg/runner/testdata/local-action-via-composite-dockerfile/push.yml @@ -0,0 +1,9 @@ +name: local-action-dockerfile +on: push + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: ./local-action-via-composite-dockerfile \ No newline at end of file diff --git a/pkg/runner/testdata/uses-composite-with-error/composite_action2/action.yml b/pkg/runner/testdata/uses-composite-with-error/composite_action2/action.yml new file mode 100644 index 00000000..627bfd1a --- /dev/null +++ b/pkg/runner/testdata/uses-composite-with-error/composite_action2/action.yml @@ -0,0 +1,11 @@ +--- +name: "Test Composite Action" +description: "Test action uses composite" + +runs: + using: "composite" + steps: + - run: exit 1 + shell: bash + - run: echo should not run in composite steps + shell: bash diff --git a/pkg/runner/testdata/uses-composite-with-error/push.yml b/pkg/runner/testdata/uses-composite-with-error/push.yml new file mode 100644 index 00000000..34289d89 --- /dev/null +++ b/pkg/runner/testdata/uses-composite-with-error/push.yml @@ -0,0 +1,12 @@ +name: uses-docker-url +on: push + +jobs: + failing-composite-action: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: ./uses-composite-with-error/composite_action2 + - run: echo should run + if: failure() + - run: echo should not run diff --git a/pkg/runner/testdata/uses-composite/composite_action/action.yml b/pkg/runner/testdata/uses-composite/composite_action/action.yml index f47f76ff..e7b878fe 100644 --- a/pkg/runner/testdata/uses-composite/composite_action/action.yml +++ b/pkg/runner/testdata/uses-composite/composite_action/action.yml @@ -22,11 +22,15 @@ inputs: description: "Required with default, due to an old bug of github actions this is allowed" required: true default: "test_input_optional_value" - + secret_input: + description: test pass a secret as input outputs: test_output: description: "Output value to pass up" - value: ${{ step.output.outputs.test_output }} + value: ${{ steps.output.outputs.test_output }} + secret_output: + description: test pass a secret as output + value: ${{ format('{0}/{1}', inputs.secret_input, env.secret_input) }} runs: using: "composite" @@ -85,6 +89,8 @@ runs: # Let's send up an output to test - run: echo "::set-output name=test_output::test_output_value" + id: output + shell: bash + - run: echo "COMPOSITE_ACTION_ENV_OUTPUT=my test value" >> $GITHUB_ENV shell: bash - diff --git a/pkg/runner/testdata/uses-composite/push.yml b/pkg/runner/testdata/uses-composite/push.yml index cecb1199..598c3b43 100755 --- a/pkg/runner/testdata/uses-composite/push.yml +++ b/pkg/runner/testdata/uses-composite/push.yml @@ -14,11 +14,21 @@ jobs: test_input_optional_with_default_overriden: 'test_input_optional_with_default_overriden' test_input_required_with_default: 'test_input_optional_value' test_input_required_with_default_overriden: 'test_input_required_with_default_overriden' - + secret_input: ${{secrets.test_input_optional || 'NO SUCH SECRET'}} + env: + secret_input: ${{secrets.test_input_optional || 'NO SUCH SECRET'}} - if: steps.composite.outputs.test_output != "test_output_value" run: | echo "steps.composite.outputs.test_output=${{ steps.composite.outputs.test_output }}" exit 1 + - run: | + echo "steps.composite.outputs.secret_output=${{ steps.composite.outputs.secret_output }}" + [[ "${{steps.composite.outputs.secret_output == format('{0}/{0}', secrets.test_input_optional || 'NO SUCH SECRET')}}" = "true" ]] || exit 1 + shell: bash + - run: | + echo "steps.composite.outputs.secret_output=$COMPOSITE_ACTION_ENV_OUTPUT" + [[ "${{env.COMPOSITE_ACTION_ENV_OUTPUT == 'my test value' }}" = "true" ]] || exit 1 + shell: bash # Now test again with default values - uses: ./uses-composite/composite_action @@ -30,5 +40,5 @@ jobs: - if: steps.composite2.outputs.test_output != "test_output_value" run: | - echo "steps.composite.outputs.test_output=${{ steps.composite.outputs.test_output }}" + echo "steps.composite.outputs.test_output=${{ steps.composite2.outputs.test_output }}" exit 1 diff --git a/pkg/runner/testdata/uses-nested-composite/composite_action2/action.yml b/pkg/runner/testdata/uses-nested-composite/composite_action2/action.yml new file mode 100644 index 00000000..1062ebd3 --- /dev/null +++ b/pkg/runner/testdata/uses-nested-composite/composite_action2/action.yml @@ -0,0 +1,63 @@ +--- +name: "Test Composite Action" +description: "Test action uses composite" + +inputs: + test_input_optional: + description: Test + +runs: + using: "composite" + steps: +# The output of actions/setup-node@v2 seems to fail the workflow +# - uses: actions/setup-node@v2 +# with: +# node-version: '16' +# - run: | +# console.log(process.version); +# console.log("Hi from node"); +# console.log("${{ inputs.test_input_optional }}"); +# if("${{ inputs.test_input_optional }}" !== "Test") { +# console.log("Invalid input test_input_optional expected \"Test\" as value"); +# process.exit(1); +# } +# if(!process.version.startsWith('v16')) { +# console.log("Expected node v16, but got " + process.version); +# process.exit(1); +# } +# shell: node {0} + - uses: ./uses-composite/composite_action + id: composite + with: + test_input_required: 'test_input_required_value' + test_input_optional: 'test_input_optional_value' + test_input_optional_with_default_overriden: 'test_input_optional_with_default_overriden' + test_input_required_with_default: 'test_input_optional_value' + test_input_required_with_default_overriden: 'test_input_required_with_default_overriden' + secret_input: ${{inputs.test_input_optional}} + env: + secret_input: ${{inputs.test_input_optional}} + - run: | + echo "steps.composite.outputs.test_output=${{ steps.composite.outputs.test_output }}" + [[ "${{steps.composite.outputs.test_output == 'test_output_value'}}" = "true" ]] || exit 1 + shell: bash + - run: | + echo "steps.composite.outputs.secret_output=${{ steps.composite.outputs.secret_output }}" + [[ "${{steps.composite.outputs.secret_output == format('{0}/{0}', inputs.test_input_optional)}}" = "true" ]] || exit 1 + shell: bash + # Now test again with default values + - uses: ./uses-composite/composite_action + id: composite2 + with: + test_input_required: 'test_input_required_value' + test_input_optional_with_default_overriden: 'test_input_optional_with_default_overriden' + test_input_required_with_default_overriden: 'test_input_required_with_default_overriden' + + - run: | + echo "steps.composite2.outputs.test_output=${{ steps.composite2.outputs.test_output }}" + [[ "${{steps.composite2.outputs.test_output == 'test_output_value'}}" = "true" ]] || exit 1 + shell: bash + - run: | + echo "steps.composite.outputs.secret_output=$COMPOSITE_ACTION_ENV_OUTPUT" + [[ "${{env.COMPOSITE_ACTION_ENV_OUTPUT == 'my test value' }}" = "true" ]] || exit 1 + shell: bash \ No newline at end of file diff --git a/pkg/runner/testdata/uses-nested-composite/push.yml b/pkg/runner/testdata/uses-nested-composite/push.yml new file mode 100644 index 00000000..b7de1fb8 --- /dev/null +++ b/pkg/runner/testdata/uses-nested-composite/push.yml @@ -0,0 +1,15 @@ +name: uses-docker-url +on: push + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: ./uses-nested-composite/composite_action2 + with: + test_input_optional: Test + - run: | + echo "steps.composite.outputs.secret_output=$COMPOSITE_ACTION_ENV_OUTPUT" + [[ "${{env.COMPOSITE_ACTION_ENV_OUTPUT == 'my test value' }}" = "true" ]] || exit 1 + shell: bash \ No newline at end of file