From 82a8c1e80dfcd73842bc0359ca8447303b6fcc08 Mon Sep 17 00:00:00 2001 From: Markus Wolf Date: Thu, 19 Jan 2023 21:49:11 +0100 Subject: [PATCH] feat: add remote reusable workflows (#1525) * feat: add remote reusable workflows This changes adds cloning of a remote repository to run a workflow included in it. Closes #826 * fix: defer plan creation until clone is done We need wait for the full clone (and only clone once) before we start to plan the execution for a remote workflow Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- pkg/runner/reusable_workflow.go | 106 ++++++++++++++++++--- pkg/runner/runner_test.go | 2 +- pkg/runner/testdata/uses-workflow/push.yml | 32 ++++++- 3 files changed, 124 insertions(+), 16 deletions(-) diff --git a/pkg/runner/reusable_workflow.go b/pkg/runner/reusable_workflow.go index 87b7bde9..b080b4db 100644 --- a/pkg/runner/reusable_workflow.go +++ b/pkg/runner/reusable_workflow.go @@ -1,35 +1,88 @@ package runner import ( + "context" + "errors" "fmt" + "io/fs" + "os" "path" + "regexp" + "strings" + "sync" "github.com/nektos/act/pkg/common" + "github.com/nektos/act/pkg/common/git" "github.com/nektos/act/pkg/model" ) func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor { - return newReusableWorkflowExecutor(rc, rc.Config.Workdir) + return newReusableWorkflowExecutor(rc, rc.Config.Workdir, rc.Run.Job().Uses) } func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor { - return common.NewErrorExecutor(fmt.Errorf("remote reusable workflows are currently not supported (see https://github.com/nektos/act/issues/826 for updates)")) + uses := rc.Run.Job().Uses + + remoteReusableWorkflow := newRemoteReusableWorkflow(uses) + if remoteReusableWorkflow == nil { + return common.NewErrorExecutor(fmt.Errorf("expected format {owner}/{repo}/.github/workflows/{filename}@{ref}. Actual '%s' Input string was not in a correct format", uses)) + } + remoteReusableWorkflow.URL = rc.Config.GitHubInstance + + workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), strings.ReplaceAll(uses, "/", "-")) + + return common.NewPipelineExecutor( + newMutexExecutor(cloneIfRequired(rc, *remoteReusableWorkflow, workflowDir)), + newReusableWorkflowExecutor(rc, workflowDir, fmt.Sprintf("./.github/workflows/%s", remoteReusableWorkflow.Filename)), + ) } -func newReusableWorkflowExecutor(rc *RunContext, directory string) common.Executor { - planner, err := model.NewWorkflowPlanner(path.Join(directory, rc.Run.Job().Uses), true) - if err != nil { - return common.NewErrorExecutor(err) +var ( + executorLock sync.Mutex +) + +func newMutexExecutor(executor common.Executor) common.Executor { + return func(ctx context.Context) error { + executorLock.Lock() + defer executorLock.Unlock() + + return executor(ctx) } +} - plan := planner.PlanEvent("workflow_call") +func cloneIfRequired(rc *RunContext, remoteReusableWorkflow remoteReusableWorkflow, targetDirectory string) common.Executor { + return common.NewConditionalExecutor( + func(ctx context.Context) bool { + _, err := os.Stat(targetDirectory) + notExists := errors.Is(err, fs.ErrNotExist) + return notExists + }, + git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{ + URL: remoteReusableWorkflow.CloneURL(), + Ref: remoteReusableWorkflow.Ref, + Dir: targetDirectory, + Token: rc.Config.Token, + }), + nil, + ) +} - runner, err := NewReusableWorkflowRunner(rc) - if err != nil { - return common.NewErrorExecutor(err) +func newReusableWorkflowExecutor(rc *RunContext, directory string, workflow string) common.Executor { + return func(ctx context.Context) error { + planner, err := model.NewWorkflowPlanner(path.Join(directory, workflow), true) + if err != nil { + return err + } + + plan := planner.PlanEvent("workflow_call") + + runner, err := NewReusableWorkflowRunner(rc) + if err != nil { + return err + } + + return runner.NewPlanExecutor(plan)(ctx) } - - return runner.NewPlanExecutor(plan) } func NewReusableWorkflowRunner(rc *RunContext) (Runner, error) { @@ -43,3 +96,32 @@ func NewReusableWorkflowRunner(rc *RunContext) (Runner, error) { return runner.configure() } + +type remoteReusableWorkflow struct { + URL string + Org string + Repo string + Filename string + Ref string +} + +func (r *remoteReusableWorkflow) CloneURL() string { + return fmt.Sprintf("https://%s/%s/%s", r.URL, r.Org, r.Repo) +} + +func newRemoteReusableWorkflow(uses string) *remoteReusableWorkflow { + // GitHub docs: + // https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_iduses + r := regexp.MustCompile(`^([^/]+)/([^/]+)/.github/workflows/([^@]+)@(.*)$`) + matches := r.FindStringSubmatch(uses) + if len(matches) != 5 { + return nil + } + return &remoteReusableWorkflow{ + Org: matches[1], + Repo: matches[2], + Filename: matches[3], + Ref: matches[4], + URL: "github.com", + } +} diff --git a/pkg/runner/runner_test.go b/pkg/runner/runner_test.go index 6096abe1..9298afcc 100644 --- a/pkg/runner/runner_test.go +++ b/pkg/runner/runner_test.go @@ -145,7 +145,7 @@ func TestRunEvent(t *testing.T) { {workdir, "uses-composite-with-error", "push", "Job 'failing-composite-action' failed", platforms, secrets}, {workdir, "uses-nested-composite", "push", "", platforms, secrets}, {workdir, "remote-action-composite-js-pre-with-defaults", "push", "", platforms, secrets}, - {workdir, "uses-workflow", "push", "reusable workflows are currently not supported (see https://github.com/nektos/act/issues/826 for updates)", platforms, secrets}, + {workdir, "uses-workflow", "push", "", platforms, map[string]string{"secret": "keep_it_private"}}, {workdir, "uses-workflow", "pull_request", "", platforms, map[string]string{"secret": "keep_it_private"}}, {workdir, "uses-docker-url", "push", "", platforms, secrets}, {workdir, "act-composite-env-test", "push", "", platforms, secrets}, diff --git a/pkg/runner/testdata/uses-workflow/push.yml b/pkg/runner/testdata/uses-workflow/push.yml index 855dacfe..ddc37b86 100644 --- a/pkg/runner/testdata/uses-workflow/push.yml +++ b/pkg/runner/testdata/uses-workflow/push.yml @@ -2,8 +2,34 @@ on: push jobs: reusable-workflow: - uses: nektos/act-tests/.github/workflows/reusable-workflow.yml@master + uses: nektos/act-test-actions/.github/workflows/reusable-workflow.yml@main with: - username: mona + string_required: string + bool_required: ${{ true }} + number_required: 1 secrets: - envPATH: ${{ secrets.envPAT }} + secret: keep_it_private + + reusable-workflow-with-inherited-secrets: + uses: nektos/act-test-actions/.github/workflows/reusable-workflow.yml@main + with: + string_required: string + bool_required: ${{ true }} + number_required: 1 + secrets: inherit + + output-test: + runs-on: ubuntu-latest + needs: + - reusable-workflow + - reusable-workflow-with-inherited-secrets + steps: + - name: output with secrets map + run: | + echo reusable-workflow.output=${{ needs.reusable-workflow.outputs.output }} + [[ "${{ needs.reusable-workflow.outputs.output == 'string' }}" = "true" ]] || exit 1 + + - name: output with inherited secrets + run: | + echo reusable-workflow-with-inherited-secrets.output=${{ needs.reusable-workflow-with-inherited-secrets.outputs.output }} + [[ "${{ needs.reusable-workflow-with-inherited-secrets.outputs.output == 'string' }}" = "true" ]] || exit 1