package runner import ( "archive/tar" "context" "errors" "fmt" "io" "os" "path" "path/filepath" "regexp" "strings" gogit "github.com/go-git/go-git/v5" "github.com/nektos/act/pkg/common" "github.com/nektos/act/pkg/common/git" "github.com/nektos/act/pkg/model" ) type stepActionRemote struct { Step *model.Step RunContext *RunContext compositeRunContext *RunContext compositeSteps *compositeSteps readAction readAction runAction runAction action *model.Action env map[string]string remoteAction *remoteAction cacheDir string resolvedSha string } var stepActionRemoteNewCloneExecutor = git.NewGitCloneExecutor func (sar *stepActionRemote) prepareActionExecutor() common.Executor { return func(ctx context.Context) error { if sar.remoteAction != nil && sar.action != nil { // we are already good to run return nil } // For gitea: // Since actions can specify the download source via a url prefix. // The prefix may contain some sensitive information that needs to be stored in secrets, // so we need to interpolate the expression value for uses first. sar.Step.Uses = sar.RunContext.NewExpressionEvaluator(ctx).Interpolate(ctx, sar.Step.Uses) 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) } github := sar.getGithubContext(ctx) 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 } for _, action := range sar.RunContext.Config.ReplaceGheActionWithGithubCom { if strings.EqualFold(fmt.Sprintf("%s/%s", sar.remoteAction.Org, sar.remoteAction.Repo), action) { sar.remoteAction.URL = "https://github.com" github.Token = sar.RunContext.Config.ReplaceGheActionTokenWithGithubCom } } if sar.RunContext.Config.ActionCache != nil { cache := sar.RunContext.Config.ActionCache var err error sar.cacheDir = fmt.Sprintf("%s/%s", sar.remoteAction.Org, sar.remoteAction.Repo) repoURL := sar.remoteAction.URL + "/" + sar.cacheDir repoRef := sar.remoteAction.Ref sar.resolvedSha, err = cache.Fetch(ctx, sar.cacheDir, repoURL, repoRef, github.Token) if err != nil { return fmt.Errorf("failed to fetch \"%s\" version \"%s\": %w", repoURL, repoRef, err) } remoteReader := func(ctx context.Context) actionYamlReader { return func(filename string) (io.Reader, io.Closer, error) { spath := path.Join(sar.remoteAction.Path, filename) for i := 0; i < maxSymlinkDepth; i++ { tars, err := cache.GetTarArchive(ctx, sar.cacheDir, sar.resolvedSha, spath) if err != nil { return nil, nil, os.ErrNotExist } treader := tar.NewReader(tars) header, err := treader.Next() if err != nil { return nil, nil, os.ErrNotExist } if header.FileInfo().Mode()&os.ModeSymlink == os.ModeSymlink { spath, err = symlinkJoin(spath, header.Linkname, ".") if err != nil { return nil, nil, err } } else { return treader, tars, nil } } return nil, nil, fmt.Errorf("max depth %d of symlinks exceeded while reading %s", maxSymlinkDepth, spath) } } actionModel, err := sar.readAction(ctx, sar.Step, sar.resolvedSha, sar.remoteAction.Path, remoteReader(ctx), os.WriteFile) sar.action = actionModel return err } actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), safeFilename(sar.Step.Uses)) gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{ URL: sar.remoteAction.CloneURL(sar.RunContext.Config.DefaultActionInstance), Ref: sar.remoteAction.Ref, Dir: actionDir, Token: "", /* Shouldn't provide token when cloning actions, the token comes from the instance which triggered the task, however, it might be not the same instance which provides actions. For GitHub, they are the same, always github.com. But for Gitea, tasks triggered by a.com can clone actions from b.com. */ OfflineMode: sar.RunContext.Config.ActionOfflineMode, InsecureSkipTLS: sar.cloneSkipTLS(), // For Gitea }) var ntErr common.Executor if err := gitClone(ctx); err != nil { if errors.Is(err, git.ErrShortRef) { 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.(*git.Error).Commit()) } else if errors.Is(err, gogit.ErrForceNeeded) { // TODO: figure out if it will be easy to shadow/alias go-git err's ntErr = common.NewInfoExecutor("Non-terminating error while running 'git clone': %v", err) } else { return err } } remoteReader := func(ctx context.Context) actionYamlReader { return func(filename string) (io.Reader, io.Closer, error) { f, err := os.Open(filepath.Join(actionDir, sar.remoteAction.Path, filename)) return f, f, err } } return common.NewPipelineExecutor( ntErr, func(ctx context.Context) error { actionModel, err := sar.readAction(ctx, sar.Step, actionDir, sar.remoteAction.Path, remoteReader(ctx), os.WriteFile) sar.action = actionModel return err }, )(ctx) } } func (sar *stepActionRemote) pre() common.Executor { sar.env = map[string]string{} return common.NewPipelineExecutor( sar.prepareActionExecutor(), runStepExecutor(sar, stepStagePre, runPreStep(sar)).If(hasPreStep(sar)).If(shouldRunPreStep(sar))) } func (sar *stepActionRemote) main() common.Executor { return common.NewPipelineExecutor( sar.prepareActionExecutor(), runStepExecutor(sar, stepStageMain, func(ctx context.Context) error { github := sar.getGithubContext(ctx) if sar.remoteAction.IsCheckout() && isLocalCheckout(github, sar.Step) && !sar.RunContext.Config.NoSkipCheckout { if sar.RunContext.Config.BindWorkdir { common.Logger(ctx).Debugf("Skipping local actions/checkout because you bound your workspace") return nil } eval := sar.RunContext.NewExpressionEvaluator(ctx) copyToPath := path.Join(sar.RunContext.JobContainer.ToContainerPath(sar.RunContext.Config.Workdir), eval.Interpolate(ctx, sar.Step.With["path"])) return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore)(ctx) } actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), safeFilename(sar.Step.Uses)) return sar.runAction(sar, actionDir, sar.remoteAction)(ctx) }), ) } func (sar *stepActionRemote) post() common.Executor { return runStepExecutor(sar, stepStagePost, runPostStep(sar)).If(hasPostStep(sar)).If(shouldRunPostStep(sar)) } func (sar *stepActionRemote) getRunContext() *RunContext { return sar.RunContext } func (sar *stepActionRemote) getGithubContext(ctx context.Context) *model.GithubContext { ghc := sar.getRunContext().getGithubContext(ctx) // extend github context if we already have an initialized remoteAction remoteAction := sar.remoteAction if remoteAction != nil { ghc.ActionRepository = fmt.Sprintf("%s/%s", remoteAction.Org, remoteAction.Repo) ghc.ActionRef = remoteAction.Ref } return ghc } func (sar *stepActionRemote) getStepModel() *model.Step { return sar.Step } func (sar *stepActionRemote) getEnv() *map[string]string { return &sar.env } func (sar *stepActionRemote) getIfExpression(ctx context.Context, stage stepStage) string { switch stage { case stepStagePre: github := sar.getGithubContext(ctx) 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 { return sar.action } func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) *RunContext { if sar.compositeRunContext == nil { actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), safeFilename(sar.Step.Uses)) actionLocation := path.Join(actionDir, sar.remoteAction.Path) _, containerActionDir := getContainerActionPaths(sar.getStepModel(), actionLocation, sar.RunContext) sar.compositeRunContext = newCompositeRunContext(ctx, sar.RunContext, sar, containerActionDir) sar.compositeSteps = sar.compositeRunContext.compositeExecutor(sar.action) } else { // Re-evaluate environment here. For remote actions the environment // need to be re-created for every stage (pre, main, post) as there // might be required context changes (inputs/outputs) while the action // stages are executed. (e.g. the output of another action is the // input for this action during the main stage, but the env // was already created during the pre stage) env := evaluateCompositeInputAndEnv(ctx, sar.RunContext, sar) sar.compositeRunContext.Env = env sar.compositeRunContext.ExtraPath = sar.RunContext.ExtraPath } return sar.compositeRunContext } func (sar *stepActionRemote) getCompositeSteps() *compositeSteps { return sar.compositeSteps } // For Gitea // cloneSkipTLS returns true if the runner can clone an action from the Gitea instance func (sar *stepActionRemote) cloneSkipTLS() bool { if !sar.RunContext.Config.InsecureSkipTLS { // Return false if the Gitea instance is not an insecure instance return false } if sar.remoteAction.URL == "" { // Empty URL means the default action instance should be used // Return true if the URL of the Gitea instance is the same as the URL of the default action instance return sar.RunContext.Config.DefaultActionInstance == sar.RunContext.Config.GitHubInstance } // Return true if the URL of the remote action is the same as the URL of the Gitea instance return sar.remoteAction.URL == sar.RunContext.Config.GitHubInstance } type remoteAction struct { URL string Org string Repo string Path string Ref string } func (ra *remoteAction) CloneURL(u string) string { if ra.URL == "" { if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { u = "https://" + u } } else { u = ra.URL } return fmt.Sprintf("%s/%s/%s", u, ra.Org, ra.Repo) } func (ra *remoteAction) IsCheckout() bool { if ra.Org == "actions" && ra.Repo == "checkout" { return true } return false } func newRemoteAction(action string) *remoteAction { // support http(s)://host/owner/repo@v3 for _, schema := range []string{"https://", "http://"} { if strings.HasPrefix(action, schema) { splits := strings.SplitN(strings.TrimPrefix(action, schema), "/", 2) if len(splits) != 2 { return nil } ret := parseAction(splits[1]) if ret == nil { return nil } ret.URL = schema + splits[0] return ret } } return parseAction(action) } func parseAction(action string) *remoteAction { // GitHub's document[^] describes: // > We strongly recommend that you include the version of // > the action you are using by specifying a Git ref, SHA, or Docker tag number. // Actually, the workflow stops if there is the uses directive that hasn't @ref. // [^]: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions r := regexp.MustCompile(`^([^/@]+)/([^/@]+)(/([^@]*))?(@(.*))?$`) matches := r.FindStringSubmatch(action) if len(matches) < 7 || matches[6] == "" { return nil } return &remoteAction{ Org: matches[1], Repo: matches[2], Path: matches[4], Ref: matches[6], URL: "", } } func safeFilename(s string) string { return strings.NewReplacer( `<`, "-", `>`, "-", `:`, "-", `"`, "-", `/`, "-", `\`, "-", `|`, "-", `?`, "-", `*`, "-", ).Replace(s) }