mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-07 09:24:22 +02:00
Revert #1136 and use actionslib instead. revert chore: bump the module path to `/v3`, take the version from the VCS stamp (#1136) gitea can not consume the runner's api by version while it's version mismatches the module version: ``` go: gitea.com/gitea/runner@v3.0.1: invalid version: module contains a go.mod file, so module path must match major version ("gitea.com/gitea/runner/v3") ``` Fix that by bumping the module version now. The existing `v3.0.0` and `v3.0.1` tags stay unusable, so a new tag is needed after this lands. Also drop the version `-X` linker flags, which would otherwise have to repeat the new path in both `Makefile` and `.goreleaser.yaml`, where a stale path makes injection silently no-op. Go has recorded the module version in the build info since 1.24, so `Version()` reads it from there, keeping the variable as an override for builds without a VCS stamp. That part started as https://gitea.com/gitea/runner/pulls/1137 but belongs here: the stamp resolves against the tags that are legal for the module path, so without the `/v3` bump it would report `v1.0.9-0.<ts>-<sha>`. Since `release-nightly.yml` triggers on every push to `main`, splitting them would publish a nightly with a `v1` version. --------- Co-authored-by: bircni <bircni@icloud.com> Reviewed-on: https://gitea.com/gitea/runner/pulls/1136 Reviewed-by: techknowlogick <9+techknowlogick@noreply.gitea.com> Reviewed-by: bircni <bircni@icloud.com> Co-authored-by: silverwind <me@silverwind.io> Reviewed-on: https://gitea.com/gitea/runner/pulls/1148 Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com>
369 lines
15 KiB
Go
369 lines
15 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"
|
|
|
|
"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")
|
|
}
|