mirror of
https://gitea.com/gitea/act_runner
synced 2026-11-03 10:57:22 +01:00
Removes dead code and redundant logic and consolidates tests, so there is less to read and maintain. No behavior change. 1. **act/runner**: inline single-use wrappers, compile regexes once instead of per call, embed the trampoline as a string, drop the unused legacy result of `parseActionCommand` 2. **act/container**: `sanitizeConfig` and `mergeContainerConfigs` stop returning the configs they already mutate in place, drop never-set option fields and the unused `user` parameter of `ExecWithCmdLine` 3. **act/common and cache server**: drop the unused `Trace`/`Tracef` loggers and the short SHA of `FindGitRevision`, `RegisterJob` releases through `RevokeJob` 4. **daemon, poller and reporter**: one shutdown wait for both poll modes, drop an unreachable fetch timeout branch, guard the reporter's last report time with its existing lock instead of an atomic 5. **build**: drop unused Makefile and release workflow variables, collapse the goreleaser ignore list to the same build targets 6. **tests**: fold near-duplicate tests into tables with shared setup Throughout, hand-rolled logic gives way to `cmp.Or`, `strings.Cut`, `slices` and `min`/`max`. Reviewed-on: https://gitea.com/gitea/runner/pulls/1335 Reviewed-by: bircni <bircni@icloud.com> Co-authored-by: silverwind <me@silverwind.io>
337 lines
12 KiB
Go
337 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)
|
|
script := filepath.Join(tempDirPath(t), filepath.Base(entrypoint))
|
|
require.NoError(t, os.WriteFile(script, body, 0o600))
|
|
patchActions(t.Context(), []string{script})
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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))
|
|
|
|
defer patchActions(t.Context(), actionScriptPaths(dir, dir, action))()
|
|
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")
|
|
}
|