github.com/nektos/act@v0.2.63/pkg/runner/action.go (about) 1 package runner 2 3 import ( 4 "context" 5 "embed" 6 "errors" 7 "fmt" 8 "io" 9 "io/fs" 10 "os" 11 "path" 12 "path/filepath" 13 "regexp" 14 "runtime" 15 "strings" 16 17 "github.com/kballard/go-shellquote" 18 19 "github.com/nektos/act/pkg/common" 20 "github.com/nektos/act/pkg/container" 21 "github.com/nektos/act/pkg/model" 22 ) 23 24 type actionStep interface { 25 step 26 27 getActionModel() *model.Action 28 getCompositeRunContext(context.Context) *RunContext 29 getCompositeSteps() *compositeSteps 30 } 31 32 type readAction func(ctx context.Context, step *model.Step, actionDir string, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) 33 34 type actionYamlReader func(filename string) (io.Reader, io.Closer, error) 35 36 type fileWriter func(filename string, data []byte, perm fs.FileMode) error 37 38 type runAction func(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor 39 40 //go:embed res/trampoline.js 41 var trampoline embed.FS 42 43 func readActionImpl(ctx context.Context, step *model.Step, actionDir string, actionPath string, readFile actionYamlReader, writeFile fileWriter) (*model.Action, error) { 44 logger := common.Logger(ctx) 45 allErrors := []error{} 46 addError := func(fileName string, err error) { 47 if err != nil { 48 allErrors = append(allErrors, fmt.Errorf("failed to read '%s' from action '%s' with path '%s' of step %w", fileName, step.String(), actionPath, err)) 49 } else { 50 // One successful read, clear error state 51 allErrors = nil 52 } 53 } 54 reader, closer, err := readFile("action.yml") 55 addError("action.yml", err) 56 if os.IsNotExist(err) { 57 reader, closer, err = readFile("action.yaml") 58 addError("action.yaml", err) 59 if os.IsNotExist(err) { 60 _, closer, err := readFile("Dockerfile") 61 addError("Dockerfile", err) 62 if err == nil { 63 closer.Close() 64 action := &model.Action{ 65 Name: "(Synthetic)", 66 Runs: model.ActionRuns{ 67 Using: "docker", 68 Image: "Dockerfile", 69 }, 70 } 71 logger.Debugf("Using synthetic action %v for Dockerfile", action) 72 return action, nil 73 } 74 if step.With != nil { 75 if val, ok := step.With["args"]; ok { 76 var b []byte 77 if b, err = trampoline.ReadFile("res/trampoline.js"); err != nil { 78 return nil, err 79 } 80 err2 := writeFile(filepath.Join(actionDir, actionPath, "trampoline.js"), b, 0o400) 81 if err2 != nil { 82 return nil, err2 83 } 84 action := &model.Action{ 85 Name: "(Synthetic)", 86 Inputs: map[string]model.Input{ 87 "cwd": { 88 Description: "(Actual working directory)", 89 Required: false, 90 Default: filepath.Join(actionDir, actionPath), 91 }, 92 "command": { 93 Description: "(Actual program)", 94 Required: false, 95 Default: val, 96 }, 97 }, 98 Runs: model.ActionRuns{ 99 Using: "node12", 100 Main: "trampoline.js", 101 }, 102 } 103 logger.Debugf("Using synthetic action %v", action) 104 return action, nil 105 } 106 } 107 } 108 } 109 if allErrors != nil { 110 return nil, errors.Join(allErrors...) 111 } 112 defer closer.Close() 113 114 action, err := model.ReadAction(reader) 115 logger.Debugf("Read action %v from '%s'", action, "Unknown") 116 return action, err 117 } 118 119 func maybeCopyToActionDir(ctx context.Context, step actionStep, actionDir string, actionPath string, containerActionDir string) error { 120 logger := common.Logger(ctx) 121 rc := step.getRunContext() 122 stepModel := step.getStepModel() 123 124 if stepModel.Type() != model.StepTypeUsesActionRemote { 125 return nil 126 } 127 128 var containerActionDirCopy string 129 containerActionDirCopy = strings.TrimSuffix(containerActionDir, actionPath) 130 logger.Debug(containerActionDirCopy) 131 132 if !strings.HasSuffix(containerActionDirCopy, `/`) { 133 containerActionDirCopy += `/` 134 } 135 136 if rc.Config != nil && rc.Config.ActionCache != nil { 137 raction := step.(*stepActionRemote) 138 ta, err := rc.Config.ActionCache.GetTarArchive(ctx, raction.cacheDir, raction.resolvedSha, "") 139 if err != nil { 140 return err 141 } 142 defer ta.Close() 143 return rc.JobContainer.CopyTarStream(ctx, containerActionDirCopy, ta) 144 } 145 146 if err := removeGitIgnore(ctx, actionDir); err != nil { 147 return err 148 } 149 150 return rc.JobContainer.CopyDir(containerActionDirCopy, actionDir+"/", rc.Config.UseGitIgnore)(ctx) 151 } 152 153 func runActionImpl(step actionStep, actionDir string, remoteAction *remoteAction) common.Executor { 154 rc := step.getRunContext() 155 stepModel := step.getStepModel() 156 157 return func(ctx context.Context) error { 158 logger := common.Logger(ctx) 159 actionPath := "" 160 if remoteAction != nil && remoteAction.Path != "" { 161 actionPath = remoteAction.Path 162 } 163 164 action := step.getActionModel() 165 logger.Debugf("About to run action %v", action) 166 167 err := setupActionEnv(ctx, step, remoteAction) 168 if err != nil { 169 return err 170 } 171 172 actionLocation := path.Join(actionDir, actionPath) 173 actionName, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc) 174 175 logger.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) 176 177 switch action.Runs.Using { 178 case model.ActionRunsUsingNode12, model.ActionRunsUsingNode16, model.ActionRunsUsingNode20: 179 if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil { 180 return err 181 } 182 containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Main)} 183 logger.Debugf("executing remote job container: %s", containerArgs) 184 185 rc.ApplyExtraPath(ctx, step.getEnv()) 186 187 return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx) 188 case model.ActionRunsUsingDocker: 189 location := actionLocation 190 if remoteAction == nil { 191 location = containerActionDir 192 } 193 return execAsDocker(ctx, step, actionName, location, remoteAction == nil) 194 case model.ActionRunsUsingComposite: 195 if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil { 196 return err 197 } 198 199 return execAsComposite(step)(ctx) 200 default: 201 return fmt.Errorf(fmt.Sprintf("The runs.using key must be one of: %v, got %s", []string{ 202 model.ActionRunsUsingDocker, 203 model.ActionRunsUsingNode12, 204 model.ActionRunsUsingNode16, 205 model.ActionRunsUsingNode20, 206 model.ActionRunsUsingComposite, 207 }, action.Runs.Using)) 208 } 209 } 210 } 211 212 func setupActionEnv(ctx context.Context, step actionStep, _ *remoteAction) error { 213 rc := step.getRunContext() 214 215 // A few fields in the environment (e.g. GITHUB_ACTION_REPOSITORY) 216 // are dependent on the action. That means we can complete the 217 // setup only after resolving the whole action model and cloning 218 // the action 219 rc.withGithubEnv(ctx, step.getGithubContext(ctx), *step.getEnv()) 220 populateEnvsFromSavedState(step.getEnv(), step, rc) 221 populateEnvsFromInput(ctx, step.getEnv(), step.getActionModel(), rc) 222 223 return nil 224 } 225 226 // https://github.com/nektos/act/issues/228#issuecomment-629709055 227 // files in .gitignore are not copied in a Docker container 228 // this causes issues with actions that ignore other important resources 229 // such as `node_modules` for example 230 func removeGitIgnore(ctx context.Context, directory string) error { 231 gitIgnorePath := path.Join(directory, ".gitignore") 232 if _, err := os.Stat(gitIgnorePath); err == nil { 233 // .gitignore exists 234 common.Logger(ctx).Debugf("Removing %s before docker cp", gitIgnorePath) 235 err := os.Remove(gitIgnorePath) 236 if err != nil { 237 return err 238 } 239 } 240 return nil 241 } 242 243 // TODO: break out parts of function to reduce complexicity 244 // 245 //nolint:gocyclo 246 func execAsDocker(ctx context.Context, step actionStep, actionName string, basedir string, localAction bool) error { 247 logger := common.Logger(ctx) 248 rc := step.getRunContext() 249 action := step.getActionModel() 250 251 var prepImage common.Executor 252 var image string 253 forcePull := false 254 if strings.HasPrefix(action.Runs.Image, "docker://") { 255 image = strings.TrimPrefix(action.Runs.Image, "docker://") 256 // Apply forcePull only for prebuild docker images 257 forcePull = rc.Config.ForcePull 258 } else { 259 // "-dockeraction" enshures that "./", "./test " won't get converted to "act-:latest", "act-test-:latest" which are invalid docker image names 260 image = fmt.Sprintf("%s-dockeraction:%s", regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(actionName, "-"), "latest") 261 image = fmt.Sprintf("act-%s", strings.TrimLeft(image, "-")) 262 image = strings.ToLower(image) 263 contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image)) 264 265 anyArchExists, err := container.ImageExistsLocally(ctx, image, "any") 266 if err != nil { 267 return err 268 } 269 270 correctArchExists, err := container.ImageExistsLocally(ctx, image, rc.Config.ContainerArchitecture) 271 if err != nil { 272 return err 273 } 274 275 if anyArchExists && !correctArchExists { 276 wasRemoved, err := container.RemoveImage(ctx, image, true, true) 277 if err != nil { 278 return err 279 } 280 if !wasRemoved { 281 return fmt.Errorf("failed to remove image '%s'", image) 282 } 283 } 284 285 if !correctArchExists || rc.Config.ForceRebuild { 286 logger.Debugf("image '%s' for architecture '%s' will be built from context '%s", image, rc.Config.ContainerArchitecture, contextDir) 287 var buildContext io.ReadCloser 288 if localAction { 289 buildContext, err = rc.JobContainer.GetContainerArchive(ctx, contextDir+"/.") 290 if err != nil { 291 return err 292 } 293 defer buildContext.Close() 294 } else if rc.Config.ActionCache != nil { 295 rstep := step.(*stepActionRemote) 296 buildContext, err = rc.Config.ActionCache.GetTarArchive(ctx, rstep.cacheDir, rstep.resolvedSha, contextDir) 297 if err != nil { 298 return err 299 } 300 defer buildContext.Close() 301 } 302 prepImage = container.NewDockerBuildExecutor(container.NewDockerBuildExecutorInput{ 303 ContextDir: contextDir, 304 Dockerfile: fileName, 305 ImageTag: image, 306 BuildContext: buildContext, 307 Platform: rc.Config.ContainerArchitecture, 308 }) 309 } else { 310 logger.Debugf("image '%s' for architecture '%s' already exists", image, rc.Config.ContainerArchitecture) 311 } 312 } 313 eval := rc.NewStepExpressionEvaluator(ctx, step) 314 cmd, err := shellquote.Split(eval.Interpolate(ctx, step.getStepModel().With["args"])) 315 if err != nil { 316 return err 317 } 318 if len(cmd) == 0 { 319 cmd = action.Runs.Args 320 evalDockerArgs(ctx, step, action, &cmd) 321 } 322 entrypoint := strings.Fields(eval.Interpolate(ctx, step.getStepModel().With["entrypoint"])) 323 if len(entrypoint) == 0 { 324 if action.Runs.Entrypoint != "" { 325 entrypoint, err = shellquote.Split(action.Runs.Entrypoint) 326 if err != nil { 327 return err 328 } 329 } else { 330 entrypoint = nil 331 } 332 } 333 stepContainer := newStepContainer(ctx, step, image, cmd, entrypoint) 334 return common.NewPipelineExecutor( 335 prepImage, 336 stepContainer.Pull(forcePull), 337 stepContainer.Remove().IfBool(!rc.Config.ReuseContainers), 338 stepContainer.Create(rc.Config.ContainerCapAdd, rc.Config.ContainerCapDrop), 339 stepContainer.Start(true), 340 ).Finally( 341 stepContainer.Remove().IfBool(!rc.Config.ReuseContainers), 342 ).Finally(stepContainer.Close())(ctx) 343 } 344 345 func evalDockerArgs(ctx context.Context, step step, action *model.Action, cmd *[]string) { 346 rc := step.getRunContext() 347 stepModel := step.getStepModel() 348 349 inputs := make(map[string]string) 350 eval := rc.NewExpressionEvaluator(ctx) 351 // Set Defaults 352 for k, input := range action.Inputs { 353 inputs[k] = eval.Interpolate(ctx, input.Default) 354 } 355 if stepModel.With != nil { 356 for k, v := range stepModel.With { 357 inputs[k] = eval.Interpolate(ctx, v) 358 } 359 } 360 mergeIntoMap(step, step.getEnv(), inputs) 361 362 stepEE := rc.NewStepExpressionEvaluator(ctx, step) 363 for i, v := range *cmd { 364 (*cmd)[i] = stepEE.Interpolate(ctx, v) 365 } 366 mergeIntoMap(step, step.getEnv(), action.Runs.Env) 367 368 ee := rc.NewStepExpressionEvaluator(ctx, step) 369 for k, v := range *step.getEnv() { 370 (*step.getEnv())[k] = ee.Interpolate(ctx, v) 371 } 372 } 373 374 func newStepContainer(ctx context.Context, step step, image string, cmd []string, entrypoint []string) container.Container { 375 rc := step.getRunContext() 376 stepModel := step.getStepModel() 377 rawLogger := common.Logger(ctx).WithField("raw_output", true) 378 logWriter := common.NewLineWriter(rc.commandHandler(ctx), func(s string) bool { 379 if rc.Config.LogOutput { 380 rawLogger.Infof("%s", s) 381 } else { 382 rawLogger.Debugf("%s", s) 383 } 384 return true 385 }) 386 envList := make([]string, 0) 387 for k, v := range *step.getEnv() { 388 envList = append(envList, fmt.Sprintf("%s=%s", k, v)) 389 } 390 391 envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TOOL_CACHE", "/opt/hostedtoolcache")) 392 envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_OS", "Linux")) 393 envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_ARCH", container.RunnerArch(ctx))) 394 envList = append(envList, fmt.Sprintf("%s=%s", "RUNNER_TEMP", "/tmp")) 395 396 binds, mounts := rc.GetBindsAndMounts() 397 networkMode := fmt.Sprintf("container:%s", rc.jobContainerName()) 398 if rc.IsHostEnv(ctx) { 399 networkMode = "default" 400 } 401 stepContainer := container.NewContainer(&container.NewContainerInput{ 402 Cmd: cmd, 403 Entrypoint: entrypoint, 404 WorkingDir: rc.JobContainer.ToContainerPath(rc.Config.Workdir), 405 Image: image, 406 Username: rc.Config.Secrets["DOCKER_USERNAME"], 407 Password: rc.Config.Secrets["DOCKER_PASSWORD"], 408 Name: createContainerName(rc.jobContainerName(), stepModel.ID), 409 Env: envList, 410 Mounts: mounts, 411 NetworkMode: networkMode, 412 Binds: binds, 413 Stdout: logWriter, 414 Stderr: logWriter, 415 Privileged: rc.Config.Privileged, 416 UsernsMode: rc.Config.UsernsMode, 417 Platform: rc.Config.ContainerArchitecture, 418 Options: rc.Config.ContainerOptions, 419 }) 420 return stepContainer 421 } 422 423 func populateEnvsFromSavedState(env *map[string]string, step actionStep, rc *RunContext) { 424 state, ok := rc.IntraActionState[step.getStepModel().ID] 425 if ok { 426 for name, value := range state { 427 envName := fmt.Sprintf("STATE_%s", name) 428 (*env)[envName] = value 429 } 430 } 431 } 432 433 func populateEnvsFromInput(ctx context.Context, env *map[string]string, action *model.Action, rc *RunContext) { 434 eval := rc.NewExpressionEvaluator(ctx) 435 for inputID, input := range action.Inputs { 436 envKey := regexp.MustCompile("[^A-Z0-9-]").ReplaceAllString(strings.ToUpper(inputID), "_") 437 envKey = fmt.Sprintf("INPUT_%s", envKey) 438 if _, ok := (*env)[envKey]; !ok { 439 (*env)[envKey] = eval.Interpolate(ctx, input.Default) 440 } 441 } 442 } 443 444 func getContainerActionPaths(step *model.Step, actionDir string, rc *RunContext) (string, string) { 445 actionName := "" 446 containerActionDir := "." 447 if step.Type() != model.StepTypeUsesActionRemote { 448 actionName = getOsSafeRelativePath(actionDir, rc.Config.Workdir) 449 containerActionDir = rc.JobContainer.ToContainerPath(rc.Config.Workdir) + "/" + actionName 450 actionName = "./" + actionName 451 } else if step.Type() == model.StepTypeUsesActionRemote { 452 actionName = getOsSafeRelativePath(actionDir, rc.ActionCacheDir()) 453 containerActionDir = rc.JobContainer.GetActPath() + "/actions/" + actionName 454 } 455 456 if actionName == "" { 457 actionName = filepath.Base(actionDir) 458 if runtime.GOOS == "windows" { 459 actionName = strings.ReplaceAll(actionName, "\\", "/") 460 } 461 } 462 return actionName, containerActionDir 463 } 464 465 func getOsSafeRelativePath(s, prefix string) string { 466 actionName := strings.TrimPrefix(s, prefix) 467 if runtime.GOOS == "windows" { 468 actionName = strings.ReplaceAll(actionName, "\\", "/") 469 } 470 actionName = strings.TrimPrefix(actionName, "/") 471 472 return actionName 473 } 474 475 func shouldRunPreStep(step actionStep) common.Conditional { 476 return func(ctx context.Context) bool { 477 log := common.Logger(ctx) 478 479 if step.getActionModel() == nil { 480 log.Debugf("skip pre step for '%s': no action model available", step.getStepModel()) 481 return false 482 } 483 484 return true 485 } 486 } 487 488 func hasPreStep(step actionStep) common.Conditional { 489 return func(ctx context.Context) bool { 490 action := step.getActionModel() 491 return action.Runs.Using == model.ActionRunsUsingComposite || 492 ((action.Runs.Using == model.ActionRunsUsingNode12 || 493 action.Runs.Using == model.ActionRunsUsingNode16 || 494 action.Runs.Using == model.ActionRunsUsingNode20) && 495 action.Runs.Pre != "") 496 } 497 } 498 499 func runPreStep(step actionStep) common.Executor { 500 return func(ctx context.Context) error { 501 logger := common.Logger(ctx) 502 logger.Debugf("run pre step for '%s'", step.getStepModel()) 503 504 rc := step.getRunContext() 505 stepModel := step.getStepModel() 506 action := step.getActionModel() 507 508 switch action.Runs.Using { 509 case model.ActionRunsUsingNode12, model.ActionRunsUsingNode16, model.ActionRunsUsingNode20: 510 // defaults in pre steps were missing, however provided inputs are available 511 populateEnvsFromInput(ctx, step.getEnv(), action, rc) 512 // todo: refactor into step 513 var actionDir string 514 var actionPath string 515 if _, ok := step.(*stepActionRemote); ok { 516 actionPath = newRemoteAction(stepModel.Uses).Path 517 actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(stepModel.Uses)) 518 } else { 519 actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses) 520 actionPath = "" 521 } 522 523 actionLocation := "" 524 if actionPath != "" { 525 actionLocation = path.Join(actionDir, actionPath) 526 } else { 527 actionLocation = actionDir 528 } 529 530 _, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc) 531 532 if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil { 533 return err 534 } 535 536 containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Pre)} 537 logger.Debugf("executing remote job container: %s", containerArgs) 538 539 rc.ApplyExtraPath(ctx, step.getEnv()) 540 541 return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx) 542 543 case model.ActionRunsUsingComposite: 544 if step.getCompositeSteps() == nil { 545 step.getCompositeRunContext(ctx) 546 } 547 548 if steps := step.getCompositeSteps(); steps != nil && steps.pre != nil { 549 return steps.pre(ctx) 550 } 551 return fmt.Errorf("missing steps in composite action") 552 553 default: 554 return nil 555 } 556 } 557 } 558 559 func shouldRunPostStep(step actionStep) common.Conditional { 560 return func(ctx context.Context) bool { 561 log := common.Logger(ctx) 562 stepResults := step.getRunContext().getStepsContext() 563 stepResult := stepResults[step.getStepModel().ID] 564 565 if stepResult == nil { 566 log.WithField("stepResult", model.StepStatusSkipped).Debugf("skipping post step for '%s'; step was not executed", step.getStepModel()) 567 return false 568 } 569 570 if stepResult.Conclusion == model.StepStatusSkipped { 571 log.WithField("stepResult", model.StepStatusSkipped).Debugf("skipping post step for '%s'; main step was skipped", step.getStepModel()) 572 return false 573 } 574 575 if step.getActionModel() == nil { 576 log.WithField("stepResult", model.StepStatusSkipped).Debugf("skipping post step for '%s': no action model available", step.getStepModel()) 577 return false 578 } 579 580 return true 581 } 582 } 583 584 func hasPostStep(step actionStep) common.Conditional { 585 return func(ctx context.Context) bool { 586 action := step.getActionModel() 587 return action.Runs.Using == model.ActionRunsUsingComposite || 588 ((action.Runs.Using == model.ActionRunsUsingNode12 || 589 action.Runs.Using == model.ActionRunsUsingNode16 || 590 action.Runs.Using == model.ActionRunsUsingNode20) && 591 action.Runs.Post != "") 592 } 593 } 594 595 func runPostStep(step actionStep) common.Executor { 596 return func(ctx context.Context) error { 597 logger := common.Logger(ctx) 598 logger.Debugf("run post step for '%s'", step.getStepModel()) 599 600 rc := step.getRunContext() 601 stepModel := step.getStepModel() 602 action := step.getActionModel() 603 604 // todo: refactor into step 605 var actionDir string 606 var actionPath string 607 if _, ok := step.(*stepActionRemote); ok { 608 actionPath = newRemoteAction(stepModel.Uses).Path 609 actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(stepModel.Uses)) 610 } else { 611 actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses) 612 actionPath = "" 613 } 614 615 actionLocation := "" 616 if actionPath != "" { 617 actionLocation = path.Join(actionDir, actionPath) 618 } else { 619 actionLocation = actionDir 620 } 621 622 _, containerActionDir := getContainerActionPaths(stepModel, actionLocation, rc) 623 624 switch action.Runs.Using { 625 case model.ActionRunsUsingNode12, model.ActionRunsUsingNode16, model.ActionRunsUsingNode20: 626 627 populateEnvsFromSavedState(step.getEnv(), step, rc) 628 629 containerArgs := []string{"node", path.Join(containerActionDir, action.Runs.Post)} 630 logger.Debugf("executing remote job container: %s", containerArgs) 631 632 rc.ApplyExtraPath(ctx, step.getEnv()) 633 634 return rc.execJobContainer(containerArgs, *step.getEnv(), "", "")(ctx) 635 636 case model.ActionRunsUsingComposite: 637 if err := maybeCopyToActionDir(ctx, step, actionDir, actionPath, containerActionDir); err != nil { 638 return err 639 } 640 641 if steps := step.getCompositeSteps(); steps != nil && steps.post != nil { 642 return steps.post(ctx) 643 } 644 return fmt.Errorf("missing steps in composite action") 645 646 default: 647 return nil 648 } 649 } 650 }