implement pre and post steps (#1089)

* feat: add post step to actions and add state command

This commit includes requried changes for running post steps
for local and remote actions.
This allows general cleanup work to be done after executing
an action.

Communication is allowed between this steps, by using the
action state.

* feat: collect pre and post steps for composite actions

* refactor: move composite action logic into own file

* refactor: restructure composite handling

* feat: run composite post steps during post step lifecycle

* refactor: remove duplicate log output

* feat: run all composite post actions in a step

Since composite actions could have multiple pre/post steps inside,
we need to run all of them in a single top-level pre/post step.

This PR includes a test case for this and the correct order of steps
to be executed.

* refactor: remove unused lines of code

* refactor: simplify test expression

* fix: use composite job logger

* fix: make step output more readable

* fix: enforce running all post executor

To make sure every post executor/step is executed, it is chained
with it's own Finally executor.

* fix: do not run post step if no step result is available

Having no step result means we do not run any step (neither pre
nor main) and we do not need to run post.

* fix: setup defaults

If no pre-if or post-if is given, it should default to 'always()'.
This could be set even if there is no pre or post step.
In fact this is required for composite actions and included post
steps to run.

* fix: output step related if expression

* test: update expectation

* feat: run pre step from actions (#1110)

This PR implements running pre steps for remote actions.
This includes remote actions using inside local composite actions.

* fix: set correct expr default status checks

For post-if conditions the default status check should be
always(), while for all other if expression the default status
check is success()

References:
https://docs.github.com/en/actions/learn-github-actions/expressions#status-check-functions
https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#runspost-if

* fix: remove code added during rebase
This commit is contained in:
Markus Wolf 2022-05-24 15:36:06 +02:00 committed by GitHub
parent ebb408f373
commit 943a0e6eea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1560 additions and 350 deletions

View file

@ -37,7 +37,7 @@ func TestFunctionContains(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, false) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)
@ -66,7 +66,7 @@ func TestFunctionStartsWith(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, false) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)
@ -95,7 +95,7 @@ func TestFunctionEndsWith(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, false) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)
@ -122,7 +122,7 @@ func TestFunctionJoin(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, false) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)
@ -148,7 +148,7 @@ func TestFunctionToJSON(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, false) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)
@ -171,7 +171,7 @@ func TestFunctionFromJSON(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, false) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)
@ -197,7 +197,7 @@ func TestFunctionHashFiles(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
workdir, err := filepath.Abs("testdata") workdir, err := filepath.Abs("testdata")
assert.Nil(t, err) assert.Nil(t, err)
output, err := NewInterpeter(env, Config{WorkingDir: workdir}).Evaluate(tt.input, false) output, err := NewInterpeter(env, Config{WorkingDir: workdir}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)
@ -234,7 +234,7 @@ func TestFunctionFormat(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, false) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
if tt.error != nil { if tt.error != nil {
assert.Equal(t, tt.error, err.Error()) assert.Equal(t, tt.error, err.Error())
} else { } else {

View file

@ -30,8 +30,32 @@ type Config struct {
Context string Context string
} }
type DefaultStatusCheck int
const (
DefaultStatusCheckNone DefaultStatusCheck = iota
DefaultStatusCheckSuccess
DefaultStatusCheckAlways
DefaultStatusCheckCanceled
DefaultStatusCheckFailure
)
func (dsc DefaultStatusCheck) String() string {
switch dsc {
case DefaultStatusCheckSuccess:
return "success"
case DefaultStatusCheckAlways:
return "always"
case DefaultStatusCheckCanceled:
return "cancelled"
case DefaultStatusCheckFailure:
return "failure"
}
return ""
}
type Interpreter interface { type Interpreter interface {
Evaluate(input string, isIfExpression bool) (interface{}, error) Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (interface{}, error)
} }
type interperterImpl struct { type interperterImpl struct {
@ -46,9 +70,9 @@ func NewInterpeter(env *EvaluationEnvironment, config Config) Interpreter {
} }
} }
func (impl *interperterImpl) Evaluate(input string, isIfExpression bool) (interface{}, error) { func (impl *interperterImpl) Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (interface{}, error) {
input = strings.TrimPrefix(input, "${{") input = strings.TrimPrefix(input, "${{")
if isIfExpression && input == "" { if defaultStatusCheck != DefaultStatusCheckNone && input == "" {
input = "success()" input = "success()"
} }
parser := actionlint.NewExprParser() parser := actionlint.NewExprParser()
@ -57,7 +81,7 @@ func (impl *interperterImpl) Evaluate(input string, isIfExpression bool) (interf
return nil, fmt.Errorf("Failed to parse: %s", err.Message) return nil, fmt.Errorf("Failed to parse: %s", err.Message)
} }
if isIfExpression { if defaultStatusCheck != DefaultStatusCheckNone {
hasStatusCheckFunction := false hasStatusCheckFunction := false
actionlint.VisitExprNode(exprNode, func(node, _ actionlint.ExprNode, entering bool) { actionlint.VisitExprNode(exprNode, func(node, _ actionlint.ExprNode, entering bool) {
if funcCallNode, ok := node.(*actionlint.FuncCallNode); entering && ok { if funcCallNode, ok := node.(*actionlint.FuncCallNode); entering && ok {
@ -72,7 +96,7 @@ func (impl *interperterImpl) Evaluate(input string, isIfExpression bool) (interf
exprNode = &actionlint.LogicalOpNode{ exprNode = &actionlint.LogicalOpNode{
Kind: actionlint.LogicalOpNodeKindAnd, Kind: actionlint.LogicalOpNodeKindAnd,
Left: &actionlint.FuncCallNode{ Left: &actionlint.FuncCallNode{
Callee: "success", Callee: defaultStatusCheck.String(),
Args: []actionlint.ExprNode{}, Args: []actionlint.ExprNode{},
}, },
Right: exprNode, Right: exprNode,
@ -361,7 +385,7 @@ func (impl *interperterImpl) coerceToNumber(value reflect.Value) reflect.Value {
} }
// try to parse the string as a number // try to parse the string as a number
evaluated, err := impl.Evaluate(value.String(), false) evaluated, err := impl.Evaluate(value.String(), DefaultStatusCheckNone)
if err != nil { if err != nil {
return reflect.ValueOf(math.NaN()) return reflect.ValueOf(math.NaN())
} }

View file

@ -29,7 +29,7 @@ func TestLiterals(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, false) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)
@ -93,7 +93,7 @@ func TestOperators(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, false) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
if tt.error != "" { if tt.error != "" {
assert.NotNil(t, err) assert.NotNil(t, err)
assert.Equal(t, tt.error, err.Error()) assert.Equal(t, tt.error, err.Error())
@ -146,7 +146,7 @@ func TestOperatorsCompare(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, false) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)
@ -509,7 +509,7 @@ func TestOperatorsBooleanEvaluation(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, false) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) assert.Nil(t, err)
if expected, ok := tt.expected.(float64); ok && math.IsNaN(expected) { if expected, ok := tt.expected.(float64); ok && math.IsNaN(expected) {
@ -607,7 +607,7 @@ func TestContexts(t *testing.T) {
for _, tt := range table { for _, tt := range table {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, false) output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
assert.Nil(t, err) assert.Nil(t, err)
assert.Equal(t, tt.expected, output) assert.Equal(t, tt.expected, output)

View file

@ -49,6 +49,10 @@ type ActionRuns struct {
Using ActionRunsUsing `yaml:"using"` Using ActionRunsUsing `yaml:"using"`
Env map[string]string `yaml:"env"` Env map[string]string `yaml:"env"`
Main string `yaml:"main"` Main string `yaml:"main"`
Pre string `yaml:"pre"`
PreIf string `yaml:"pre-if"`
Post string `yaml:"post"`
PostIf string `yaml:"post-if"`
Image string `yaml:"image"` Image string `yaml:"image"`
Entrypoint string `yaml:"entrypoint"` Entrypoint string `yaml:"entrypoint"`
Args []string `yaml:"args"` Args []string `yaml:"args"`
@ -90,5 +94,13 @@ func ReadAction(in io.Reader) (*Action, error) {
return nil, err return nil, err
} }
// set defaults
if a.Runs.PreIf == "" {
a.Runs.PreIf = "always()"
}
if a.Runs.PostIf == "" {
a.Runs.PostIf = "always()"
}
return a, nil return a, nil
} }

View file

@ -42,4 +42,5 @@ type StepResult struct {
Outputs map[string]string `json:"outputs"` Outputs map[string]string `json:"outputs"`
Conclusion stepStatus `json:"conclusion"` Conclusion stepStatus `json:"conclusion"`
Outcome stepStatus `json:"outcome"` Outcome stepStatus `json:"outcome"`
State map[string]string
} }

View file

@ -24,6 +24,8 @@ type actionStep interface {
step step
getActionModel() *model.Action getActionModel() *model.Action
getCompositeRunContext() *RunContext
getCompositeSteps() *compositeSteps
} }
type readAction func(step *model.Step, actionDir string, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) type readAction func(step *model.Step, actionDir string, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error)
@ -98,14 +100,37 @@ func readActionImpl(step *model.Step, actionDir string, actionPath string, readF
return action, err return action, err
} }
func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir string, actionPath string, containerActionDir string) error {
rc := step.getRunContext()
stepModel := step.getStepModel()
if stepModel.Type() != model.StepTypeUsesActionRemote {
return nil
}
if err := removeGitIgnore(actionDir); err != nil {
return err
}
var containerActionDirCopy string
containerActionDirCopy = strings.TrimSuffix(containerActionDir, actionPath)
log.Debug(containerActionDirCopy)
if !strings.HasSuffix(containerActionDirCopy, `/`) {
containerActionDirCopy += `/`
}
return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore)(ctx)
}
func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor { func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor {
rc := step.getRunContext() rc := step.getRunContext()
stepModel := step.getStepModel() stepModel := step.getStepModel()
return func(ctx context.Context) error { return func(ctx context.Context) error {
actionPath := "" actionPath := ""
if remoteAction != nil && remoteAction.Path != "" { if remoteAction != nil && remoteAction.Path != "" {
actionPath = remoteAction.Path actionPath = remoteAction.Path
} }
action := step.getActionModel() action := step.getActionModel()
log.Debugf("About to run action %v", action) log.Debugf("About to run action %v", action)
@ -127,6 +152,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
// time, we don't have all environment prepared // time, we don't have all environment prepared
mergeIntoMap(step.getEnv(), rc.withGithubEnv(map[string]string{})) mergeIntoMap(step.getEnv(), rc.withGithubEnv(map[string]string{}))
populateEnvsFromSavedState(step.getEnv(), step, rc)
populateEnvsFromInput(step.getEnv(), action, rc) populateEnvsFromInput(step.getEnv(), action, rc)
actionLocation := path.Join(actionDir, actionPath) actionLocation := path.Join(actionDir, actionPath)
@ -134,27 +160,9 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
log.Debugf("type=%v actionDir=%s actionPath=%s workdir=%s actionCacheDir=%s actionName=%s containerActionDir=%s", stepModel.Type(), actionDir, actionPath, rc.Config.Workdir, rc.ActionCacheDir(), actionName, containerActionDir) log.Debugf("type=%v actionDir=%s actionPath=%s workdir=%s actionCacheDir=%s actionName=%s containerActionDir=%s", stepModel.Type(), actionDir, actionPath, rc.Config.Workdir, rc.ActionCacheDir(), actionName, containerActionDir)
maybeCopyToActionDir := func() error {
if stepModel.Type() != model.StepTypeUsesActionRemote {
return nil
}
if err := removeGitIgnore(actionDir); err != nil {
return err
}
var containerActionDirCopy string
containerActionDirCopy = strings.TrimSuffix(containerActionDir, actionPath)
log.Debug(containerActionDirCopy)
if !strings.HasSuffix(containerActionDirCopy, `/`) {
containerActionDirCopy += `/`
}
return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore)(ctx)
}
switch action.Runs.Using { switch action.Runs.Using {
case model.ActionRunsUsingNode12, model.ActionRunsUsingNode16: case model.ActionRunsUsingNode12, model.ActionRunsUsingNode16:
if err := maybeCopyToActionDir(); err != nil { if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err return err
} }
containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Main)} containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Main)}
@ -167,10 +175,11 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction
} }
return execAsDocker(ctx, step, actionName, location, remoteAction == nil) return execAsDocker(ctx, step, actionName, location, remoteAction == nil)
case model.ActionRunsUsingComposite: case model.ActionRunsUsingComposite:
if err := maybeCopyToActionDir(); err != nil { if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err return err
} }
return execAsComposite(step, containerActionDir)(ctx)
return execAsComposite(step)(ctx)
default: default:
return fmt.Errorf(fmt.Sprintf("The runs.using key must be one of: %v, got %s", []string{ return fmt.Errorf(fmt.Sprintf("The runs.using key must be one of: %v, got %s", []string{
model.ActionRunsUsingDocker, model.ActionRunsUsingDocker,
@ -359,134 +368,36 @@ func newStepContainer(ctx context.Context, step step, image string, cmd []string
return stepContainer return stepContainer
} }
func execAsComposite(step actionStep, containerActionDir string) common.Executor { func (rc *RunContext) setupActionInputs(step actionStep) {
rc := step.getRunContext() if step.getActionModel() == nil {
// e.g. local checkout skip has no action model
return
}
stepModel := step.getStepModel()
action := step.getActionModel() action := step.getActionModel()
return func(ctx context.Context) error {
eval := rc.NewExpressionEvaluator() eval := rc.NewExpressionEvaluator()
inputs := make(map[string]interface{}) inputs := make(map[string]interface{})
for k, input := range action.Inputs { for k, input := range action.Inputs {
inputs[k] = eval.Interpolate(input.Default) inputs[k] = eval.Interpolate(input.Default)
} }
if step.getStepModel().With != nil { if stepModel.With != nil {
for k, v := range step.getStepModel().With { for k, v := range stepModel.With {
inputs[k] = eval.Interpolate(v) inputs[k] = eval.Interpolate(v)
} }
} }
env := make(map[string]string) rc.Inputs = inputs
for k, v := range rc.Env {
env[k] = eval.Interpolate(v)
}
for k, v := range step.getStepModel().Environment() {
env[k] = eval.Interpolate(v)
} }
// run with the global config but without secrets func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) {
configCopy := *rc.Config stepResult := rc.StepResults[step.getStepModel().ID]
configCopy.Secrets = nil if stepResult != nil {
for name, value := range stepResult.State {
// create a run context for the composite action to run in envName := fmt.Sprintf("STATE_%s", name)
compositerc := &RunContext{ (*env)[envName] = value
Name: rc.Name,
JobName: rc.JobName,
Run: &model.Run{
JobID: "composite-job",
Workflow: &model.Workflow{
Name: rc.Run.Workflow.Name,
Jobs: map[string]*model.Job{
"composite-job": {},
},
},
},
Config: &configCopy,
StepResults: map[string]*model.StepResult{},
JobContainer: rc.JobContainer,
Inputs: inputs,
ActionPath: containerActionDir,
ActionRepository: rc.ActionRepository,
ActionRef: rc.ActionRef,
Env: env,
Masks: rc.Masks,
ExtraPath: rc.ExtraPath,
} }
ctx = WithCompositeLogger(ctx, &compositerc.Masks)
// We need to inject a composite RunContext related command
// handler into the current running job container
// We need this, to support scoping commands to the composite action
// executing.
rawLogger := common.Logger(ctx).WithField("raw_output", true)
logWriter := common.NewLineWriter(compositerc.commandHandler(ctx), func(s string) bool {
if rc.Config.LogOutput {
rawLogger.Infof("%s", s)
} else {
rawLogger.Debugf("%s", s)
}
return true
})
oldout, olderr := compositerc.JobContainer.ReplaceLogWriter(logWriter, logWriter)
defer (func() {
rc.JobContainer.ReplaceLogWriter(oldout, olderr)
})()
err := compositerc.compositeExecutor(action)(ctx)
// Map outputs from composite RunContext to job RunContext
eval = compositerc.NewExpressionEvaluator()
for outputName, output := range action.Outputs {
rc.setOutput(ctx, map[string]string{
"name": outputName,
}, eval.Interpolate(output.Value))
}
rc.Masks = compositerc.Masks
rc.ExtraPath = compositerc.ExtraPath
return err
}
}
// Executor returns a pipeline executor for all the steps in the job
func (rc *RunContext) compositeExecutor(action *model.Action) common.Executor {
steps := make([]common.Executor, 0)
sf := &stepFactoryImpl{}
for i, step := range action.Runs.Steps {
if step.ID == "" {
step.ID = fmt.Sprintf("%d", i)
}
// create a copy of the step, since this composite action could
// run multiple times and we might modify the instance
stepcopy := step
step, err := sf.newStep(&stepcopy, rc)
if err != nil {
return common.NewErrorExecutor(err)
}
stepExec := common.NewPipelineExecutor(step.pre(), step.main(), step.post())
steps = append(steps, func(ctx context.Context) error {
err := stepExec(ctx)
if err != nil {
common.Logger(ctx).Errorf("%v", err)
common.SetJobError(ctx, err)
} else if ctx.Err() != nil {
common.Logger(ctx).Errorf("%v", ctx.Err())
common.SetJobError(ctx, ctx.Err())
}
return nil
})
}
steps = append(steps, common.JobError)
return func(ctx context.Context) error {
return common.NewPipelineExecutor(steps...)(common.WithJobErrorContainer(ctx))
} }
} }
@ -531,3 +442,162 @@ func getOsSafeRelativePath(s, prefix string) string {
return actionName return actionName
} }
func shouldRunPreStep(step actionStep) common.Conditional {
return func(ctx context.Context) bool {
log := common.Logger(ctx)
if step.getActionModel() == nil {
log.Debugf("skip pre step for '%s': no action model available", step.getStepModel())
return false
}
return true
}
}
func hasPreStep(step actionStep) common.Conditional {
return func(ctx context.Context) bool {
action := step.getActionModel()
return action.Runs.Using == model.ActionRunsUsingComposite ||
((action.Runs.Using == model.ActionRunsUsingNode12 ||
action.Runs.Using == model.ActionRunsUsingNode16) &&
action.Runs.Pre != "")
}
}
func runPreStep(step actionStep) common.Executor {
return func(ctx context.Context) error {
common.Logger(ctx).Debugf("run pre step for '%s'", step.getStepModel())
rc := step.getRunContext()
stepModel := step.getStepModel()
action := step.getActionModel()
switch action.Runs.Using {
case model.ActionRunsUsingNode12, model.ActionRunsUsingNode16:
// todo: refactor into step
var actionDir string
var actionPath string
if _, ok := step.(*stepActionRemote); ok {
actionPath = newRemoteAction(stepModel.Uses).Path
actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), strings.ReplaceAll(stepModel.Uses, "/", "-"))
} else {
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
actionPath = ""
}
actionLocation := ""
if actionPath != "" {
actionLocation = path.Join(actionDir, actionPath)
} else {
actionLocation = actionDir
}
_, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err
}
containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Pre)}
log.Debugf("executing remote job container: %s", containerArgs)
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
case model.ActionRunsUsingComposite:
step.getCompositeRunContext().updateCompositeRunContext(step.getRunContext(), step)
return step.getCompositeSteps().pre(ctx)
default:
return nil
}
}
}
func shouldRunPostStep(step actionStep) common.Conditional {
return func(ctx context.Context) bool {
log := common.Logger(ctx)
stepResults := step.getRunContext().getStepsContext()
stepResult := stepResults[step.getStepModel().ID]
if stepResult == nil {
log.Debugf("skip post step for '%s'; step was not executed", step.getStepModel())
return false
}
if stepResult.Conclusion == model.StepStatusSkipped {
log.Debugf("skip post step for '%s'; main step was skipped", step.getStepModel())
return false
}
if step.getActionModel() == nil {
log.Debugf("skip post step for '%s': no action model available", step.getStepModel())
return false
}
return true
}
}
func hasPostStep(step actionStep) common.Conditional {
return func(ctx context.Context) bool {
action := step.getActionModel()
return action.Runs.Using == model.ActionRunsUsingComposite ||
((action.Runs.Using == model.ActionRunsUsingNode12 ||
action.Runs.Using == model.ActionRunsUsingNode16) &&
action.Runs.Post != "")
}
}
func runPostStep(step actionStep) common.Executor {
return func(ctx context.Context) error {
common.Logger(ctx).Debugf("run post step for '%s'", step.getStepModel())
rc := step.getRunContext()
stepModel := step.getStepModel()
action := step.getActionModel()
// todo: refactor into step
var actionDir string
var actionPath string
if _, ok := step.(*stepActionRemote); ok {
actionPath = newRemoteAction(stepModel.Uses).Path
actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), strings.ReplaceAll(stepModel.Uses, "/", "-"))
} else {
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
actionPath = ""
}
actionLocation := ""
if actionPath != "" {
actionLocation = path.Join(actionDir, actionPath)
} else {
actionLocation = actionDir
}
_, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc)
switch action.Runs.Using {
case model.ActionRunsUsingNode12, model.ActionRunsUsingNode16:
populateEnvsFromSavedState(step.getEnv(), step, rc)
containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Post)}
log.Debugf("executing remote job container: %s", containerArgs)
return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx)
case model.ActionRunsUsingComposite:
if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil {
return err
}
step.getCompositeRunContext().updateCompositeRunContext(step.getRunContext(), step)
return step.getCompositeSteps().post(ctx)
default:
return nil
}
}
}

View file

@ -0,0 +1,195 @@
package runner
import (
"context"
"fmt"
"github.com/nektos/act/pkg/common"
"github.com/nektos/act/pkg/model"
)
func evaluteCompositeInputAndEnv(parent *RunContext, step actionStep) (inputs map[string]interface{}, env map[string]string) {
eval := parent.NewExpressionEvaluator()
inputs = make(map[string]interface{})
for k, input := range step.getActionModel().Inputs {
inputs[k] = eval.Interpolate(input.Default)
}
if step.getStepModel().With != nil {
for k, v := range step.getStepModel().With {
inputs[k] = eval.Interpolate(v)
}
}
env = make(map[string]string)
for k, v := range parent.Env {
env[k] = eval.Interpolate(v)
}
for k, v := range step.getStepModel().Environment() {
env[k] = eval.Interpolate(v)
}
return inputs, env
}
func newCompositeRunContext(parent *RunContext, step actionStep, actionPath string) *RunContext {
inputs, env := evaluteCompositeInputAndEnv(parent, step)
// run with the global config but without secrets
configCopy := *(parent.Config)
configCopy.Secrets = nil
// create a run context for the composite action to run in
compositerc := &RunContext{
Name: parent.Name,
JobName: parent.JobName,
Run: &model.Run{
JobID: "composite-job",
Workflow: &model.Workflow{
Name: parent.Run.Workflow.Name,
Jobs: map[string]*model.Job{
"composite-job": {},
},
},
},
Config: &configCopy,
StepResults: map[string]*model.StepResult{},
JobContainer: parent.JobContainer,
Inputs: inputs,
ActionPath: actionPath,
ActionRepository: parent.ActionRepository,
ActionRef: parent.ActionRef,
Env: env,
Masks: parent.Masks,
ExtraPath: parent.ExtraPath,
}
return compositerc
}
// This updates a composite context inputs, env and masks.
// This is needed to re-evalute/update that context between pre/main/post steps.
// Some of the inputs/env may requires the results of in-between steps.
func (rc *RunContext) updateCompositeRunContext(parent *RunContext, step actionStep) {
inputs, env := evaluteCompositeInputAndEnv(parent, step)
rc.Inputs = inputs
rc.Env = env
rc.Masks = append(rc.Masks, parent.Masks...)
}
func execAsComposite(step actionStep) common.Executor {
rc := step.getRunContext()
action := step.getActionModel()
return func(ctx context.Context) error {
compositerc := step.getCompositeRunContext()
steps := step.getCompositeSteps()
ctx = WithCompositeLogger(ctx, &compositerc.Masks)
compositerc.updateCompositeRunContext(rc, step)
err := steps.main(ctx)
// Map outputs from composite RunContext to job RunContext
eval := compositerc.NewExpressionEvaluator()
for outputName, output := range action.Outputs {
rc.setOutput(ctx, map[string]string{
"name": outputName,
}, eval.Interpolate(output.Value))
}
rc.Masks = append(rc.Masks, compositerc.Masks...)
rc.ExtraPath = compositerc.ExtraPath
return err
}
}
type compositeSteps struct {
pre common.Executor
main common.Executor
post common.Executor
}
// Executor returns a pipeline executor for all the steps in the job
func (rc *RunContext) compositeExecutor(action *model.Action) *compositeSteps {
steps := make([]common.Executor, 0)
preSteps := make([]common.Executor, 0)
var postExecutor common.Executor
sf := &stepFactoryImpl{}
for i, step := range action.Runs.Steps {
if step.ID == "" {
step.ID = fmt.Sprintf("%d", i)
}
// create a copy of the step, since this composite action could
// run multiple times and we might modify the instance
stepcopy := step
step, err := sf.newStep(&stepcopy, rc)
if err != nil {
return &compositeSteps{
main: common.NewErrorExecutor(err),
}
}
preSteps = append(preSteps, step.pre())
steps = append(steps, func(ctx context.Context) error {
err := step.main()(ctx)
if err != nil {
common.Logger(ctx).Errorf("%v", err)
common.SetJobError(ctx, err)
} else if ctx.Err() != nil {
common.Logger(ctx).Errorf("%v", ctx.Err())
common.SetJobError(ctx, ctx.Err())
}
return nil
})
// run the post executor in reverse order
if postExecutor != nil {
postExecutor = step.post().Finally(postExecutor)
} else {
postExecutor = step.post()
}
}
steps = append(steps, common.JobError)
return &compositeSteps{
pre: rc.newCompositeCommandExecutor(common.NewPipelineExecutor(preSteps...)),
main: rc.newCompositeCommandExecutor(func(ctx context.Context) error {
return common.NewPipelineExecutor(steps...)(common.WithJobErrorContainer(ctx))
}),
post: rc.newCompositeCommandExecutor(postExecutor),
}
}
func (rc *RunContext) newCompositeCommandExecutor(executor common.Executor) common.Executor {
return func(ctx context.Context) error {
ctx = WithCompositeLogger(ctx, &rc.Masks)
// We need to inject a composite RunContext related command
// handler into the current running job container
// We need this, to support scoping commands to the composite action
// executing.
rawLogger := common.Logger(ctx).WithField("raw_output", true)
logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool {
if rc.Config.LogOutput {
rawLogger.Infof("%s", s)
} else {
rawLogger.Debugf("%s", s)
}
return true
})
oldout, olderr := rc.JobContainer.ReplaceLogWriter(logWriter, logWriter)
defer rc.JobContainer.ReplaceLogWriter(oldout, olderr)
return executor(ctx)
}
}

View file

@ -46,6 +46,8 @@ runs:
Runs: model.ActionRuns{ Runs: model.ActionRuns{
Using: "node16", Using: "node16",
Main: "main.js", Main: "main.js",
PreIf: "always()",
PostIf: "always()",
}, },
}, },
}, },
@ -59,6 +61,8 @@ runs:
Runs: model.ActionRuns{ Runs: model.ActionRuns{
Using: "node16", Using: "node16",
Main: "main.js", Main: "main.js",
PreIf: "always()",
PostIf: "always()",
}, },
}, },
}, },
@ -140,12 +144,13 @@ func TestActionRunner(t *testing.T) {
table := []struct { table := []struct {
name string name string
step actionStep step actionStep
expectedEnv map[string]string
}{ }{
{ {
name: "Test", name: "with-input",
step: &stepActionRemote{ step: &stepActionRemote{
Step: &model.Step{ Step: &model.Step{
Uses: "repo@ref", Uses: "org/repo/path@ref",
}, },
RunContext: &RunContext{ RunContext: &RunContext{
Config: &Config{}, Config: &Config{},
@ -172,6 +177,47 @@ func TestActionRunner(t *testing.T) {
}, },
env: map[string]string{}, env: map[string]string{},
}, },
expectedEnv: map[string]string{"INPUT_KEY": "default value"},
},
{
name: "restore-saved-state",
step: &stepActionRemote{
Step: &model.Step{
ID: "step",
Uses: "org/repo/path@ref",
},
RunContext: &RunContext{
ActionRepository: "org/repo",
ActionPath: "path",
ActionRef: "ref",
Config: &Config{},
Run: &model.Run{
JobID: "job",
Workflow: &model.Workflow{
Jobs: map[string]*model.Job{
"job": {
Name: "job",
},
},
},
},
CurrentStep: "post-step",
StepResults: map[string]*model.StepResult{
"step": {
State: map[string]string{
"name": "state value",
},
},
},
},
action: &model.Action{
Runs: model.ActionRuns{
Using: "node16",
},
},
env: map[string]string{},
},
expectedEnv: map[string]string{"STATE_name": "state value"},
}, },
} }
@ -183,8 +229,14 @@ func TestActionRunner(t *testing.T) {
cm.On("CopyDir", "/var/run/act/actions/dir/", "dir/", false).Return(func(ctx context.Context) error { return nil }) cm.On("CopyDir", "/var/run/act/actions/dir/", "dir/", false).Return(func(ctx context.Context) error { return nil })
envMatcher := mock.MatchedBy(func(env map[string]string) bool { envMatcher := mock.MatchedBy(func(env map[string]string) bool {
return env["INPUT_KEY"] == "default value" for k, v := range tt.expectedEnv {
if env[k] != v {
return false
}
}
return true
}) })
cm.On("Exec", []string{"node", "/var/run/act/actions/dir/path"}, envMatcher, "", "").Return(func(ctx context.Context) error { return nil }) cm.On("Exec", []string{"node", "/var/run/act/actions/dir/path"}, envMatcher, "", "").Return(func(ctx context.Context) error { return nil })
tt.step.getRunContext().JobContainer = cm tt.step.getRunContext().JobContainer = cm

View file

@ -63,6 +63,9 @@ func (rc *RunContext) commandHandler(ctx context.Context) common.LineHandler {
case resumeCommand: case resumeCommand:
resumeCommand = "" resumeCommand = ""
logger.Infof(" \U00002699 %s", line) logger.Infof(" \U00002699 %s", line)
case "save-state":
logger.Infof(" \U0001f4be %s", line)
rc.saveState(ctx, kvPairs, arg)
default: default:
logger.Infof(" \U00002753 %s", line) logger.Infof(" \U00002753 %s", line)
} }
@ -141,3 +144,15 @@ func unescapeKvPairs(kvPairs map[string]string) map[string]string {
} }
return kvPairs return kvPairs
} }
func (rc *RunContext) saveState(ctx context.Context, kvPairs map[string]string, arg string) {
if rc.CurrentStep != "" {
stepResult := rc.StepResults[rc.CurrentStep]
if stepResult != nil {
if stepResult.State == nil {
stepResult.State = map[string]string{}
}
stepResult.State[kvPairs["name"]] = arg
}
}
}

View file

@ -173,3 +173,21 @@ func TestAddmaskUsemask(t *testing.T) {
a.Equal("[testjob] \U00002699 ***\n[testjob] \U00002699 ::set-output:: = token=***\n", re) a.Equal("[testjob] \U00002699 ***\n[testjob] \U00002699 ::set-output:: = token=***\n", re)
} }
func TestSaveState(t *testing.T) {
rc := &RunContext{
CurrentStep: "step",
StepResults: map[string]*model.StepResult{
"step": {
State: map[string]string{},
},
},
}
ctx := context.Background()
handler := rc.commandHandler(ctx)
handler("::save-state name=state-name::state-value\n")
assert.Equal(t, "state-value", rc.StepResults["step"].State["state-name"])
}

View file

@ -12,7 +12,7 @@ import (
// ExpressionEvaluator is the interface for evaluating expressions // ExpressionEvaluator is the interface for evaluating expressions
type ExpressionEvaluator interface { type ExpressionEvaluator interface {
evaluate(string, bool) (interface{}, error) evaluate(string, exprparser.DefaultStatusCheck) (interface{}, error)
EvaluateYamlNode(node *yaml.Node) error EvaluateYamlNode(node *yaml.Node) error
Interpolate(string) string Interpolate(string) string
} }
@ -115,9 +115,9 @@ type expressionEvaluator struct {
interpreter exprparser.Interpreter interpreter exprparser.Interpreter
} }
func (ee expressionEvaluator) evaluate(in string, isIfExpression bool) (interface{}, error) { func (ee expressionEvaluator) evaluate(in string, defaultStatusCheck exprparser.DefaultStatusCheck) (interface{}, error) {
log.Debugf("evaluating expression '%s'", in) log.Debugf("evaluating expression '%s'", in)
evaluated, err := ee.interpreter.Evaluate(in, isIfExpression) evaluated, err := ee.interpreter.Evaluate(in, defaultStatusCheck)
log.Debugf("expression '%s' evaluated to '%t'", in, evaluated) log.Debugf("expression '%s' evaluated to '%t'", in, evaluated)
return evaluated, err return evaluated, err
} }
@ -131,7 +131,7 @@ func (ee expressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error {
return nil return nil
} }
expr, _ := rewriteSubExpression(in, false) expr, _ := rewriteSubExpression(in, false)
res, err := ee.evaluate(expr, false) res, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone)
if err != nil { if err != nil {
return err return err
} }
@ -201,7 +201,7 @@ func (ee expressionEvaluator) Interpolate(in string) string {
} }
expr, _ := rewriteSubExpression(in, true) expr, _ := rewriteSubExpression(in, true)
evaluated, err := ee.evaluate(expr, false) evaluated, err := ee.evaluate(expr, exprparser.DefaultStatusCheckNone)
if err != nil { if err != nil {
log.Errorf("Unable to interpolate expression '%s': %s", expr, err) log.Errorf("Unable to interpolate expression '%s': %s", expr, err)
return "" return ""
@ -216,10 +216,10 @@ func (ee expressionEvaluator) Interpolate(in string) string {
} }
// EvalBool evaluates an expression against given evaluator // EvalBool evaluates an expression against given evaluator
func EvalBool(evaluator ExpressionEvaluator, expr string) (bool, error) { func EvalBool(evaluator ExpressionEvaluator, expr string, defaultStatusCheck exprparser.DefaultStatusCheck) (bool, error) {
nextExpr, _ := rewriteSubExpression(expr, false) nextExpr, _ := rewriteSubExpression(expr, false)
evaluated, err := evaluator.evaluate(nextExpr, true) evaluated, err := evaluator.evaluate(nextExpr, defaultStatusCheck)
if err != nil { if err != nil {
return false, err return false, err
} }

View file

@ -7,6 +7,7 @@ import (
"sort" "sort"
"testing" "testing"
"github.com/nektos/act/pkg/exprparser"
"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" yaml "gopkg.in/yaml.v3"
@ -135,7 +136,7 @@ func TestEvaluateRunContext(t *testing.T) {
table := table table := table
t.Run(table.in, func(t *testing.T) { t.Run(table.in, func(t *testing.T) {
assertObject := assert.New(t) assertObject := assert.New(t)
out, err := ee.evaluate(table.in, false) out, err := ee.evaluate(table.in, exprparser.DefaultStatusCheckNone)
if table.errMesg == "" { if table.errMesg == "" {
assertObject.NoError(err, table.in) assertObject.NoError(err, table.in)
assertObject.Equal(table.out, out, table.in) assertObject.Equal(table.out, out, table.in)
@ -175,7 +176,7 @@ func TestEvaluateStep(t *testing.T) {
table := table table := table
t.Run(table.in, func(t *testing.T) { t.Run(table.in, func(t *testing.T) {
assertObject := assert.New(t) assertObject := assert.New(t)
out, err := ee.evaluate(table.in, false) out, err := ee.evaluate(table.in, exprparser.DefaultStatusCheckNone)
if table.errMesg == "" { if table.errMesg == "" {
assertObject.NoError(err, table.in) assertObject.NoError(err, table.in)
assertObject.Equal(table.out, out, table.in) assertObject.Equal(table.out, out, table.in)

View file

@ -21,7 +21,7 @@ type jobInfo interface {
func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor { func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executor {
steps := make([]common.Executor, 0) steps := make([]common.Executor, 0)
preSteps := make([]common.Executor, 0) preSteps := make([]common.Executor, 0)
postSteps := make([]common.Executor, 0) var postExecutor common.Executor
steps = append(steps, func(ctx context.Context) error { steps = append(steps, func(ctx context.Context) error {
if len(info.matrix()) > 0 { if len(info.matrix()) > 0 {
@ -72,10 +72,15 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
})(withStepLogger(ctx, stepName)) })(withStepLogger(ctx, stepName))
}) })
postSteps = append([]common.Executor{step.post()}, postSteps...) // run the post exector in reverse order
if postExecutor != nil {
postExecutor = step.post().Finally(postExecutor)
} else {
postExecutor = step.post()
}
} }
postSteps = append(postSteps, func(ctx context.Context) error { postExecutor = postExecutor.Finally(func(ctx context.Context) error {
jobError := common.JobError(ctx) jobError := common.JobError(ctx)
if jobError != nil { if jobError != nil {
info.result("failure") info.result("failure")
@ -93,7 +98,9 @@ func newJobExecutor(info jobInfo, sf stepFactory, rc *RunContext) common.Executo
pipeline := make([]common.Executor, 0) pipeline := make([]common.Executor, 0)
pipeline = append(pipeline, preSteps...) pipeline = append(pipeline, preSteps...)
pipeline = append(pipeline, steps...) pipeline = append(pipeline, steps...)
pipeline = append(pipeline, postSteps...)
return common.NewPipelineExecutor(pipeline...).Finally(info.interpolateOutputs()).Finally(info.closeContainer()) return common.NewPipelineExecutor(pipeline...).
Finally(postExecutor).
Finally(info.interpolateOutputs()).
Finally(info.closeContainer())
} }

View file

@ -20,6 +20,7 @@ import (
"github.com/nektos/act/pkg/common" "github.com/nektos/act/pkg/common"
"github.com/nektos/act/pkg/container" "github.com/nektos/act/pkg/container"
"github.com/nektos/act/pkg/exprparser"
"github.com/nektos/act/pkg/model" "github.com/nektos/act/pkg/model"
) )
@ -346,7 +347,7 @@ func (rc *RunContext) hostname() string {
func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) { func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) {
job := rc.Run.Job() job := rc.Run.Job()
l := common.Logger(ctx) l := common.Logger(ctx)
runJob, err := EvalBool(rc.ExprEval, job.If.Value) runJob, err := EvalBool(rc.ExprEval, job.If.Value, exprparser.DefaultStatusCheckSuccess)
if err != nil { if err != nil {
return false, fmt.Errorf(" \u274C Error in if-expression: \"if: %s\" (%s)", job.If.Value, err) return false, fmt.Errorf(" \u274C Error in if-expression: \"if: %s\" (%s)", job.If.Value, err)
} }

View file

@ -10,6 +10,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/nektos/act/pkg/exprparser"
"github.com/nektos/act/pkg/model" "github.com/nektos/act/pkg/model"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -155,7 +156,7 @@ func TestRunContext_EvalBool(t *testing.T) {
table := table table := table
t.Run(table.in, func(t *testing.T) { t.Run(table.in, func(t *testing.T) {
assertObject := assert.New(t) assertObject := assert.New(t)
b, err := EvalBool(rc.ExprEval, table.in) b, err := EvalBool(rc.ExprEval, table.in, exprparser.DefaultStatusCheckSuccess)
if table.wantErr { if table.wantErr {
assertObject.Error(err) assertObject.Error(err)
} }

View file

@ -173,6 +173,7 @@ func TestRunEvent(t *testing.T) {
{workdir, "job-status-check", "push", "job 'fail' failed", platforms}, {workdir, "job-status-check", "push", "job 'fail' failed", platforms},
{workdir, "if-expressions", "push", "Job 'mytest' failed", platforms}, {workdir, "if-expressions", "push", "Job 'mytest' failed", platforms},
{workdir, "actions-environment-and-context-tests", "push", "", platforms}, {workdir, "actions-environment-and-context-tests", "push", "", platforms},
{workdir, "uses-action-with-pre-and-post-step", "push", "", platforms},
{"../model/testdata", "strategy", "push", "", platforms}, // TODO: move all testdata into pkg so we can validate it with planner and runner {"../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
{"../model/testdata", "container-volumes", "push", "", platforms}, {"../model/testdata", "container-volumes", "push", "", platforms},

View file

@ -6,6 +6,7 @@ import (
"strings" "strings"
"github.com/nektos/act/pkg/common" "github.com/nektos/act/pkg/common"
"github.com/nektos/act/pkg/exprparser"
"github.com/nektos/act/pkg/model" "github.com/nektos/act/pkg/model"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@ -18,14 +19,49 @@ type step interface {
getRunContext() *RunContext getRunContext() *RunContext
getStepModel() *model.Step getStepModel() *model.Step
getEnv() *map[string]string getEnv() *map[string]string
getIfExpression(stage stepStage) string
} }
func runStepExecutor(step step, executor common.Executor) common.Executor { type stepStage int
const (
stepStagePre stepStage = iota
stepStageMain
stepStagePost
)
func (s stepStage) String() string {
switch s {
case stepStagePre:
return "Pre"
case stepStageMain:
return "Main"
case stepStagePost:
return "Post"
}
return "Unknown"
}
func (s stepStage) getStepName(stepModel *model.Step) string {
switch s {
case stepStagePre:
return fmt.Sprintf("pre-%s", stepModel.ID)
case stepStageMain:
return stepModel.ID
case stepStagePost:
return fmt.Sprintf("post-%s", stepModel.ID)
}
return "unknown"
}
func runStepExecutor(step step, stage stepStage, executor common.Executor) common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
rc := step.getRunContext() rc := step.getRunContext()
stepModel := step.getStepModel() stepModel := step.getStepModel()
rc.CurrentStep = stepModel.ID ifExpression := step.getIfExpression(stage)
rc.CurrentStep = stage.getStepName(stepModel)
rc.StepResults[rc.CurrentStep] = &model.StepResult{ rc.StepResults[rc.CurrentStep] = &model.StepResult{
Outcome: model.StepStatusSuccess, Outcome: model.StepStatusSuccess,
Conclusion: model.StepStatusSuccess, Conclusion: model.StepStatusSuccess,
@ -37,7 +73,7 @@ func runStepExecutor(step step, executor common.Executor) common.Executor {
return err return err
} }
runStep, err := isStepEnabled(ctx, step) runStep, err := isStepEnabled(ctx, ifExpression, step, stage)
if err != nil { if err != nil {
rc.StepResults[rc.CurrentStep].Conclusion = model.StepStatusFailure rc.StepResults[rc.CurrentStep].Conclusion = model.StepStatusFailure
rc.StepResults[rc.CurrentStep].Outcome = model.StepStatusFailure rc.StepResults[rc.CurrentStep].Outcome = model.StepStatusFailure
@ -45,7 +81,7 @@ func runStepExecutor(step step, executor common.Executor) common.Executor {
} }
if !runStep { if !runStep {
log.Debugf("Skipping step '%s' due to '%s'", stepModel.String(), stepModel.If.Value) log.Debugf("Skipping step '%s' due to '%s'", stepModel, ifExpression)
rc.StepResults[rc.CurrentStep].Conclusion = model.StepStatusSkipped rc.StepResults[rc.CurrentStep].Conclusion = model.StepStatusSkipped
rc.StepResults[rc.CurrentStep].Outcome = model.StepStatusSkipped rc.StepResults[rc.CurrentStep].Outcome = model.StepStatusSkipped
return nil return nil
@ -55,14 +91,14 @@ func runStepExecutor(step step, executor common.Executor) common.Executor {
if strings.Contains(stepString, "::add-mask::") { if strings.Contains(stepString, "::add-mask::") {
stepString = "add-mask command" stepString = "add-mask command"
} }
common.Logger(ctx).Infof("\u2B50 Run %s", stepString) common.Logger(ctx).Infof("\u2B50 Run %s %s", stage, stepString)
err = executor(ctx) err = executor(ctx)
if err == nil { if err == nil {
common.Logger(ctx).Infof(" \u2705 Success - %s", stepString) common.Logger(ctx).Infof(" \u2705 Success - %s %s", stage, stepString)
} else { } else {
common.Logger(ctx).Errorf(" \u274C Failure - %s", stepString) common.Logger(ctx).Errorf(" \u274C Failure - %s %s", stage, stepString)
rc.StepResults[rc.CurrentStep].Outcome = model.StepStatusFailure rc.StepResults[rc.CurrentStep].Outcome = model.StepStatusFailure
if stepModel.ContinueOnError { if stepModel.ContinueOnError {
@ -129,12 +165,19 @@ func mergeEnv(step step) {
mergeIntoMap(env, rc.withGithubEnv(*env)) mergeIntoMap(env, rc.withGithubEnv(*env))
} }
func isStepEnabled(ctx context.Context, step step) (bool, error) { func isStepEnabled(ctx context.Context, expr string, step step, stage stepStage) (bool, error) {
rc := step.getRunContext() rc := step.getRunContext()
runStep, err := EvalBool(rc.NewStepExpressionEvaluator(step), step.getStepModel().If.Value) var defaultStatusCheck exprparser.DefaultStatusCheck
if stage == stepStagePost {
defaultStatusCheck = exprparser.DefaultStatusCheckAlways
} else {
defaultStatusCheck = exprparser.DefaultStatusCheckSuccess
}
runStep, err := EvalBool(rc.NewStepExpressionEvaluator(step), expr, defaultStatusCheck)
if err != nil { if err != nil {
return false, fmt.Errorf(" \u274C Error in if-expression: \"if: %s\" (%s)", step.getStepModel().If.Value, err) return false, fmt.Errorf(" \u274C Error in if-expression: \"if: %s\" (%s)", expr, err)
} }
return runStep, nil return runStep, nil

View file

@ -11,12 +11,13 @@ import (
"github.com/nektos/act/pkg/common" "github.com/nektos/act/pkg/common"
"github.com/nektos/act/pkg/model" "github.com/nektos/act/pkg/model"
log "github.com/sirupsen/logrus"
) )
type stepActionLocal struct { type stepActionLocal struct {
Step *model.Step Step *model.Step
RunContext *RunContext RunContext *RunContext
compositeRunContext *RunContext
compositeSteps *compositeSteps
runAction runAction runAction runAction
readAction readAction readAction readAction
env map[string]string env map[string]string
@ -24,15 +25,9 @@ type stepActionLocal struct {
} }
func (sal *stepActionLocal) pre() common.Executor { func (sal *stepActionLocal) pre() common.Executor {
return func(ctx context.Context) error {
return nil
}
}
func (sal *stepActionLocal) main() common.Executor {
sal.env = map[string]string{} sal.env = map[string]string{}
return runStepExecutor(sal, func(ctx context.Context) error { return func(ctx context.Context) error {
actionDir := filepath.Join(sal.getRunContext().Config.Workdir, sal.Step.Uses) actionDir := filepath.Join(sal.getRunContext().Config.Workdir, sal.Step.Uses)
localReader := func(ctx context.Context) actionYamlReader { localReader := func(ctx context.Context) actionYamlReader {
@ -56,16 +51,27 @@ func (sal *stepActionLocal) main() common.Executor {
} }
sal.action = actionModel sal.action = actionModel
log.Debugf("Read action %v from '%s'", sal.action, "Unknown")
// run local pre step only for composite actions, to allow to run
// inside pre steps
if sal.action.Runs.Using == model.ActionRunsUsingComposite {
sal.RunContext.setupActionInputs(sal)
return runStepExecutor(sal, stepStagePre, runPreStep(sal)).If(hasPreStep(sal)).If(shouldRunPreStep(sal))(ctx)
}
return nil
}
}
func (sal *stepActionLocal) main() common.Executor {
return runStepExecutor(sal, stepStageMain, func(ctx context.Context) error {
actionDir := filepath.Join(sal.getRunContext().Config.Workdir, sal.Step.Uses)
return sal.runAction(sal, actionDir, nil)(ctx) return sal.runAction(sal, actionDir, nil)(ctx)
}) })
} }
func (sal *stepActionLocal) post() common.Executor { func (sal *stepActionLocal) post() common.Executor {
return func(ctx context.Context) error { return runStepExecutor(sal, stepStagePost, runPostStep(sal)).If(hasPostStep(sal)).If(shouldRunPostStep(sal))
return nil
}
} }
func (sal *stepActionLocal) getRunContext() *RunContext { func (sal *stepActionLocal) getRunContext() *RunContext {
@ -80,6 +86,31 @@ func (sal *stepActionLocal) getEnv() *map[string]string {
return &sal.env return &sal.env
} }
func (sal *stepActionLocal) getIfExpression(stage stepStage) string {
switch stage {
case stepStageMain:
return sal.Step.If.Value
case stepStagePost:
return sal.action.Runs.PostIf
}
return ""
}
func (sal *stepActionLocal) getActionModel() *model.Action { func (sal *stepActionLocal) getActionModel() *model.Action {
return sal.action return sal.action
} }
func (sal *stepActionLocal) getCompositeRunContext() *RunContext {
if sal.compositeRunContext == nil {
actionDir := filepath.Join(sal.RunContext.Config.Workdir, sal.Step.Uses)
_, containerActionDir := getContainerActionPaths(sal.getStepModel(), actionDir, sal.RunContext)
sal.compositeRunContext = newCompositeRunContext(sal.RunContext, sal, containerActionDir)
sal.compositeSteps = sal.compositeRunContext.compositeExecutor(sal.action)
}
return sal.compositeRunContext
}
func (sal *stepActionLocal) getCompositeSteps() *compositeSteps {
return sal.compositeSteps
}

View file

@ -2,12 +2,14 @@ package runner
import ( import (
"context" "context"
"strings"
"testing" "testing"
"github.com/nektos/act/pkg/common" "github.com/nektos/act/pkg/common"
"github.com/nektos/act/pkg/model" "github.com/nektos/act/pkg/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"gopkg.in/yaml.v3"
) )
type stepActionLocalMocks struct { type stepActionLocalMocks struct {
@ -80,22 +82,252 @@ func TestStepActionLocalTest(t *testing.T) {
return nil return nil
}) })
err := sal.main()(ctx) err := sal.pre()(ctx)
assert.Nil(t, err)
err = sal.main()(ctx)
assert.Nil(t, err) assert.Nil(t, err)
cm.AssertExpectations(t) cm.AssertExpectations(t)
salm.AssertExpectations(t) salm.AssertExpectations(t)
} }
func TestStepActionLocalPrePost(t *testing.T) { func TestStepActionLocalPre(t *testing.T) {
cm := &containerMock{}
salm := &stepActionLocalMocks{}
ctx := context.Background() ctx := context.Background()
sal := &stepActionLocal{} sal := &stepActionLocal{
readAction: salm.readAction,
RunContext: &RunContext{
StepResults: map[string]*model.StepResult{},
ExprEval: &expressionEvaluator{},
Config: &Config{
Workdir: "/tmp",
},
Run: &model.Run{
JobID: "1",
Workflow: &model.Workflow{
Jobs: map[string]*model.Job{
"1": {
Defaults: model.Defaults{
Run: model.RunDefaults{
Shell: "bash",
},
},
},
},
},
},
JobContainer: cm,
},
Step: &model.Step{
ID: "1",
Uses: "./path/to/action",
},
}
salm.On("readAction", sal.Step, "/tmp/path/to/action", "", mock.Anything, mock.Anything).
Return(&model.Action{}, nil)
err := sal.pre()(ctx) err := sal.pre()(ctx)
assert.Nil(t, err) assert.Nil(t, err)
err = sal.post()(ctx) cm.AssertExpectations(t)
assert.Nil(t, err) salm.AssertExpectations(t)
}
func TestStepActionLocalPost(t *testing.T) {
table := []struct {
name string
stepModel *model.Step
actionModel *model.Action
initialStepResults map[string]*model.StepResult
expectedPostStepResult *model.StepResult
err error
mocks struct {
env bool
exec bool
}
}{
{
name: "main-success",
stepModel: &model.Step{
ID: "step",
Uses: "./local/action",
},
actionModel: &model.Action{
Runs: model.ActionRuns{
Using: "node16",
Post: "post.js",
PostIf: "always()",
},
},
initialStepResults: map[string]*model.StepResult{
"step": {
Conclusion: model.StepStatusSuccess,
Outcome: model.StepStatusSuccess,
Outputs: map[string]string{},
},
},
expectedPostStepResult: &model.StepResult{
Conclusion: model.StepStatusSuccess,
Outcome: model.StepStatusSuccess,
Outputs: map[string]string{},
},
mocks: struct {
env bool
exec bool
}{
env: true,
exec: true,
},
},
{
name: "main-failed",
stepModel: &model.Step{
ID: "step",
Uses: "./local/action",
},
actionModel: &model.Action{
Runs: model.ActionRuns{
Using: "node16",
Post: "post.js",
PostIf: "always()",
},
},
initialStepResults: map[string]*model.StepResult{
"step": {
Conclusion: model.StepStatusFailure,
Outcome: model.StepStatusFailure,
Outputs: map[string]string{},
},
},
expectedPostStepResult: &model.StepResult{
Conclusion: model.StepStatusSuccess,
Outcome: model.StepStatusSuccess,
Outputs: map[string]string{},
},
mocks: struct {
env bool
exec bool
}{
env: true,
exec: true,
},
},
{
name: "skip-if-failed",
stepModel: &model.Step{
ID: "step",
Uses: "./local/action",
},
actionModel: &model.Action{
Runs: model.ActionRuns{
Using: "node16",
Post: "post.js",
PostIf: "success()",
},
},
initialStepResults: map[string]*model.StepResult{
"step": {
Conclusion: model.StepStatusFailure,
Outcome: model.StepStatusFailure,
Outputs: map[string]string{},
},
},
expectedPostStepResult: &model.StepResult{
Conclusion: model.StepStatusSkipped,
Outcome: model.StepStatusSkipped,
Outputs: map[string]string{},
},
mocks: struct {
env bool
exec bool
}{
env: true,
exec: false,
},
},
{
name: "skip-if-main-skipped",
stepModel: &model.Step{
ID: "step",
If: yaml.Node{Value: "failure()"},
Uses: "./local/action",
},
actionModel: &model.Action{
Runs: model.ActionRuns{
Using: "node16",
Post: "post.js",
PostIf: "always()",
},
},
initialStepResults: map[string]*model.StepResult{
"step": {
Conclusion: model.StepStatusSkipped,
Outcome: model.StepStatusSkipped,
Outputs: map[string]string{},
},
},
expectedPostStepResult: nil,
mocks: struct {
env bool
exec bool
}{
env: false,
exec: false,
},
},
}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
cm := &containerMock{}
sal := &stepActionLocal{
env: map[string]string{},
RunContext: &RunContext{
Config: &Config{
GitHubInstance: "https://github.com",
},
JobContainer: cm,
Run: &model.Run{
JobID: "1",
Workflow: &model.Workflow{
Jobs: map[string]*model.Job{
"1": {},
},
},
},
StepResults: tt.initialStepResults,
},
Step: tt.stepModel,
action: tt.actionModel,
}
if tt.mocks.env {
cm.On("UpdateFromImageEnv", &sal.env).Return(func(ctx context.Context) error { return nil })
cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", &sal.env).Return(func(ctx context.Context) error { return nil })
cm.On("UpdateFromPath", &sal.env).Return(func(ctx context.Context) error { return nil })
}
if tt.mocks.exec {
suffixMatcher := func(suffix string) interface{} {
return mock.MatchedBy(func(array []string) bool {
return strings.HasSuffix(array[1], suffix)
})
}
cm.On("Exec", suffixMatcher("pkg/runner/local/action/post.js"), sal.env, "", "").Return(func(ctx context.Context) error { return tt.err })
}
err := sal.post()(ctx)
assert.Equal(t, tt.err, err)
assert.Equal(t, tt.expectedPostStepResult, sal.RunContext.StepResults["post-step"])
cm.AssertExpectations(t)
})
}
} }

View file

@ -6,6 +6,7 @@ import (
"io" "io"
"io/ioutil" "io/ioutil"
"os" "os"
"path"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
@ -13,49 +14,46 @@ import (
"github.com/nektos/act/pkg/common" "github.com/nektos/act/pkg/common"
"github.com/nektos/act/pkg/model" "github.com/nektos/act/pkg/model"
"github.com/pkg/errors" "github.com/pkg/errors"
log "github.com/sirupsen/logrus"
) )
type stepActionRemote struct { type stepActionRemote struct {
Step *model.Step Step *model.Step
RunContext *RunContext RunContext *RunContext
compositeRunContext *RunContext
compositeSteps *compositeSteps
readAction readAction readAction readAction
runAction runAction runAction runAction
action *model.Action action *model.Action
env map[string]string env map[string]string
} remoteAction *remoteAction
func (sar *stepActionRemote) pre() common.Executor {
return func(ctx context.Context) error {
return nil
}
} }
var ( var (
stepActionRemoteNewCloneExecutor = common.NewGitCloneExecutor stepActionRemoteNewCloneExecutor = common.NewGitCloneExecutor
) )
func (sar *stepActionRemote) main() common.Executor { func (sar *stepActionRemote) pre() common.Executor {
sar.env = map[string]string{} sar.env = map[string]string{}
return runStepExecutor(sar, func(ctx context.Context) error { return common.NewPipelineExecutor(
remoteAction := newRemoteAction(sar.Step.Uses) func(ctx context.Context) error {
if remoteAction == nil { sar.remoteAction = newRemoteAction(sar.Step.Uses)
if sar.remoteAction == nil {
return fmt.Errorf("Expected format {org}/{repo}[/path]@ref. Actual '%s' Input string was not in a correct format", sar.Step.Uses) return fmt.Errorf("Expected format {org}/{repo}[/path]@ref. Actual '%s' Input string was not in a correct format", sar.Step.Uses)
} }
remoteAction.URL = sar.RunContext.Config.GitHubInstance sar.remoteAction.URL = sar.RunContext.Config.GitHubInstance
github := sar.RunContext.getGithubContext() github := sar.RunContext.getGithubContext()
if remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout { if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout {
common.Logger(ctx).Debugf("Skipping local actions/checkout because workdir was already copied") common.Logger(ctx).Debugf("Skipping local actions/checkout because workdir was already copied")
return nil return nil
} }
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), strings.ReplaceAll(sar.Step.Uses, "/", "-")) actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), strings.ReplaceAll(sar.Step.Uses, "/", "-"))
gitClone := stepActionRemoteNewCloneExecutor(common.NewGitCloneExecutorInput{ gitClone := stepActionRemoteNewCloneExecutor(common.NewGitCloneExecutorInput{
URL: remoteAction.CloneURL(), URL: sar.remoteAction.CloneURL(),
Ref: remoteAction.Ref, Ref: sar.remoteAction.Ref,
Dir: actionDir, Dir: actionDir,
Token: github.Token, Token: github.Token,
}) })
@ -63,7 +61,7 @@ func (sar *stepActionRemote) main() common.Executor {
if err := gitClone(ctx); err != nil { if err := gitClone(ctx); err != nil {
if err.Error() == "short SHA references are not supported" { if err.Error() == "short SHA references are not supported" {
err = errors.Cause(err) err = errors.Cause(err)
return fmt.Errorf("Unable to resolve action `%s`, the provided ref `%s` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `%s` instead", sar.Step.Uses, remoteAction.Ref, err.Error()) return fmt.Errorf("Unable to resolve action `%s`, the provided ref `%s` is the shortened version of a commit SHA, which is not supported. Please use the full commit SHA `%s` instead", sar.Step.Uses, sar.remoteAction.Ref, err.Error())
} else if err.Error() != "some refs were not updated" { } else if err.Error() != "some refs were not updated" {
return err return err
} else { } else {
@ -73,7 +71,7 @@ func (sar *stepActionRemote) main() common.Executor {
remoteReader := func(ctx context.Context) actionYamlReader { remoteReader := func(ctx context.Context) actionYamlReader {
return func(filename string) (io.Reader, io.Closer, error) { return func(filename string) (io.Reader, io.Closer, error) {
f, err := os.Open(filepath.Join(actionDir, remoteAction.Path, filename)) f, err := os.Open(filepath.Join(actionDir, sar.remoteAction.Path, filename))
return f, f, err return f, f, err
} }
} }
@ -81,20 +79,37 @@ func (sar *stepActionRemote) main() common.Executor {
return common.NewPipelineExecutor( return common.NewPipelineExecutor(
ntErr, ntErr,
func(ctx context.Context) error { func(ctx context.Context) error {
actionModel, err := sar.readAction(sar.Step, actionDir, remoteAction.Path, remoteReader(ctx), ioutil.WriteFile) actionModel, err := sar.readAction(sar.Step, actionDir, sar.remoteAction.Path, remoteReader(ctx), ioutil.WriteFile)
sar.action = actionModel sar.action = actionModel
log.Debugf("Read action %v from '%s'", sar.action, "Unknown")
return err return err
}, },
sar.runAction(sar, actionDir, remoteAction), )(ctx)
},
func(ctx context.Context) error {
sar.RunContext.setupActionInputs(sar)
return nil
},
runStepExecutor(sar, stepStagePre, runPreStep(sar)).If(hasPreStep(sar)).If(shouldRunPreStep(sar)))
}
func (sar *stepActionRemote) main() common.Executor {
return runStepExecutor(sar, stepStageMain, func(ctx context.Context) error {
github := sar.RunContext.getGithubContext()
if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout {
common.Logger(ctx).Debugf("Skipping local actions/checkout because workdir was already copied")
return nil
}
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), strings.ReplaceAll(sar.Step.Uses, "/", "-"))
return common.NewPipelineExecutor(
sar.runAction(sar, actionDir, sar.remoteAction),
)(ctx) )(ctx)
}) })
} }
func (sar *stepActionRemote) post() common.Executor { func (sar *stepActionRemote) post() common.Executor {
return func(ctx context.Context) error { return runStepExecutor(sar, stepStagePost, runPostStep(sar)).If(hasPostStep(sar)).If(shouldRunPostStep(sar))
return nil
}
} }
func (sar *stepActionRemote) getRunContext() *RunContext { func (sar *stepActionRemote) getRunContext() *RunContext {
@ -109,10 +124,43 @@ func (sar *stepActionRemote) getEnv() *map[string]string {
return &sar.env return &sar.env
} }
func (sar *stepActionRemote) getIfExpression(stage stepStage) string {
switch stage {
case stepStagePre:
github := sar.RunContext.getGithubContext()
if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout {
// skip local checkout pre step
return "false"
}
return sar.action.Runs.PreIf
case stepStageMain:
return sar.Step.If.Value
case stepStagePost:
return sar.action.Runs.PostIf
}
return ""
}
func (sar *stepActionRemote) getActionModel() *model.Action { func (sar *stepActionRemote) getActionModel() *model.Action {
return sar.action return sar.action
} }
func (sar *stepActionRemote) getCompositeRunContext() *RunContext {
if sar.compositeRunContext == nil {
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), strings.ReplaceAll(sar.Step.Uses, "/", "-"))
actionLocation := path.Join(actionDir, sar.remoteAction.Path)
_, containerActionDir := getContainerActionPaths(sar.getStepModel(), actionLocation, sar.RunContext)
sar.compositeRunContext = newCompositeRunContext(sar.RunContext, sar, containerActionDir)
sar.compositeSteps = sar.compositeRunContext.compositeExecutor(sar.action)
}
return sar.compositeRunContext
}
func (sar *stepActionRemote) getCompositeSteps() *compositeSteps {
return sar.compositeSteps
}
type remoteAction struct { type remoteAction struct {
URL string URL string
Org string Org string

View file

@ -2,6 +2,7 @@ package runner
import ( import (
"context" "context"
"errors"
"strings" "strings"
"testing" "testing"
@ -9,6 +10,7 @@ import (
"github.com/nektos/act/pkg/model" "github.com/nektos/act/pkg/model"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
"gopkg.in/yaml.v3"
) )
type stepActionRemoteMocks struct { type stepActionRemoteMocks struct {
@ -25,11 +27,97 @@ func (sarm *stepActionRemoteMocks) runAction(step actionStep, actionDir string,
return args.Get(0).(func(context.Context) error) return args.Get(0).(func(context.Context) error)
} }
func TestStepActionRemoteTest(t *testing.T) { func TestStepActionRemote(t *testing.T) {
table := []struct {
name string
stepModel *model.Step
result *model.StepResult
mocks struct {
env bool
cloned bool
read bool
run bool
}
runError error
}{
{
name: "run-successful",
stepModel: &model.Step{
ID: "step",
Uses: "remote/action@v1",
},
result: &model.StepResult{
Conclusion: model.StepStatusSuccess,
Outcome: model.StepStatusSuccess,
Outputs: map[string]string{},
},
mocks: struct {
env bool
cloned bool
read bool
run bool
}{
env: true,
cloned: true,
read: true,
run: true,
},
},
{
name: "run-skipped",
stepModel: &model.Step{
ID: "step",
Uses: "remote/action@v1",
If: yaml.Node{Value: "false"},
},
result: &model.StepResult{
Conclusion: model.StepStatusSkipped,
Outcome: model.StepStatusSkipped,
Outputs: map[string]string{},
},
mocks: struct {
env bool
cloned bool
read bool
run bool
}{
env: true,
cloned: true,
read: true,
run: false,
},
},
{
name: "run-error",
stepModel: &model.Step{
ID: "step",
Uses: "remote/action@v1",
},
result: &model.StepResult{
Conclusion: model.StepStatusFailure,
Outcome: model.StepStatusFailure,
Outputs: map[string]string{},
},
mocks: struct {
env bool
cloned bool
read bool
run bool
}{
env: true,
cloned: true,
read: true,
run: true,
},
runError: errors.New("error"),
},
}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background() ctx := context.Background()
cm := &containerMock{} cm := &containerMock{}
sarm := &stepActionRemoteMocks{} sarm := &stepActionRemoteMocks{}
clonedAction := false clonedAction := false
@ -61,9 +149,7 @@ func TestStepActionRemoteTest(t *testing.T) {
StepResults: map[string]*model.StepResult{}, StepResults: map[string]*model.StepResult{},
JobContainer: cm, JobContainer: cm,
}, },
Step: &model.Step{ Step: tt.stepModel,
Uses: "remote/action@v1",
},
readAction: sarm.readAction, readAction: sarm.readAction,
runAction: sarm.runAction, runAction: sarm.runAction,
} }
@ -74,29 +160,297 @@ func TestStepActionRemoteTest(t *testing.T) {
}) })
} }
if tt.mocks.env {
cm.On("UpdateFromImageEnv", &sar.env).Return(func(ctx context.Context) error { return nil }) cm.On("UpdateFromImageEnv", &sar.env).Return(func(ctx context.Context) error { return nil })
cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", &sar.env).Return(func(ctx context.Context) error { return nil }) cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", &sar.env).Return(func(ctx context.Context) error { return nil })
cm.On("UpdateFromPath", &sar.env).Return(func(ctx context.Context) error { return nil }) cm.On("UpdateFromPath", &sar.env).Return(func(ctx context.Context) error { return nil })
sarm.On("readAction", sar.Step, suffixMatcher("act/remote-action@v1"), "", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
sarm.On("runAction", sar, suffixMatcher("act/remote-action@v1"), newRemoteAction(sar.Step.Uses)).Return(func(ctx context.Context) error { return nil })
err := sar.main()(ctx)
assert.Nil(t, err)
assert.True(t, clonedAction)
sarm.AssertExpectations(t)
cm.AssertExpectations(t)
} }
if tt.mocks.read {
func TestStepActionRemotePrePost(t *testing.T) { sarm.On("readAction", sar.Step, suffixMatcher("act/remote-action@v1"), "", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
ctx := context.Background() }
if tt.mocks.run {
sar := &stepActionRemote{} sarm.On("runAction", sar, suffixMatcher("act/remote-action@v1"), newRemoteAction(sar.Step.Uses)).Return(func(ctx context.Context) error { return tt.runError })
}
err := sar.pre()(ctx) err := sar.pre()(ctx)
assert.Nil(t, err) if err == nil {
err = sar.main()(ctx)
err = sar.post()(ctx) }
assert.Nil(t, err)
assert.Equal(t, tt.runError, err)
assert.Equal(t, tt.mocks.cloned, clonedAction)
assert.Equal(t, tt.result, sar.RunContext.StepResults["step"])
sarm.AssertExpectations(t)
cm.AssertExpectations(t)
})
}
}
func TestStepActionRemotePre(t *testing.T) {
table := []struct {
name string
stepModel *model.Step
}{
{
name: "run-pre",
stepModel: &model.Step{
Uses: "org/repo/path@ref",
},
},
}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
clonedAction := false
sarm := &stepActionRemoteMocks{}
origStepAtionRemoteNewCloneExecutor := stepActionRemoteNewCloneExecutor
stepActionRemoteNewCloneExecutor = func(input common.NewGitCloneExecutorInput) common.Executor {
return func(ctx context.Context) error {
clonedAction = true
return nil
}
}
defer (func() {
stepActionRemoteNewCloneExecutor = origStepAtionRemoteNewCloneExecutor
})()
sar := &stepActionRemote{
Step: tt.stepModel,
RunContext: &RunContext{
Config: &Config{
GitHubInstance: "https://github.com",
},
Run: &model.Run{
JobID: "1",
Workflow: &model.Workflow{
Jobs: map[string]*model.Job{
"1": {},
},
},
},
},
readAction: sarm.readAction,
}
suffixMatcher := func(suffix string) interface{} {
return mock.MatchedBy(func(actionDir string) bool {
return strings.HasSuffix(actionDir, suffix)
})
}
sarm.On("readAction", sar.Step, suffixMatcher("org-repo-path@ref"), "path", mock.Anything, mock.Anything).Return(&model.Action{}, nil)
err := sar.pre()(ctx)
assert.Nil(t, err)
assert.Equal(t, true, clonedAction)
sarm.AssertExpectations(t)
})
}
}
func TestStepActionRemotePost(t *testing.T) {
table := []struct {
name string
stepModel *model.Step
actionModel *model.Action
initialStepResults map[string]*model.StepResult
expectedEnv map[string]string
expectedPostStepResult *model.StepResult
err error
mocks struct {
env bool
exec bool
}
}{
{
name: "main-success",
stepModel: &model.Step{
ID: "step",
Uses: "remote/action@v1",
},
actionModel: &model.Action{
Runs: model.ActionRuns{
Using: "node16",
Post: "post.js",
PostIf: "always()",
},
},
initialStepResults: map[string]*model.StepResult{
"step": {
Conclusion: model.StepStatusSuccess,
Outcome: model.StepStatusSuccess,
Outputs: map[string]string{},
State: map[string]string{
"key": "value",
},
},
},
expectedEnv: map[string]string{
"STATE_key": "value",
},
expectedPostStepResult: &model.StepResult{
Conclusion: model.StepStatusSuccess,
Outcome: model.StepStatusSuccess,
Outputs: map[string]string{},
},
mocks: struct {
env bool
exec bool
}{
env: true,
exec: true,
},
},
{
name: "main-failed",
stepModel: &model.Step{
ID: "step",
Uses: "remote/action@v1",
},
actionModel: &model.Action{
Runs: model.ActionRuns{
Using: "node16",
Post: "post.js",
PostIf: "always()",
},
},
initialStepResults: map[string]*model.StepResult{
"step": {
Conclusion: model.StepStatusFailure,
Outcome: model.StepStatusFailure,
Outputs: map[string]string{},
},
},
expectedPostStepResult: &model.StepResult{
Conclusion: model.StepStatusSuccess,
Outcome: model.StepStatusSuccess,
Outputs: map[string]string{},
},
mocks: struct {
env bool
exec bool
}{
env: true,
exec: true,
},
},
{
name: "skip-if-failed",
stepModel: &model.Step{
ID: "step",
Uses: "remote/action@v1",
},
actionModel: &model.Action{
Runs: model.ActionRuns{
Using: "node16",
Post: "post.js",
PostIf: "success()",
},
},
initialStepResults: map[string]*model.StepResult{
"step": {
Conclusion: model.StepStatusFailure,
Outcome: model.StepStatusFailure,
Outputs: map[string]string{},
},
},
expectedPostStepResult: &model.StepResult{
Conclusion: model.StepStatusSkipped,
Outcome: model.StepStatusSkipped,
Outputs: map[string]string{},
},
mocks: struct {
env bool
exec bool
}{
env: true,
exec: false,
},
},
{
name: "skip-if-main-skipped",
stepModel: &model.Step{
ID: "step",
If: yaml.Node{Value: "failure()"},
Uses: "remote/action@v1",
},
actionModel: &model.Action{
Runs: model.ActionRuns{
Using: "node16",
Post: "post.js",
PostIf: "always()",
},
},
initialStepResults: map[string]*model.StepResult{
"step": {
Conclusion: model.StepStatusSkipped,
Outcome: model.StepStatusSkipped,
Outputs: map[string]string{},
},
},
expectedPostStepResult: nil,
mocks: struct {
env bool
exec bool
}{
env: false,
exec: false,
},
},
}
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
cm := &containerMock{}
sar := &stepActionRemote{
env: map[string]string{},
RunContext: &RunContext{
Config: &Config{
GitHubInstance: "https://github.com",
},
JobContainer: cm,
Run: &model.Run{
JobID: "1",
Workflow: &model.Workflow{
Jobs: map[string]*model.Job{
"1": {},
},
},
},
StepResults: tt.initialStepResults,
},
Step: tt.stepModel,
action: tt.actionModel,
}
if tt.mocks.env {
cm.On("UpdateFromImageEnv", &sar.env).Return(func(ctx context.Context) error { return nil })
cm.On("UpdateFromEnv", "/var/run/act/workflow/envs.txt", &sar.env).Return(func(ctx context.Context) error { return nil })
cm.On("UpdateFromPath", &sar.env).Return(func(ctx context.Context) error { return nil })
}
if tt.mocks.exec {
cm.On("Exec", []string{"node", "/var/run/act/actions/remote-action@v1/post.js"}, sar.env, "", "").Return(func(ctx context.Context) error { return tt.err })
}
err := sar.post()(ctx)
assert.Equal(t, tt.err, err)
if tt.expectedEnv != nil {
for key, value := range tt.expectedEnv {
assert.Equal(t, value, sar.env[key])
}
}
assert.Equal(t, tt.expectedPostStepResult, sar.RunContext.StepResults["post-step"])
cm.AssertExpectations(t)
})
}
} }

View file

@ -26,7 +26,7 @@ func (sd *stepDocker) pre() common.Executor {
func (sd *stepDocker) main() common.Executor { func (sd *stepDocker) main() common.Executor {
sd.env = map[string]string{} sd.env = map[string]string{}
return runStepExecutor(sd, sd.runUsesContainer()) return runStepExecutor(sd, stepStageMain, sd.runUsesContainer())
} }
func (sd *stepDocker) post() common.Executor { func (sd *stepDocker) post() common.Executor {
@ -47,6 +47,10 @@ func (sd *stepDocker) getEnv() *map[string]string {
return &sd.env return &sd.env
} }
func (sd *stepDocker) getIfExpression(stage stepStage) string {
return sd.Step.If.Value
}
func (sd *stepDocker) runUsesContainer() common.Executor { func (sd *stepDocker) runUsesContainer() common.Executor {
rc := sd.RunContext rc := sd.RunContext
step := sd.Step step := sd.Step

View file

@ -27,7 +27,7 @@ func (sr *stepRun) pre() common.Executor {
func (sr *stepRun) main() common.Executor { func (sr *stepRun) main() common.Executor {
sr.env = map[string]string{} sr.env = map[string]string{}
return runStepExecutor(sr, common.NewPipelineExecutor( return runStepExecutor(sr, stepStageMain, common.NewPipelineExecutor(
sr.setupShellCommandExecutor(), sr.setupShellCommandExecutor(),
func(ctx context.Context) error { func(ctx context.Context) error {
return sr.getRunContext().JobContainer.Exec(sr.cmd, sr.env, "", sr.Step.WorkingDirectory)(ctx) return sr.getRunContext().JobContainer.Exec(sr.cmd, sr.env, "", sr.Step.WorkingDirectory)(ctx)
@ -53,6 +53,10 @@ func (sr *stepRun) getEnv() *map[string]string {
return &sr.env return &sr.env
} }
func (sr *stepRun) getIfExpression(stage stepStage) string {
return sr.Step.If.Value
}
func (sr *stepRun) setupShellCommandExecutor() common.Executor { func (sr *stepRun) setupShellCommandExecutor() common.Executor {
return func(ctx context.Context) error { return func(ctx context.Context) error {
scriptName, script, err := sr.setupShellCommand(ctx) scriptName, script, err := sr.setupShellCommand(ctx)

View file

@ -230,49 +230,49 @@ func TestIsStepEnabled(t *testing.T) {
// success() // success()
step := createTestStep(t, "if: success()") step := createTestStep(t, "if: success()")
assertObject.True(isStepEnabled(context.Background(), step)) assertObject.True(isStepEnabled(context.Background(), step.getIfExpression(stepStageMain), step, stepStageMain))
step = createTestStep(t, "if: success()") step = createTestStep(t, "if: success()")
step.getRunContext().StepResults["a"] = &model.StepResult{ step.getRunContext().StepResults["a"] = &model.StepResult{
Conclusion: model.StepStatusSuccess, Conclusion: model.StepStatusSuccess,
} }
assertObject.True(isStepEnabled(context.Background(), step)) assertObject.True(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
step = createTestStep(t, "if: success()") step = createTestStep(t, "if: success()")
step.getRunContext().StepResults["a"] = &model.StepResult{ step.getRunContext().StepResults["a"] = &model.StepResult{
Conclusion: model.StepStatusFailure, Conclusion: model.StepStatusFailure,
} }
assertObject.False(isStepEnabled(context.Background(), step)) assertObject.False(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
// failure() // failure()
step = createTestStep(t, "if: failure()") step = createTestStep(t, "if: failure()")
assertObject.False(isStepEnabled(context.Background(), step)) assertObject.False(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
step = createTestStep(t, "if: failure()") step = createTestStep(t, "if: failure()")
step.getRunContext().StepResults["a"] = &model.StepResult{ step.getRunContext().StepResults["a"] = &model.StepResult{
Conclusion: model.StepStatusSuccess, Conclusion: model.StepStatusSuccess,
} }
assertObject.False(isStepEnabled(context.Background(), step)) assertObject.False(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
step = createTestStep(t, "if: failure()") step = createTestStep(t, "if: failure()")
step.getRunContext().StepResults["a"] = &model.StepResult{ step.getRunContext().StepResults["a"] = &model.StepResult{
Conclusion: model.StepStatusFailure, Conclusion: model.StepStatusFailure,
} }
assertObject.True(isStepEnabled(context.Background(), step)) assertObject.True(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
// always() // always()
step = createTestStep(t, "if: always()") step = createTestStep(t, "if: always()")
assertObject.True(isStepEnabled(context.Background(), step)) assertObject.True(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
step = createTestStep(t, "if: always()") step = createTestStep(t, "if: always()")
step.getRunContext().StepResults["a"] = &model.StepResult{ step.getRunContext().StepResults["a"] = &model.StepResult{
Conclusion: model.StepStatusSuccess, Conclusion: model.StepStatusSuccess,
} }
assertObject.True(isStepEnabled(context.Background(), step)) assertObject.True(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
step = createTestStep(t, "if: always()") step = createTestStep(t, "if: always()")
step.getRunContext().StepResults["a"] = &model.StepResult{ step.getRunContext().StepResults["a"] = &model.StepResult{
Conclusion: model.StepStatusFailure, Conclusion: model.StepStatusFailure,
} }
assertObject.True(isStepEnabled(context.Background(), step)) assertObject.True(isStepEnabled(context.Background(), step.getStepModel().If.Value, step, stepStageMain))
} }

View file

@ -0,0 +1,7 @@
name: "last action check"
description: "last action check"
runs:
using: "node16"
main: main.js
post: post.js

View file

@ -0,0 +1,17 @@
const pre = process.env['ACTION_OUTPUT_PRE'];
const main = process.env['ACTION_OUTPUT_MAIN'];
const post = process.env['ACTION_OUTPUT_POST'];
console.log({pre, main, post});
if (pre !== 'pre') {
throw new Error(`Expected 'pre' but got '${pre}'`);
}
if (main !== 'main') {
throw new Error(`Expected 'main' but got '${main}'`);
}
if (post !== 'post') {
throw new Error(`Expected 'post' but got '${post}'`);
}

View file

@ -0,0 +1,15 @@
name: uses-action-with-pre-and-post-step
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: ./uses-action-with-pre-and-post-step/last-action
- uses: nektos/act-test-actions/js-with-pre-and-post-step@main
with:
pre: true
post: true
- run: |
cat $GITHUB_ENV

View file

@ -0,0 +1,13 @@
name: "Action with pre and post"
description: "Action with pre and post"
inputs:
step:
description: "step"
required: true
runs:
using: "node16"
pre: pre.js
main: main.js
post: post.js

View file

@ -0,0 +1,3 @@
const { appendFileSync } = require('fs');
const step = process.env['INPUT_STEP'];
appendFileSync(process.env['GITHUB_ENV'], `;${step}`, { encoding:'utf-8' })

View file

@ -0,0 +1,3 @@
const { appendFileSync } = require('fs');
const step = process.env['INPUT_STEP'];
appendFileSync(process.env['GITHUB_ENV'], `;${step}-post`, { encoding:'utf-8' })

View file

@ -0,0 +1 @@
console.log('pre');

View file

@ -0,0 +1,12 @@
name: "Test Composite Action"
description: "Test action uses composite"
runs:
using: "composite"
steps:
- uses: ./uses-composite-with-pre-and-post-steps/action-with-pre-and-post
with:
step: step1
- uses: ./uses-composite-with-pre-and-post-steps/action-with-pre-and-post
with:
step: step2

View file

@ -0,0 +1,7 @@
name: "last action check"
description: "last action check"
runs:
using: "node16"
main: main.js
post: post.js

View file

@ -0,0 +1,7 @@
const output = process.env['STEP_OUTPUT_TEST'];
const expected = 'empty;step1;step2;step2-post;step1-post';
console.log(output);
if (output !== expected) {
throw new Error(`Expected '${expected}' but got '${output}'`);
}

View file

@ -0,0 +1,11 @@
name: uses-composite-with-pre-and-post-steps
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: ./uses-composite-with-pre-and-post-steps/last-action
- uses: actions/checkout@v2
- run: echo -n "STEP_OUTPUT_TEST=empty" >> $GITHUB_ENV
- uses: ./uses-composite-with-pre-and-post-steps/composite_action