mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-05-08 08:13:25 +02:00
`NewGitCloneExecutor` holds a per-directory mutex while it `git checkout --force`s a remote action into the shared `<ActionCacheDir>/<UsesHash>`, but four read sites ran unlocked: - `maybeCopyToActionDir`'s tar walk via `JobContainer.CopyDir` - `prepareActionExecutor`'s `readAction` parse of `action.yml` - `newReusableWorkflowExecutor`'s `model.NewWorkflowPlanner` after `cloneRemoteReusableWorkflow` released its lock - `execAsDocker` when `ActionCache == nil`: `docker build` walks `contextDir` for the daemon-side build context When two matrix jobs share a `uses:`, a read interleaved with a peer's checkout produces partial state — observed as `Cannot find module .../dist/index.js` and `setup-uv` failing on a half-written `action.yml`. Exports `acquireCloneLock` as `AcquireCloneLock` and takes it at all four sites. `container.ImageExistsLocally` / `NewDockerBuildExecutor` and `model.NewWorkflowPlanner` are indirected through package-level vars so the docker-action build path and the reusable-workflow read site are testable without a real daemon, mirroring `ContainerNewContainer`. Three regression tests cover the higher-risk sites (`maybeCopyToActionDir`, `execAsDocker`, `newReusableWorkflowExecutor`); each fails if its `AcquireCloneLock` is removed. Subsumed by https://gitea.com/gitea/runner/pulls/814 once that lands. Related: https://gitea.com/gitea/runner/pulls/930 --- This PR was written with the help of Claude Opus 4.7 --------- Co-authored-by: Nicolas <bircni@icloud.com> Reviewed-on: https://gitea.com/gitea/runner/pulls/938 Reviewed-by: Nicolas <bircni@icloud.com> Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com> Co-authored-by: silverwind <me@silverwind.io> Co-committed-by: silverwind <me@silverwind.io>
135 lines
4.0 KiB
Go
135 lines
4.0 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package runner
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.com/gitea/runner/act/common/git"
|
|
"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 TestNewReusableWorkflowExecutorHoldsCloneLock(t *testing.T) {
|
|
workflowDir := t.TempDir()
|
|
|
|
unlockOnce := sync.OnceFunc(git.AcquireCloneLock(workflowDir))
|
|
defer unlockOnce()
|
|
|
|
plannerCalled := make(chan struct{})
|
|
|
|
origPlanner := modelNewWorkflowPlanner
|
|
modelNewWorkflowPlanner = func(string, bool) (model.WorkflowPlanner, error) {
|
|
close(plannerCalled)
|
|
return nil, errors.New("stop")
|
|
}
|
|
defer func() { modelNewWorkflowPlanner = origPlanner }()
|
|
|
|
rc := &RunContext{
|
|
Config: &Config{},
|
|
Run: &model.Run{Workflow: &model.Workflow{Jobs: map[string]*model.Job{}}},
|
|
}
|
|
exec := newReusableWorkflowExecutor(rc, workflowDir, "reusable.yml")
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- exec(context.Background()) }()
|
|
|
|
select {
|
|
case <-plannerCalled:
|
|
t.Fatal("planner ran while clone lock was held")
|
|
case err := <-done:
|
|
t.Fatalf("executor returned before planner was reached: %v", err)
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
unlockOnce()
|
|
|
|
select {
|
|
case <-plannerCalled:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("planner not called after lock was released")
|
|
}
|
|
|
|
select {
|
|
case err := <-done:
|
|
require.Error(t, err)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("executor did not return after planner ran")
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|