mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-11-02 05:16:47 +08:00
`gitea-runner exec --no-skip-checkout` panics with a nil pointer dereference on any job that calls a local reusable workflow (`uses: ./.gitea/workflows/x.yml`). Fetching a local reusable workflow from the instance was gated on `NoSkipCheckout`, but it reads the repository and SHA from the task's preset context, which only the daemon has. `exec` always has the workflow on disk, so the fetch is now gated on the preset instead, and `--no-skip-checkout` only governs checkout again. Daemon behavior is unchanged. The `exec` e2e test now also runs the existing reusable caller/callee pair with `--no-skip-checkout`. Reviewed-on: https://gitea.com/gitea/runner/pulls/1362 Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: silverwind <me@silverwind.io>
222 lines
8.5 KiB
Go
222 lines
8.5 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) {
|
|
t.Parallel()
|
|
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{Env: testCheckoutEnv()},
|
|
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, NoSkipCheckout: true},
|
|
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{
|
|
Env: testCheckoutEnv(),
|
|
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:pass@gitea.example.net/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:pass@gitea.my-nas.lan/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))
|
|
}
|