diff --git a/act/runner/reusable_workflow.go b/act/runner/reusable_workflow.go index 84a633ac..9cdf8bc7 100644 --- a/act/runner/reusable_workflow.go +++ b/act/runner/reusable_workflow.go @@ -7,15 +7,11 @@ package runner import ( "archive/tar" "context" - "errors" "fmt" - "io/fs" "net/url" - "os" "path" "regexp" "strings" - "sync" "gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/common/git" @@ -51,7 +47,7 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor { token := rc.Config.GetToken() return common.NewPipelineExecutor( - newMutexExecutor(cloneIfRequired(rc, *remoteReusableWorkflow, workflowDir, token)), + cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token), newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()), ) } @@ -85,7 +81,7 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor { token := getGitCloneToken(rc.Config, remoteReusableWorkflow.CloneURL()) return common.NewPipelineExecutor( - newMutexExecutor(cloneIfRequired(rc, *remoteReusableWorkflow, workflowDir, token)), + cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token), newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.FilePath()), ) } @@ -125,43 +121,27 @@ func newActionCacheReusableWorkflowExecutor(rc *RunContext, filename string, rem } } -var executorLock sync.Mutex - -func newMutexExecutor(executor common.Executor) common.Executor { +// cloneRemoteReusableWorkflow always invokes the clone executor — moving refs +// (branches, tags) must be re-resolved each run, matching GitHub Actions. +// +// Callers must not change remoteReusableWorkflow.URL, because: +// 1. Gitea doesn't support specifying GithubContext.ServerURL by the GITHUB_SERVER_URL env +// 2. Gitea has already full URL with rc.Config.GitHubInstance when calling newRemoteReusableWorkflowWithPlat +// +// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL +func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor { return func(ctx context.Context) error { - executorLock.Lock() - defer executorLock.Unlock() - - return executor(ctx) + cloneURL = rc.NewExpressionEvaluator(ctx).Interpolate(ctx, cloneURL) + return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{ + URL: cloneURL, + Ref: ref, + Dir: targetDirectory, + Token: token, + OfflineMode: rc.Config.ActionOfflineMode, + })(ctx) } } -func cloneIfRequired(rc *RunContext, remoteReusableWorkflow remoteReusableWorkflow, targetDirectory, token string) common.Executor { - return common.NewConditionalExecutor( - func(ctx context.Context) bool { - _, err := os.Stat(targetDirectory) - notExists := errors.Is(err, fs.ErrNotExist) - return notExists - }, - func(ctx context.Context) error { - // interpolate the cloneURL - cloneURL := rc.NewExpressionEvaluator(ctx).Interpolate(ctx, remoteReusableWorkflow.CloneURL()) - // Do not change the remoteReusableWorkflow.URL, because: - // 1. Gitea doesn't support specifying GithubContext.ServerURL by the GITHUB_SERVER_URL env - // 2. Gitea has already full URL with rc.Config.GitHubInstance when calling newRemoteReusableWorkflowWithPlat - // remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL - return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{ - URL: cloneURL, - Ref: remoteReusableWorkflow.Ref, - Dir: targetDirectory, - Token: token, - OfflineMode: rc.Config.ActionOfflineMode, - })(ctx) - }, - nil, - ) -} - func newReusableWorkflowExecutor(rc *RunContext, directory, workflow string) common.Executor { return func(ctx context.Context) error { planner, err := model.NewWorkflowPlanner(path.Join(directory, workflow), true) diff --git a/act/runner/reusable_workflow_test.go b/act/runner/reusable_workflow_test.go new file mode 100644 index 00000000..7dfb5bea --- /dev/null +++ b/act/runner/reusable_workflow_test.go @@ -0,0 +1,82 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package runner + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "gitea.com/gitea/runner/act/model" + + "github.com/stretchr/testify/require" +) + +// Regression test for go-gitea/gitea#37483: a remote reusable workflow at a moving +// ref (branch/tag) must reflect the new tip on every invocation, not stay pinned +// to the cache populated on the first run. +func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available in PATH") + } + + remoteDir := t.TempDir() + gitMust(t, "", "init", "--bare", "--initial-branch=master", remoteDir) + + workDir := t.TempDir() + gitMust(t, "", "clone", remoteDir, workDir) + gitMust(t, workDir, "config", "user.email", "test@test") + gitMust(t, workDir, "config", "user.name", "test") + gitMust(t, workDir, "checkout", "-b", "master") + + const workflowPath = ".gitea/workflows/reusable.yml" + tmpl := func(tag string) string { + return "name: reusable\non:\n workflow_call:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo " + tag + "\n" + } + + require.NoError(t, os.MkdirAll(filepath.Join(workDir, ".gitea/workflows"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(workDir, workflowPath), []byte(tmpl("v1")), 0o644)) + gitMust(t, workDir, "add", workflowPath) + gitMust(t, workDir, "commit", "-m", "v1") + gitMust(t, workDir, "push", "-u", "origin", "master") + + rc := &RunContext{ + Config: &Config{}, + Run: &model.Run{ + JobID: "j1", + Workflow: &model.Workflow{ + Name: "wf", + Jobs: map[string]*model.Job{"j1": {}}, + }, + }, + } + cacheDir := t.TempDir() + + require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir, "")(context.Background())) + got, err := os.ReadFile(filepath.Join(cacheDir, workflowPath)) + require.NoError(t, err) + require.Equal(t, tmpl("v1"), string(got)) + + // Branch tip moves; cache key (cacheDir) does not. + require.NoError(t, os.WriteFile(filepath.Join(workDir, workflowPath), []byte(tmpl("v2")), 0o644)) + gitMust(t, workDir, "commit", "-am", "v2") + gitMust(t, workDir, "push", "origin", "master") + + require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir, "")(context.Background())) + got, err = os.ReadFile(filepath.Join(cacheDir, workflowPath)) + require.NoError(t, err) + require.Equal(t, tmpl("v2"), string(got), "cached workflow file must reflect the updated branch tip") +} + +func gitMust(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + if dir != "" { + cmd.Dir = dir + } + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, string(out)) +}