mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-05 23:05:45 +00:00
Each step refreshed its own clone of an action, so a composite using one action repository ten times fetched it ten times per job, and a single network error failed the job. Like actions/runner, a job now downloads each action repository and ref once, as an archive extracted into a directory per commit. Each action still gets its own copy in the job container. This restores the per-job dedupe of https://gitea.com/gitea/runner/pulls/1178, which only reached a since-deleted branch. How a download works: 1. The action URL is taken after `insteadOf` rewriting, as git would use it. 2. The archive comes from `<url>/archive/<ref>.tar.gz`, or from codeload.github.com for github.com, as for actions/runner. 3. A cached tag or branch is revalidated with its ETag, which GitHub and Gitea 28+ answer with a 304. A cached commit pin needs no request. 4. Transient failures (5xx, 408, 429 honouring `Retry-After`, dropped transfers) are retried three times with a 10 to 30 second backoff. 5. Git takes over, cloning and exporting the commit into the same directory layout, when the URL is not HTTP(S), the ref is a short commit, the host serves no usable archive, the host cannot be connected to, or the retries run out. Median of 3 runs, git (shallow clone, then fetch) against archives, on a cold and a warm cache: | Action | git cold | git warm | archive cold | archive warm | |---|---|---|---|---| | github.com actions/checkout@v4 | 937ms | 726ms | 286ms | 171ms | | github.com actions/cache@v4 | 1006ms | 801ms | 516ms | 161ms | | github.com actions/setup-node@v4 | 1046ms | 786ms | 463ms | 156ms | | gitea.com actions/checkout@v4 | 2007ms | 1165ms | 667ms | 222ms | The action cache layout changes, so existing caches download once more. *Written by Claude.* Fixes: https://gitea.com/gitea/runner/issues/1220 Reviewed-on: https://gitea.com/gitea/runner/pulls/1316 Reviewed-by: bircni <[email protected]> Co-authored-by: silverwind <[email protected]>
220 lines
8.4 KiB
Go
220 lines
8.4 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package runner
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.com/gitea/runner/act/common/git"
|
|
|
|
"gitea.dev/actionslib/pkg/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)(t.Context()))
|
|
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)(t.Context()))
|
|
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()
|
|
require.NoError(t, os.WriteFile(filepath.Join(workflowDir, "reusable.yml"), []byte(":"), 0o644))
|
|
|
|
unlock, err := git.AcquireCloneLock(t.Context(), workflowDir)
|
|
require.NoError(t, err)
|
|
unlockOnce := sync.OnceFunc(unlock)
|
|
defer unlockOnce()
|
|
|
|
rc := &RunContext{
|
|
Config: &Config{},
|
|
Run: &model.Run{Workflow: &model.Workflow{Jobs: map[string]*model.Job{}}},
|
|
}
|
|
exec := newReusableWorkflowExecutor(rc, workflowDir, "reusable.yml", true)
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- exec(context.Background()) }()
|
|
|
|
select {
|
|
case err := <-done:
|
|
t.Fatalf("executor returned while clone lock was held: %v", err)
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
unlockOnce()
|
|
|
|
select {
|
|
case err := <-done:
|
|
require.Error(t, err)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("executor did not return after lock was released")
|
|
}
|
|
}
|
|
|
|
func TestNewLocalReusableWorkflowExecutorFindsSameRepositoryPaths(t *testing.T) {
|
|
workdir := filepath.Join(t.TempDir(), "workspace")
|
|
require.NoError(t, os.MkdirAll(filepath.Join(workdir, ".gitea", "workflows"), 0o755))
|
|
require.NoError(t, os.Mkdir(workdir+".lock", 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(workdir, ".gitea", "workflows", "reusable.yml"), []byte(":"), 0o644))
|
|
|
|
for _, uses := range []string{"./.gitea/workflows/reusable.yml", "$/.gitea/workflows/reusable.yml"} {
|
|
rc := &RunContext{
|
|
Config: &Config{Workdir: workdir},
|
|
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {Uses: uses}}}},
|
|
}
|
|
err := newLocalReusableWorkflowExecutor(rc)(t.Context())
|
|
require.ErrorContains(t, err, "workflow is not valid")
|
|
}
|
|
}
|
|
|
|
func TestCloneRemoteReusableWorkflowSendsInstanceTokenAndClientCert(t *testing.T) {
|
|
var gotCert bool
|
|
var gotToken string
|
|
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotCert = len(r.TLS.PeerCertificates) > 0
|
|
_, gotToken, _ = r.BasicAuth()
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
server.TLS = &tls.Config{ClientAuth: tls.RequireAnyClientCert}
|
|
server.StartTLS()
|
|
defer server.Close()
|
|
pair := server.TLS.Certificates[0]
|
|
keyDER, err := x509.MarshalPKCS8PrivateKey(pair.PrivateKey)
|
|
require.NoError(t, err)
|
|
dir := t.TempDir()
|
|
certFile, keyFile := filepath.Join(dir, "client.crt"), filepath.Join(dir, "client.key")
|
|
require.NoError(t, os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: pair.Certificate[0]}), 0o600))
|
|
require.NoError(t, os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0o600))
|
|
rc := &RunContext{
|
|
Config: &Config{
|
|
GitHubInstance: server.URL,
|
|
Secrets: map[string]string{"GITEA_TOKEN": "token"},
|
|
InsecureSkipTLS: true,
|
|
ClientCertFile: certFile,
|
|
ClientKeyFile: keyFile,
|
|
},
|
|
Run: &model.Run{JobID: "j1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"j1": {}}}},
|
|
}
|
|
|
|
require.Error(t, cloneRemoteReusableWorkflow(rc, server.URL+"/owner/repo", "main", t.TempDir())(t.Context()))
|
|
require.True(t, gotCert)
|
|
require.Equal(t, "token", gotToken)
|
|
}
|
|
|
|
func TestInstanceAuthAndTransportOnlyApplyToTheInstance(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name, instanceURL, trustedActionInstance, cloneURL string
|
|
token, cert, insecure bool
|
|
}{
|
|
{"same host with schemaless instance", "gitea.example.net", "", "https://gitea.example.net/actions/tools", true, true, true},
|
|
{"same host with schemaless instance and port", "gitea.example.net:3000", "", "https://gitea.example.net:3000/actions/tools", true, true, true},
|
|
{"different host", "gitea.example.net", "", "https://github.com/actions/tools", false, false, false},
|
|
{"embedded basic auth keeps its own credentials", "gitea.example.net", "", "https://user:[email protected]/actions/tools", false, true, true},
|
|
{"invalid clone URL", "gitea.example.net", "", "://gitea.example.net/actions/tools", false, false, false},
|
|
{"self-hosted action instance on different host skips no verification", "gitea.local", "https://gitea.my-nas.lan", "https://gitea.my-nas.lan/owner/action", true, true, false},
|
|
{"self-hosted action instance with embedded basic auth", "gitea.local", "https://gitea.my-nas.lan", "https://user:[email protected]/owner/action", false, true, false},
|
|
{"github mode does not trust mirror host", "gitea.local", "", "https://mirror.example.com/owner/action", false, false, false},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
conf := Config{
|
|
GitHubInstance: tt.instanceURL,
|
|
DefaultActionInstance: tt.trustedActionInstance,
|
|
DefaultActionInstanceIsSelfHosted: tt.trustedActionInstance != "",
|
|
InsecureSkipTLS: true,
|
|
ClientCertFile: "/cert",
|
|
ClientKeyFile: "/key",
|
|
InstanceTransport: &http.Transport{},
|
|
}
|
|
want := git.NewGitCloneExecutorInput{URL: tt.cloneURL, InsecureSkipTLS: tt.insecure}
|
|
if tt.insecure {
|
|
want.Transport = conf.InstanceTransport
|
|
}
|
|
if tt.token {
|
|
want.Token = "token"
|
|
}
|
|
if tt.cert {
|
|
want.ClientCertFile, want.ClientKeyFile = conf.ClientCertFile, conf.ClientKeyFile
|
|
}
|
|
require.Equal(t, want, conf.withInstanceAuth(git.NewGitCloneExecutorInput{URL: tt.cloneURL}, "token"))
|
|
})
|
|
}
|
|
|
|
transport := &http.Transport{}
|
|
conf := Config{GitHubInstance: "https://gitea.local", InstanceTransport: transport}
|
|
require.Same(t, transport, conf.instanceTransportFor("https://gitea.local/api/actions_pipeline/"))
|
|
require.Nil(t, conf.instanceTransportFor("https://gitea.local.example/api/actions_pipeline/"))
|
|
require.Nil(t, conf.instanceTransportFor("https://other.example/x#/api/actions_pipeline/"))
|
|
}
|
|
|
|
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))
|
|
}
|