mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-14 06:51:52 +02:00
refactor: move act under internal
The runner is an application, not a library. `act/model` and `act/exprparser` were the last packages anything outside this repository consumed and they now live in actionslib, so nothing needs the rest of `act` to be importable, and keeping it importable invites the coupling that was just removed. Import paths only, the files are unchanged. Assisted-by: Codet:GPT-5.1-Codex
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
// 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/internal/act/artifactcache"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// actionsCacheRef pins the actions/cache release this is verified against. Bump it
|
||||
// deliberately: a new release is exactly what can stop the patch matching.
|
||||
const actionsCacheRef = "v6.1.0"
|
||||
|
||||
// bundleFromGitHub downloads one entrypoint, keeping it in the user cache dir so repeated runs
|
||||
// cost nothing. The bundles are megabytes, too large to vendor.
|
||||
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()
|
||||
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
|
||||
}
|
||||
|
||||
// jobEnv is the environment a job gets from this runner, which is what decides where an action's
|
||||
// toolkit looks for the cache and artifact services.
|
||||
type jobEnv struct {
|
||||
workspace, runnerTemp string
|
||||
cacheURL, resultsURL string
|
||||
token string
|
||||
}
|
||||
|
||||
// runActionEntrypoint runs one action entrypoint the way a job would. Adding another action to these tests
|
||||
// means downloading its entrypoint with bundleFromGitHub and calling this with its inputs, whose
|
||||
// names are the ones the action's own action.yml uses.
|
||||
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)
|
||||
}
|
||||
|
||||
// patchedAction downloads one entrypoint and patches it exactly as a downloaded action would be,
|
||||
// keeping the untouched original in the sidecar beside it.
|
||||
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)
|
||||
dir := tempDirPath(t)
|
||||
script := filepath.Join(dir, filepath.Base(entrypoint))
|
||||
require.NoError(t, os.WriteFile(script, body, 0o600))
|
||||
patchToolkit(t.Context(), dir, []string{script})
|
||||
return script
|
||||
}
|
||||
|
||||
// tempDirPath is TempDir with symlinks resolved, because macOS hands out /var paths that resolve
|
||||
// to /private/var and the client derives archive paths relative to the workspace.
|
||||
func tempDirPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
return dir
|
||||
}
|
||||
|
||||
// The whole chain against the pinned release, whose bundles ship unminified: patch them, run the
|
||||
// real client with an ordinary Gitea server URL and a results URL that goes nowhere, and have it
|
||||
// save and restore through this runner's cache server. The unreachable results URL is the point,
|
||||
// it is what proves the cache reaches the runner without the runner fronting Gitea. If a release
|
||||
// stops matching the patch the client falls back to v1 and this fails on the version line, which
|
||||
// is the signal to look at the new bundle.
|
||||
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")
|
||||
|
||||
handler, err := artifactcache.StartHandler(filepath.Join(t.TempDir(), "cache"), "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = handler.Close() })
|
||||
const token, repo = "e2e-runtime-token", "testuser/testrepo"
|
||||
handler.RegisterJob(token, artifactcache.JobCredential{Repo: repo})
|
||||
|
||||
env := jobEnv{
|
||||
workspace: tempDirPath(t),
|
||||
runnerTemp: tempDirPath(t),
|
||||
cacheURL: handler.ExternalURL(),
|
||||
// The results service is the cache server's too, which is what the runner advertises.
|
||||
resultsURL: handler.ExternalURL(),
|
||||
token: token,
|
||||
}
|
||||
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)
|
||||
|
||||
// Untouched, the same client takes a Gitea host for GHES and stays on v1, which reaches the
|
||||
// cache server on its own address. That is what a runner without a results service of its own
|
||||
// leaves its jobs with, so it has to round trip too.
|
||||
env.workspace = tempDirPath(t)
|
||||
v1 := runActionEntrypoint(t, filepath.Join(sidecarDir(filepath.Dir(restore)), "index.js"), env, inputs)
|
||||
require.Contains(t, v1, "Cache service version: v1")
|
||||
require.Contains(t, v1, "Cache restored from key: "+key)
|
||||
}
|
||||
|
||||
// The gate and the URL getter are separate functions, and a bundler may put either first: the gap
|
||||
// between them runs from 159 to 1179 bytes across these actions, which is why neither edit is
|
||||
// anchored on that distance. One entrypoint from each of the families that bundle the cache
|
||||
// toolkit, patched but not run, is what keeps a future release from quietly matching only one of
|
||||
// the two shapes and leaving every cache on v1.
|
||||
func TestToolkitPatchAcrossActions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
repo, ref, path string
|
||||
wantPatched bool
|
||||
}{
|
||||
// The cache toolkit, in each bundler shape and from a spread of ecosystems, including the
|
||||
// actions that drive a Go or a Rust cache client of their own.
|
||||
{"actions/cache", actionsCacheRef, "dist/restore/index.js", true},
|
||||
{"actions/setup-node", "v7.0.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},
|
||||
{"ruby/setup-ruby", "v1.321.0", "dist/index.js", true},
|
||||
{"pnpm/action-setup", "v6.0.9", "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},
|
||||
{"docker/build-push-action", "v7.3.0", "dist/index.cjs", true},
|
||||
// The artifact toolkit, where the gate is a refusal and there is nothing to redirect.
|
||||
// v4.4.0 is the first release whose gate carries the localhost test this matches; the
|
||||
// releases before it refuse in a shape the runner leaves alone.
|
||||
{"actions/upload-artifact", "v4.4.0", "dist/upload/index.js", true},
|
||||
{"actions/upload-artifact", "v7.0.1", "dist/upload/index.js", true},
|
||||
{"actions/download-artifact", "v8.0.1", "dist/index.js", true},
|
||||
// Neither toolkit's gate, so these have to come back byte for byte. sccache-action is the
|
||||
// one that exports ACTIONS_CACHE_SERVICE_V2 itself, for the Rust client it installs.
|
||||
{"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 // the artifact toolkit: a refusal to open, and no URL to move
|
||||
}
|
||||
// Only the reads inside getCacheServiceURL are rewritten. The others, such as the
|
||||
// feature-availability check, must be left as they are.
|
||||
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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The stock artifact actions refuse on a Gitea host until the gate is opened, and then they talk
|
||||
// to the results service, which is this runner's cache server forwarding the artifact half on to
|
||||
// Gitea. Running the real upload-artifact against a stand-in Gitea covers both halves at once:
|
||||
// the patch, and the forwarding the job's registration set up.
|
||||
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: // the zip on its way up, in the blocks the Azure protocol puts it in
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
switch r.URL.Query().Get("comp") {
|
||||
case "block":
|
||||
zipped = append(zipped, body...)
|
||||
case "blocklist": // the ordering document, not content
|
||||
default:
|
||||
zipped = body
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
}))
|
||||
defer gitea.Close()
|
||||
|
||||
handler, err := artifactcache.StartHandler(filepath.Join(t.TempDir(), "cache"), "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = handler.Close() })
|
||||
// The artifact client decodes the runtime token for the run ids it puts in its requests, where
|
||||
// the cache client only presents it, so this one has to be shaped like Gitea's.
|
||||
token := "e30." + base64.RawURLEncoding.EncodeToString([]byte(`{"scp":"Actions.Results:11:22"}`)) + ".sig"
|
||||
defer handler.RegisterJob(token, artifactcache.JobCredential{Repo: "testuser/testrepo", Results: gitea.URL})()
|
||||
|
||||
upload := patchedAction(t, "actions/upload-artifact", "v7.0.1", "dist/upload/index.js")
|
||||
|
||||
env := jobEnv{
|
||||
workspace: tempDirPath(t),
|
||||
runnerTemp: tempDirPath(t),
|
||||
cacheURL: handler.ExternalURL(),
|
||||
resultsURL: handler.ExternalURL(),
|
||||
token: token,
|
||||
}
|
||||
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")
|
||||
|
||||
// And back down again: listing and downloading go the same way, and the signed URL the
|
||||
// artifact service hands out is fetched straight from it.
|
||||
download := patchedAction(t, "actions/download-artifact", "v8.0.1", "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)
|
||||
}
|
||||
|
||||
// The setup actions carry the same toolkit and reach the same service, from a key of their own
|
||||
// making. setup-node is the cheapest of them to run: given a lockfile and no version to install,
|
||||
// it does the cache lookup and nothing else.
|
||||
func TestSetupActionFindsTheCacheService(t *testing.T) {
|
||||
requireHostTools(t, "node", "npm")
|
||||
|
||||
setup := patchedAction(t, "actions/setup-node", "v7.0.0", "dist/setup/index.js")
|
||||
|
||||
handler, err := artifactcache.StartHandler(filepath.Join(t.TempDir(), "cache"), "127.0.0.1", 0, "", nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = handler.Close() })
|
||||
const token = "setup-runtime-token"
|
||||
defer handler.RegisterJob(token, artifactcache.JobCredential{Repo: "testuser/testrepo"})()
|
||||
|
||||
env := jobEnv{
|
||||
workspace: tempDirPath(t),
|
||||
runnerTemp: tempDirPath(t),
|
||||
cacheURL: handler.ExternalURL(),
|
||||
resultsURL: handler.ExternalURL(),
|
||||
token: token,
|
||||
}
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user