From 2208e7ec63c3266fd3c566810d697ffa49bb10be Mon Sep 17 00:00:00 2001 From: Vi Date: Wed, 20 May 2026 14:09:39 +0000 Subject: [PATCH] feat: add cache.offline_mode to reuse cached actions (#966) Co-authored-by: silverwind Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com> Co-authored-by: TKaxv_7S <56359+tkaxv_7s@noreply.gitea.com> Co-authored-by: techknowlogick Co-authored-by: TKaxv_7S <954067342@qq.com> Co-authored-by: TKaxv_7S Reviewed-on: https://gitea.com/gitea/runner/pulls/966 Reviewed-by: Nicolas Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> Co-authored-by: Vi Co-committed-by: Vi --- act/common/git/git.go | 91 ++++++++++++++----------- act/common/git/git_test.go | 48 +++++++++++++ act/runner/runner.go | 2 +- internal/app/run/runner.go | 9 +-- internal/pkg/config/config.example.yaml | 3 + internal/pkg/config/config.go | 1 + 6 files changed, 108 insertions(+), 46 deletions(-) diff --git a/act/common/git/git.go b/act/common/git/git.go index 3ebe9724..f8d22c3d 100644 --- a/act/common/git/git.go +++ b/act/common/git/git.go @@ -243,47 +243,50 @@ type NewGitCloneExecutorInput struct { InsecureSkipTLS bool } -// CloneIfRequired ... -func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, error) { +// CloneIfRequired returns the repository and a boolean indicating whether an existing local clone was reused. +func CloneIfRequired(ctx context.Context, refName plumbing.ReferenceName, input NewGitCloneExecutorInput, logger log.FieldLogger) (*git.Repository, bool, error) { r, err := git.PlainOpen(input.Dir) - if err != nil { - var progressWriter io.Writer - if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) { - if entry, ok := logger.(*log.Entry); ok { - progressWriter = entry.WriterLevel(log.DebugLevel) - } else if lgr, ok := logger.(*log.Logger); ok { - progressWriter = lgr.WriterLevel(log.DebugLevel) - } else { - log.Errorf("Unable to get writer from logger (type=%T)", logger) - progressWriter = os.Stdout - } - } + if err == nil { + // Reuse existing clone + return r, true, nil + } - cloneOptions := git.CloneOptions{ - URL: input.URL, - Progress: progressWriter, - - InsecureSkipTLS: input.InsecureSkipTLS, // For Gitea - } - if input.Token != "" { - cloneOptions.Auth = &http.BasicAuth{ - Username: "token", - Password: input.Token, - } - } - - r, err = git.PlainCloneContext(ctx, input.Dir, false, &cloneOptions) - if err != nil { - logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err) - return nil, err - } - - if err = os.Chmod(input.Dir, 0o755); err != nil { - return nil, err + var progressWriter io.Writer + if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) { + if entry, ok := logger.(*log.Entry); ok { + progressWriter = entry.WriterLevel(log.DebugLevel) + } else if lgr, ok := logger.(*log.Logger); ok { + progressWriter = lgr.WriterLevel(log.DebugLevel) + } else { + log.Errorf("Unable to get writer from logger (type=%T)", logger) + progressWriter = os.Stdout } } - return r, nil + cloneOptions := git.CloneOptions{ + URL: input.URL, + Progress: progressWriter, + + InsecureSkipTLS: input.InsecureSkipTLS, // For Gitea + } + if input.Token != "" { + cloneOptions.Auth = &http.BasicAuth{ + Username: "token", + Password: input.Token, + } + } + + r, err = git.PlainCloneContext(ctx, input.Dir, false, &cloneOptions) + if err != nil { + logger.Errorf("Unable to clone %v %s: %v", input.URL, refName, err) + return nil, false, err + } + + if err = os.Chmod(input.Dir, 0o755); err != nil { + return nil, false, err + } + + return r, false, nil } func gitOptions(token string) (fetchOptions git.FetchOptions, pullOptions git.PullOptions) { @@ -313,7 +316,7 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor { defer AcquireCloneLock(input.Dir)() refName := plumbing.ReferenceName("refs/heads/" + input.Ref) - r, err := CloneIfRequired(ctx, refName, input, logger) + r, reused, err := CloneIfRequired(ctx, refName, input, logger) if err != nil { return err } @@ -338,10 +341,10 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor { var hash *plumbing.Hash rev := plumbing.Revision(input.Ref) if hash, err = r.ResolveRevision(rev); err != nil { + // ResolveRevision returns a nil hash on error, and a branch ref legitimately fails + // here (no local refs/heads/); the duck-typing below resolves it. logger.Errorf("Unable to resolve %s: %v", input.Ref, err) - } - - if hash.String() != input.Ref && strings.HasPrefix(hash.String(), input.Ref) { + } else if hash.String() != input.Ref && strings.HasPrefix(hash.String(), input.Ref) { return &Error{ err: ErrShortRef, commit: hash.String(), @@ -392,12 +395,18 @@ func NewGitCloneExecutor(input NewGitCloneExecutorInput) common.Executor { return err } } + + reusedMsg := "" + if !isOfflineMode { if err = w.Pull(&pullOptions); err != nil && err != git.NoErrAlreadyUpToDate { logger.Debugf("Unable to pull %s: %v", refName, err) } + } else if reused { + reusedMsg = " (reused in offline mode)" } - logger.Debugf("Cloned %s to %s", input.URL, input.Dir) + + logger.Debugf("Cloned %s to %s%s", input.URL, input.Dir, reusedMsg) if hash.String() != input.Ref && refType == "branch" { logger.Debugf("Provided ref is not a sha. Updating branch ref after pull") diff --git a/act/common/git/git_test.go b/act/common/git/git_test.go index 710674ca..86ed1af3 100644 --- a/act/common/git/git_test.go +++ b/act/common/git/git_test.go @@ -279,6 +279,54 @@ func TestGitCloneExecutorNonFastForwardRef(t *testing.T) { assert.Equal(t, "second", strings.TrimSpace(string(out)), "working tree should be at the latest commit") } +func TestGitCloneExecutorOfflineMode(t *testing.T) { + gitConfig() + + // Build a local "remote" with a single commit on main. + remoteDir := t.TempDir() + require.NoError(t, gitCmd("init", "--bare", "--initial-branch=main", remoteDir)) + workDir := t.TempDir() + require.NoError(t, gitCmd("clone", remoteDir, workDir)) + require.NoError(t, gitCmd("-C", workDir, "checkout", "-b", "main")) + require.NoError(t, gitCmd("-C", workDir, "commit", "--allow-empty", "-m", "initial")) + require.NoError(t, gitCmd("-C", workDir, "push", "-u", "origin", "main")) + + // Prime the cache with an online clone of main. + cacheDir := t.TempDir() + require.NoError(t, NewGitCloneExecutor(NewGitCloneExecutorInput{ + URL: remoteDir, + Ref: "main", + Dir: cacheDir, + })(context.Background())) + + t.Run("cached branch resolves without fetching", func(t *testing.T) { + // Offline reuse of a cached branch must succeed even though ResolveRevision(input.Ref) + // finds no local refs/heads/. + err := NewGitCloneExecutor(NewGitCloneExecutorInput{ + URL: remoteDir, + Ref: "main", + Dir: cacheDir, + OfflineMode: true, + })(context.Background()) + require.NoError(t, err) + + out, err := exec.Command("git", "-C", cacheDir, "log", "--oneline", "-1", "--format=%s").Output() + require.NoError(t, err) + assert.Equal(t, "initial", strings.TrimSpace(string(out))) + }) + + t.Run("unresolvable cached ref returns error", func(t *testing.T) { + // The ref was never cached; offline mode cannot resolve it and must return an error. + err := NewGitCloneExecutor(NewGitCloneExecutorInput{ + URL: remoteDir, + Ref: "never-fetched", + Dir: cacheDir, + OfflineMode: true, + })(context.Background()) + require.Error(t, err) + }) +} + func gitConfig() { if os.Getenv("GITHUB_ACTIONS") == "true" { var err error diff --git a/act/runner/runner.go b/act/runner/runner.go index cb389ecd..1dda2b18 100644 --- a/act/runner/runner.go +++ b/act/runner/runner.go @@ -30,7 +30,7 @@ type Config struct { Actor string // the user that triggered the event Workdir string // path to working directory ActionCacheDir string // path used for caching action contents - ActionOfflineMode bool // when offline, use caching action contents + ActionOfflineMode bool // when offline, use cached action contents BindWorkdir bool // bind the workdir to the job container EventName string // name of event to run EventPath string // path to JSON file to use for event.json in containers diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go index 6d72415f..23e648cf 100644 --- a/internal/app/run/runner.go +++ b/internal/app/run/runner.go @@ -344,10 +344,11 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. runnerConfig := &runner.Config{ // On Linux, Workdir will be like "///" // On Windows, Workdir will be like "\\\" - Workdir: workdir, - BindWorkdir: r.cfg.Container.BindWorkdir, - ActionCacheDir: filepath.FromSlash(r.cfg.Host.WorkdirParent), - AllocatePTY: r.cfg.Runner.AllocatePTY, + Workdir: workdir, + BindWorkdir: r.cfg.Container.BindWorkdir, + ActionCacheDir: filepath.FromSlash(r.cfg.Host.WorkdirParent), + AllocatePTY: r.cfg.Runner.AllocatePTY, + ActionOfflineMode: r.cfg.Cache.OfflineMode, ReuseContainers: false, ForcePull: r.cfg.Container.ForcePull, diff --git a/internal/pkg/config/config.example.yaml b/internal/pkg/config/config.example.yaml index 24fc478d..53d136a0 100644 --- a/internal/pkg/config/config.example.yaml +++ b/internal/pkg/config/config.example.yaml @@ -102,6 +102,9 @@ cache: # (or `gitea-runner cache-server`) is in use: the runner pre-registers each job's ACTIONS_RUNTIME_TOKEN with the # cache-server, and the cache-server enforces bearer auth + per-repo cache isolation. external_secret: "" + # When true, reuse a cached action instead of fetching from the remote on every job. Note: a moved tag + # (e.g. a re-tagged "v6") or an updated branch stays at the cached commit until its cache entry is removed. + offline_mode: false container: # Specifies the network to which the container will connect. diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index efad73de..5f573ff7 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -52,6 +52,7 @@ type Cache struct { Port uint16 `yaml:"port"` // Port specifies the caching port. ExternalServer string `yaml:"external_server"` // ExternalServer specifies the URL of external cache server ExternalSecret string `yaml:"external_secret"` // ExternalSecret is a shared secret between this runner and an external gitea-runner cache-server, enabling per-job ACTIONS_RUNTIME_TOKEN authentication and repo scoping over the network. Leave empty to keep the legacy unauthenticated behavior. + OfflineMode bool `yaml:"offline_mode"` // OfflineMode reuses a cached action without fetching from the remote; a moved tag or branch stays at the cached commit until the cache entry is removed. } // Container represents the configuration for the container.