mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-10-30 20:08:41 +08:00
Without a docker daemon six `act/runner` tests failed instead of passing. A dry run still created and removed job networks, the job teardown dropped the dry-run flag along with every other context value, and the container mocks asked the daemon for the runner arch. This also speeds up the tests and CI without dropping assertions. Most of the time went to git and docker round trips, which are now pinned, batched or coalesced, and the dind and e2e steps cache or overlap more of their setup. On CI, `act/runner` went from 77s to 55s and `test-dind` from 108s to 55s. Locally, `act/runner` without docker went from 53s to 7s. Split out of https://gitea.com/gitea/runner/pulls/1348, which adds Windows CI on top. The go cache changes moved to https://gitea.com/gitea/runner/pulls/1350. Written by Claude Code. --------- Co-authored-by: bircni <bircni@icloud.com> Reviewed-on: https://gitea.com/gitea/runner/pulls/1349 Reviewed-by: bircni <bircni@icloud.com> Co-authored-by: silverwind <me@silverwind.io>
344 lines
12 KiB
Go
344 lines
12 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package runner
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.com/gitea/runner/act/artifactcache"
|
|
|
|
"gitea.dev/actionslib/pkg/model"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const actionsCacheRef = "v6.1.0"
|
|
|
|
func bundleFromGitHub(t *testing.T, repo, ref, path string) string {
|
|
t.Helper()
|
|
|
|
cacheDir, err := os.UserCacheDir()
|
|
require.NoError(t, err)
|
|
dir := filepath.Join(cacheDir, "gitea-runner-test", strings.ReplaceAll(repo, "/", "-")+"-"+ref)
|
|
bundle := filepath.Join(dir, strings.ReplaceAll(path, "/", "-"))
|
|
if _, err := os.Stat(bundle); err == nil {
|
|
return bundle
|
|
}
|
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
|
|
|
url := "https://raw.githubusercontent.com/" + repo + "/" + ref + "/" + path
|
|
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
require.NoError(t, err)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Skipf("cannot reach %s: %v", url, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
require.NotEqual(t, http.StatusNotFound, resp.StatusCode, "%s is not in this release", url)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Skipf("GET %s: %s", url, resp.Status)
|
|
}
|
|
file, err := os.Create(bundle)
|
|
require.NoError(t, err)
|
|
_, err = io.Copy(file, resp.Body)
|
|
require.NoError(t, file.Close())
|
|
require.NoError(t, err)
|
|
return bundle
|
|
}
|
|
|
|
type jobEnv struct {
|
|
workspace, runnerTemp string
|
|
cacheURL, resultsURL string
|
|
token string
|
|
}
|
|
|
|
func newActionJobEnv(t *testing.T, token string, credential artifactcache.JobCredential) jobEnv {
|
|
t.Helper()
|
|
|
|
handler, err := artifactcache.StartHandler(artifactcache.Options{Dir: filepath.Join(t.TempDir(), "cache"), OutboundIP: "127.0.0.1"})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = handler.Close() })
|
|
t.Cleanup(handler.RegisterJob(token, credential))
|
|
return jobEnv{
|
|
workspace: tempDirPath(t),
|
|
runnerTemp: tempDirPath(t),
|
|
cacheURL: handler.ExternalURL(),
|
|
resultsURL: handler.ExternalURL(),
|
|
token: token,
|
|
}
|
|
}
|
|
|
|
func runActionEntrypoint(t *testing.T, script string, env jobEnv, inputs map[string]string) string {
|
|
t.Helper()
|
|
state := filepath.Join(env.runnerTemp, "state")
|
|
output := filepath.Join(env.runnerTemp, "output")
|
|
for _, name := range []string{state, output} {
|
|
require.NoError(t, os.WriteFile(name, nil, 0o600))
|
|
}
|
|
|
|
cmd := exec.CommandContext(t.Context(), "node", script)
|
|
cmd.Dir = env.workspace
|
|
cmd.Env = append(os.Environ(),
|
|
"ACTIONS_RUNTIME_TOKEN="+env.token,
|
|
"ACTIONS_CACHE_URL="+env.cacheURL+"/",
|
|
"ACTIONS_RESULTS_URL="+env.resultsURL,
|
|
"ACTIONS_CACHE_SERVICE_V2=true",
|
|
"GITHUB_SERVER_URL=https://gitea.example.com",
|
|
"GITHUB_REPOSITORY=testuser/testrepo",
|
|
"GITHUB_RUN_ID=1",
|
|
"GITHUB_REF=refs/heads/main",
|
|
"GITHUB_EVENT_NAME=push",
|
|
"GITHUB_WORKSPACE="+env.workspace,
|
|
"RUNNER_TEMP="+env.runnerTemp,
|
|
"GITHUB_STATE="+state,
|
|
"GITHUB_OUTPUT="+output,
|
|
)
|
|
for name, value := range inputs {
|
|
cmd.Env = append(cmd.Env, "INPUT_"+strings.ToUpper(name)+"="+value)
|
|
}
|
|
out, err := cmd.CombinedOutput()
|
|
t.Logf("%s:\n%s", filepath.Base(script), out)
|
|
require.NoError(t, err, "%s failed", script)
|
|
return string(out)
|
|
}
|
|
|
|
func patchedAction(t *testing.T, repo, ref, entrypoint string) string {
|
|
t.Helper()
|
|
|
|
body, err := os.ReadFile(bundleFromGitHub(t, repo, ref, entrypoint))
|
|
require.NoError(t, err)
|
|
if patched, ok := patchedBundle(body); ok {
|
|
body = patched
|
|
}
|
|
script := filepath.Join(tempDirPath(t), filepath.Base(entrypoint))
|
|
require.NoError(t, os.WriteFile(script, body, 0o600))
|
|
return script
|
|
}
|
|
|
|
func tempDirPath(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
dir, err := filepath.EvalSymlinks(t.TempDir())
|
|
require.NoError(t, err)
|
|
return dir
|
|
}
|
|
|
|
func TestCacheServiceV2EndToEnd(t *testing.T) {
|
|
t.Parallel()
|
|
requireHostTools(t, "node")
|
|
|
|
restore := patchedAction(t, "actions/cache", actionsCacheRef, "dist/restore/index.js")
|
|
save := patchedAction(t, "actions/cache", actionsCacheRef, "dist/save/index.js")
|
|
|
|
env := newActionJobEnv(t, "e2e-runtime-token", artifactcache.JobCredential{Repo: "testuser/testrepo"})
|
|
require.NoError(t, os.MkdirAll(filepath.Join(env.workspace, "to-cache"), 0o755))
|
|
content := []byte("cached through the patched gate")
|
|
require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "to-cache", "data.txt"), content, 0o600))
|
|
|
|
const key = "patched-gate-key"
|
|
inputs := map[string]string{"path": "to-cache", "key": key}
|
|
|
|
missed := runActionEntrypoint(t, restore, env, inputs)
|
|
require.Contains(t, missed, "Cache service version: v2", "the patch did not take, the client stayed on v1")
|
|
require.Contains(t, missed, "Cache not found for input keys: "+key)
|
|
|
|
saved := runActionEntrypoint(t, save, env, inputs)
|
|
require.Contains(t, saved, "Cache saved with key: "+key)
|
|
|
|
env.workspace = tempDirPath(t)
|
|
hit := runActionEntrypoint(t, restore, env, inputs)
|
|
require.Contains(t, hit, "Cache restored from key: "+key)
|
|
|
|
got, err := os.ReadFile(filepath.Join(env.workspace, "to-cache", "data.txt"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, content, got)
|
|
env.workspace = tempDirPath(t)
|
|
v1 := runActionEntrypoint(t, bundleFromGitHub(t, "actions/cache", actionsCacheRef, "dist/restore/index.js"), env, inputs)
|
|
require.Contains(t, v1, "Cache service version: v1")
|
|
require.Contains(t, v1, "Cache restored from key: "+key)
|
|
}
|
|
|
|
func TestPatchedBundleAcrossActions(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
repo, ref, path string
|
|
wantPatched bool
|
|
}{
|
|
{"actions/cache", actionsCacheRef, "dist/restore/index.js", true},
|
|
{"actions/setup-node", "v7.1.0", "dist/cache-save/index.js", true},
|
|
{"actions/setup-python", "v7.0.0", "dist/setup/index.js", true},
|
|
{"actions/setup-go", "v7.0.0", "dist/setup/index.js", true},
|
|
{"actions/setup-java", "v5.7.0", "dist/setup/index.js", true},
|
|
{"actions/setup-java", "v6.1.0", "dist/setup/295.index.js", true},
|
|
{"ruby/setup-ruby", "v1.331.0", "dist/index.js", true},
|
|
{"pnpm/action-setup", "v6.1.0", "dist/index.js", true},
|
|
{"oven-sh/setup-bun", "v2.2.0", "dist/setup/index.js", true},
|
|
{"Swatinem/rust-cache", "v2.9.1", "dist/restore/index.js", true},
|
|
{"Swatinem/rust-cache", "v2.9.2", "dist/cache-CTfQoCTL.js", true},
|
|
{"docker/build-push-action", "v7.4.0", "dist/index.cjs", true},
|
|
{"gradle/actions", "v6.4.0", "dist/setup-gradle/main/index.js", true},
|
|
{"actions/upload-artifact", "v4.4.0", "dist/upload/index.js", true},
|
|
{"actions/upload-artifact", "v7.0.2", "dist/upload/index.js", true},
|
|
{"actions/download-artifact", "v8.0.2", "dist/index.js", true},
|
|
{"actions/checkout", "v7.0.1", "dist/index.js", false},
|
|
{"mozilla-actions/sccache-action", "v0.0.11", "dist/setup/index.js", false},
|
|
} {
|
|
t.Run(tc.repo+"@"+tc.ref, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
data, err := os.ReadFile(bundleFromGitHub(t, tc.repo, tc.ref, tc.path))
|
|
require.NoError(t, err)
|
|
|
|
out, patched := patchedBundle(data)
|
|
require.Equal(t, tc.wantPatched, patched)
|
|
if !tc.wantPatched {
|
|
assert.Equal(t, data, out, "an untouched bundle must come back byte for byte")
|
|
return
|
|
}
|
|
assert.NotContains(t, string(out), ".LOCALHOST", "a copy of the gate was missed")
|
|
|
|
if !strings.Contains(string(data), CacheServiceV2Env) {
|
|
return
|
|
}
|
|
assert.NotZero(t, strings.Count(string(out), "(process.env."+cacheURLEnv+"||process.env"),
|
|
"the cache service URL was not redirected")
|
|
assert.Equal(t, strings.Count(string(data), resultsURLEnv), strings.Count(string(out), resultsURLEnv),
|
|
"a read of the results URL was lost, it must stay as the fallback")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUploadArtifactThroughTheResultsService(t *testing.T) {
|
|
t.Parallel()
|
|
requireHostTools(t, "node")
|
|
|
|
var called []string
|
|
var zipped []byte
|
|
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
method := path.Base(r.URL.Path)
|
|
called = append(called, method)
|
|
w.Header().Set("x-ms-request-id", "stub")
|
|
switch method {
|
|
case "CreateArtifact":
|
|
_, _ = io.WriteString(w, `{"ok":true,"signed_upload_url":"http://`+r.Host+
|
|
`/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact?sig=x"}`)
|
|
case "FinalizeArtifact":
|
|
_, _ = io.WriteString(w, `{"ok":true,"artifact_id":"1"}`)
|
|
case "ListArtifacts":
|
|
_, _ = io.WriteString(w, `{"artifacts":[{"workflow_run_backend_id":"11",`+
|
|
`"workflow_job_run_backend_id":"22","database_id":"1","name":"an-artifact","size":"`+
|
|
strconv.Itoa(len(zipped))+`"}]}`)
|
|
case "GetSignedArtifactURL":
|
|
_, _ = io.WriteString(w, `{"signed_url":"http://`+r.Host+`/download"}`)
|
|
case "download":
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
_, _ = w.Write(zipped)
|
|
default:
|
|
body, _ := io.ReadAll(r.Body)
|
|
switch r.URL.Query().Get("comp") {
|
|
case "block":
|
|
zipped = append(zipped, body...)
|
|
case "blocklist":
|
|
default:
|
|
zipped = body
|
|
}
|
|
w.WriteHeader(http.StatusCreated)
|
|
}
|
|
}))
|
|
defer gitea.Close()
|
|
env := newActionJobEnv(t, "e30."+base64.RawURLEncoding.EncodeToString([]byte(`{"scp":"Actions.Results:11:22"}`))+".sig",
|
|
artifactcache.JobCredential{Repo: "testuser/testrepo", Results: gitea.URL})
|
|
|
|
upload := patchedAction(t, "actions/upload-artifact", "v7.0.2", "dist/upload/index.js")
|
|
|
|
uploaded := []byte("through the results service")
|
|
require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "artifact.txt"), uploaded, 0o600))
|
|
|
|
out := runActionEntrypoint(t, upload, env, map[string]string{
|
|
"name": "an-artifact", "path": "artifact.txt", "if-no-files-found": "error",
|
|
"retention-days": "0", "compression-level": "6", "overwrite": "false",
|
|
"include-hidden-files": "false", "archive": "true",
|
|
})
|
|
|
|
require.Contains(t, out, "has been successfully uploaded")
|
|
download := patchedAction(t, "actions/download-artifact", "v8.0.2", "dist/index.js")
|
|
env.workspace = tempDirPath(t)
|
|
out = runActionEntrypoint(t, download, env, map[string]string{
|
|
"name": "an-artifact", "path": "downloaded", "merge-multiple": "false",
|
|
"skip-decompress": "false", "include-hidden-files": "false", "github-token": "",
|
|
})
|
|
|
|
require.Contains(t, out, "Artifact download completed")
|
|
assert.Subset(t, called,
|
|
[]string{"CreateArtifact", "UploadArtifact", "FinalizeArtifact", "ListArtifacts", "GetSignedArtifactURL"},
|
|
"the artifact service was not reached through the cache server")
|
|
got, err := os.ReadFile(filepath.Join(env.workspace, "downloaded", "artifact.txt"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, uploaded, got)
|
|
}
|
|
|
|
func TestSetupActionFindsTheCacheService(t *testing.T) {
|
|
t.Parallel()
|
|
requireHostTools(t, "node", "npm")
|
|
|
|
setup := patchedAction(t, "actions/setup-node", "v7.1.0", "dist/setup/index.js")
|
|
|
|
env := newActionJobEnv(t, "setup-runtime-token", artifactcache.JobCredential{Repo: "testuser/testrepo"})
|
|
require.NoError(t, os.WriteFile(filepath.Join(env.workspace, "package-lock.json"),
|
|
[]byte(`{"lockfileVersion":3}`), 0o600))
|
|
|
|
out := runActionEntrypoint(t, setup, env, map[string]string{"cache": "npm"})
|
|
|
|
require.Contains(t, out, "Cache service version: v2")
|
|
require.Contains(t, out, "npm cache is not found", "the lookup did not reach the cache server")
|
|
}
|
|
|
|
func TestCodeSplitActionFindsTheCacheService(t *testing.T) {
|
|
requireHostTools(t, "node", "rustc")
|
|
|
|
dir := tempDirPath(t)
|
|
for _, name := range []string{
|
|
"action.yml", "dist/restore.js", "dist/save.js", "dist/cleanup-BWEbZ6YT.js",
|
|
"dist/cache-CTfQoCTL.js", "dist/cache-DC26rUaF.js", "dist/state-cjs-CkvFUaNy.js",
|
|
} {
|
|
body, err := os.ReadFile(bundleFromGitHub(t, "Swatinem/rust-cache", "v2.9.2", name))
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, name), body, 0o600))
|
|
}
|
|
body, err := os.ReadFile(filepath.Join(dir, "action.yml"))
|
|
require.NoError(t, err)
|
|
action, err := model.ReadAction(strings.NewReader(string(body)))
|
|
require.NoError(t, err)
|
|
inputs := map[string]string{"lookup-only": "true", "add-rust-environment-hash-key": "false", "cache-bin": "false"}
|
|
for name, input := range action.Inputs {
|
|
if _, ok := inputs[name]; !ok {
|
|
inputs[name] = input.Default
|
|
}
|
|
}
|
|
t.Setenv("CARGO_HOME", tempDirPath(t))
|
|
|
|
for _, entry := range patchedScripts(t.Context(), dir, actionScriptPaths(dir, dir, action)) {
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, entry.Name), []byte(entry.Body), 0o600))
|
|
}
|
|
out := runActionEntrypoint(t, filepath.Join(dir, action.Runs.Main),
|
|
newActionJobEnv(t, "split-runtime-token", artifactcache.JobCredential{Repo: "testuser/testrepo"}), inputs)
|
|
|
|
require.Contains(t, out, "Cache service version: v2")
|
|
require.Contains(t, out, "No cache found.", "the lookup did not reach the cache server")
|
|
}
|