diff --git a/act/model/action.go b/act/model/action.go index 8bfcd080..7e00e0fd 100644 --- a/act/model/action.go +++ b/act/model/action.go @@ -76,17 +76,19 @@ func (a ActionRunsUsing) IsComposite() bool { // ActionRuns are a field in Action type ActionRuns struct { - Using ActionRunsUsing `yaml:"using"` - Env map[string]string `yaml:"env"` - 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"` - Entrypoint string `yaml:"entrypoint"` - Args []string `yaml:"args"` - Steps []Step `yaml:"steps"` + Using ActionRunsUsing `yaml:"using"` + Env map[string]string `yaml:"env"` + 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"` + PreEntrypoint string `yaml:"pre-entrypoint"` + Entrypoint string `yaml:"entrypoint"` + PostEntrypoint string `yaml:"post-entrypoint"` + Args []string `yaml:"args"` + Steps []Step `yaml:"steps"` } // Action describes a metadata file for GitHub actions. The metadata filename must be either action.yml or action.yaml. The data in the metadata file defines the inputs, outputs and main entrypoint for your action. diff --git a/act/model/action_test.go b/act/model/action_test.go index 31ecd7a2..96caa768 100644 --- a/act/model/action_test.go +++ b/act/model/action_test.go @@ -61,3 +61,22 @@ runs: t.Fatalf("error = %q, want invalid value", err) } } + +func TestReadActionDockerEntrypoints(t *testing.T) { + action, err := ReadAction(strings.NewReader(` +runs: + using: docker + image: Dockerfile + pre-entrypoint: pre.sh + post-entrypoint: post.sh +`)) + if err != nil { + t.Fatal(err) + } + if action.Runs.PreEntrypoint != "pre.sh" { + t.Fatalf("pre-entrypoint = %q, want pre.sh", action.Runs.PreEntrypoint) + } + if action.Runs.PostEntrypoint != "post.sh" { + t.Fatalf("post-entrypoint = %q, want post.sh", action.Runs.PostEntrypoint) + } +} diff --git a/act/runner/action.go b/act/runner/action.go index 4e736a09..700b1f29 100644 --- a/act/runner/action.go +++ b/act/runner/action.go @@ -207,7 +207,7 @@ func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction if remoteAction == nil { location = containerActionDir } - return execAsDocker(ctx, step, actionName, actionDir, location, remoteAction == nil) + return execAsDocker(ctx, step, actionName, actionDir, location, remoteAction == nil, stepStageMain) case x.IsComposite(): if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil { return err @@ -305,7 +305,7 @@ func dockerActionImageTag(repository, actionName string, localAction bool) strin } // TODO: break out parts of function to reduce complexicity -func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool) error { +func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool, stage stepStage) error { logger := common.Logger(ctx) rc := step.getRunContext() action := step.getActionModel() @@ -386,16 +386,9 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b cmd = action.Runs.Args evalDockerArgs(ctx, step, action, &cmd) } - entrypoint := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])) - if len(entrypoint) == 0 { - if action.Runs.Entrypoint != "" { - entrypoint, err = shellquote.Split(action.Runs.Entrypoint) - if err != nil { - return err - } - } else { - entrypoint = nil - } + entrypoint, err := dockerEntrypoint(ctx, step, eval, stage) + if err != nil { + return err } stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint) return common.NewPipelineExecutor( @@ -409,6 +402,30 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b ).Finally(stepContainer.Close())(ctx) } +// dockerEntrypoint returns the entrypoint the action's image runs with for the given +// stage. Only the main stage honours the `entrypoint` input. +func dockerEntrypoint(ctx context.Context, step actionStep, eval ExpressionEvaluator, stage stepStage) ([]string, error) { + runs := step.getActionModel().Runs + + var entrypoint string + switch stage { + case stepStagePre: + entrypoint = runs.PreEntrypoint + case stepStagePost: + entrypoint = runs.PostEntrypoint + default: + if fields := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])); len(fields) > 0 { + return fields, nil + } + entrypoint = runs.Entrypoint + } + + if entrypoint == "" { + return nil, nil + } + return shellquote.Split(entrypoint) +} + func evalDockerArgs(ctx context.Context, step step, action *model.Action, cmd *[]string) { rc := step.getRunContext() stepModel := step.getStepModel() @@ -559,44 +576,57 @@ func hasPreStep(step actionStep) common.Conditional { return action.Runs.Using.IsComposite() || (action.Runs.Using.IsNode() && action.Runs.Pre != "") || + (action.Runs.Using.IsDocker() && + action.Runs.PreEntrypoint != "") || (action.Runs.Using == model.ActionRunsUsingGo && action.Runs.Pre != "") } } +// actionStagePaths resolves where a step's action lives and where the job container sees +// it, for the pre and post stage. +func actionStagePaths(step actionStep) (actionDir, actionPath, actionName, containerActionDir string) { + rc := step.getRunContext() + stepModel := step.getStepModel() + + if _, ok := step.(*stepActionRemote); ok { + actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), stepModel.UsesHash()) + actionPath = newRemoteAction(stepModel.Uses).Path + } else { + actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses) + } + + actionName, containerActionDir = getContainerActionPaths(stepModel, path.Join(actionDir, actionPath), rc) + return actionDir, actionPath, actionName, containerActionDir +} + +// execDockerActionStage runs a docker action's image for its pre or post stage. +func execDockerActionStage(ctx context.Context, step actionStep, stage stepStage) error { + actionDir, actionPath, actionName, containerActionDir := actionStagePaths(step) + + _, remote := step.(*stepActionRemote) + location := containerActionDir + if remote { + location = path.Join(actionDir, actionPath) + } + return execAsDocker(ctx, step, actionName, actionDir, location, !remote, stage) +} + func runPreStep(step actionStep) common.Executor { return func(ctx context.Context) error { logger := common.Logger(ctx) logger.Debugf("run pre step for '%s'", step.getStepModel()) rc := step.getRunContext() - stepModel := step.getStepModel() action := step.getActionModel() + actionDir, actionPath, _, containerActionDir := actionStagePaths(step) + x := action.Runs.Using switch { case x.IsNode(): // defaults in pre steps were missing, however provided inputs are available populateEnvsFromInput(ctx, step.getEnv(), action, rc) - // 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(), stepModel.UsesHash()) - } else { - actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses) - actionPath = "" - } - - var actionLocation string - 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 @@ -609,6 +639,12 @@ func runPreStep(step actionStep) common.Executor { return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx) + case x.IsDocker(): + // defaults in pre steps were missing, however provided inputs are available + populateEnvsFromInput(ctx, step.getEnv(), action, rc) + + return execDockerActionStage(ctx, step, stepStagePre) + case x.IsComposite(): if step.getCompositeSteps() == nil { step.getCompositeRunContext(ctx) @@ -622,25 +658,6 @@ func runPreStep(step actionStep) common.Executor { case x == model.ActionRunsUsingGo: // defaults in pre steps were missing, however provided inputs are available populateEnvsFromInput(ctx, step.getEnv(), action, rc) - // 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(), stepModel.UsesHash()) - } else { - actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses) - actionPath = "" - } - - var actionLocation string - 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 @@ -693,6 +710,8 @@ func hasPostStep(step actionStep) common.Conditional { return action.Runs.Using.IsComposite() || (action.Runs.Using.IsNode() && action.Runs.Post != "") || + (action.Runs.Using.IsDocker() && + action.Runs.PostEntrypoint != "") || (action.Runs.Using == model.ActionRunsUsingGo && action.Runs.Post != "") } @@ -704,28 +723,9 @@ func runPostStep(step actionStep) common.Executor { logger.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(), stepModel.UsesHash()) - } else { - actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses) - actionPath = "" - } - - var actionLocation string - if actionPath != "" { - actionLocation = path.Join(actionDir, actionPath) - } else { - actionLocation = actionDir - } - - _, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc) + actionDir, actionPath, _, containerActionDir := actionStagePaths(step) x := action.Runs.Using switch { @@ -740,6 +740,11 @@ func runPostStep(step actionStep) common.Executor { return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx) + case x.IsDocker(): + populateEnvsFromSavedState(step.getEnv(), step, rc) + + return execDockerActionStage(ctx, step, stepStagePost) + case x.IsComposite(): if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil { return err diff --git a/act/runner/action_test.go b/act/runner/action_test.go index a3b686fe..eb068881 100644 --- a/act/runner/action_test.go +++ b/act/runner/action_test.go @@ -183,7 +183,7 @@ func TestExecAsDockerAutoRemove(t *testing.T) { cm.On("Start", true).Return(func(context.Context) error { return nil }) cm.On("Close").Return(func(context.Context) error { return nil }) - require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false)) + require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, stepStageMain)) cm.AssertExpectations(t) assert.Equal(t, tc.removes, removes) } @@ -465,7 +465,7 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) { defer cancel() done := make(chan error, 1) - go func() { done <- execAsDocker(ctx, step, "test-action", actionDir, actionDir, false) }() + go func() { done <- execAsDocker(ctx, step, "test-action", actionDir, actionDir, false, stepStageMain) }() select { case <-innerEntered: @@ -541,3 +541,86 @@ func TestDockerActionImageTag(t *testing.T) { dockerActionImageTag("owner/repo", "./sub", true), ) } + +// Only the entrypoint is stage specific: every stage of a docker action receives runs.args +// and runs.env, and the `entrypoint` input applies to the main stage alone. +func TestExecAsDockerStageEntrypoint(t *testing.T) { + orig := ContainerNewContainer + defer func() { ContainerNewContainer = orig }() + + for _, tc := range []struct { + name string + stage stepStage + wantEntrypoint []string + }{ + { + name: "main stage prefers the entrypoint input", + stage: stepStageMain, + wantEntrypoint: []string{"input.sh"}, + }, + { + name: "pre stage uses runs.pre-entrypoint", + stage: stepStagePre, + wantEntrypoint: []string{"pre.sh", "--verbose"}, + }, + { + name: "post stage uses runs.post-entrypoint", + stage: stepStagePost, + wantEntrypoint: []string{"post.sh"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + cm := &containerMock{} + var input *container.NewContainerInput + ContainerNewContainer = func(in *container.NewContainerInput) container.ExecutionsEnvironment { + input = in + return cm + } + + step := &stepActionRemote{ + Step: &model.Step{ID: "1", Uses: "org/action@v1", With: map[string]string{"entrypoint": "input.sh"}}, + RunContext: &RunContext{ + Config: &Config{}, + Run: &model.Run{JobID: "1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"1": {}}}}, + JobContainer: cm, + }, + action: &model.Action{Runs: model.ActionRuns{ + Using: "docker", + Image: "docker://node:14", + PreEntrypoint: "pre.sh --verbose", + Entrypoint: "main.sh", + PostEntrypoint: "post.sh", + Args: []string{"hello"}, + Env: map[string]string{"MY_VAR": "world"}, + }}, + env: map[string]string{}, + } + + cm.On("Pull", false).Return(func(context.Context) error { return nil }) + cm.On("Remove").Return(func(context.Context) error { return nil }) + cm.On("Create", []string(nil), []string(nil)).Return(func(context.Context) error { return nil }) + cm.On("Start", true).Return(func(context.Context) error { return nil }) + cm.On("Close").Return(func(context.Context) error { return nil }) + + require.NoError(t, execAsDocker(context.Background(), step, "action", t.TempDir(), t.TempDir(), false, tc.stage)) + require.NotNil(t, input) + assert.Equal(t, tc.wantEntrypoint, input.Entrypoint) + assert.Equal(t, []string{"hello"}, input.Cmd) + assert.Contains(t, input.Env, "MY_VAR=world") + }) + } +} + +func TestDockerActionHasPreAndPostStep(t *testing.T) { + newStep := func(runs model.ActionRuns) actionStep { + return &stepActionRemote{action: &model.Action{Runs: runs}} + } + ctx := context.Background() + + assert.False(t, hasPreStep(newStep(model.ActionRuns{Using: "docker", Image: "Dockerfile"}))(ctx)) + assert.False(t, hasPostStep(newStep(model.ActionRuns{Using: "docker", Image: "Dockerfile"}))(ctx)) + + withStages := model.ActionRuns{Using: "docker", Image: "Dockerfile", PreEntrypoint: "pre.sh", PostEntrypoint: "post.sh"} + assert.True(t, hasPreStep(newStep(withStages))(ctx)) + assert.True(t, hasPostStep(newStep(withStages))(ctx)) +}