mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-02 21:03:09 +00:00
Serves `github.actions.results.api.v1.CacheService` next to the v1 cache API, sharing its store, plus the subset of the Azure blob protocol the toolkit uploads with. On by default via `cache.v2`, and works with `external_server`. Clients reach it through two edits in the action's own bundle: the GHES check is opened, and the cache service URL is taken from `ACTIONS_CACHE_URL`. The same GHES check is what makes the stock `actions/upload-artifact` and `download-artifact` abort on Gitea. Opening it makes them work without the `gitea-upload-artifact` fork, from `upload-artifact@v4.4.0` on. Verified against 118 real bundles, every major version of 16 actions: 92 patched, the rest deliberately left alone, and every patched bundle checked with `node --check`. Also end to end against pinned `actions/cache@v6.1.0` with an unreachable results URL, so only the patch can make the cache work. --------- Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: bircni <me@silverwind.io> Reviewed-on: https://gitea.com/gitea/runner/pulls/1110 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
329 lines
15 KiB
Go
329 lines
15 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package runner
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.com/gitea/runner/act/model"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// The three shapes real bundlers emit, reduced to the bytes that matter: the version gate, and
|
|
// the URL getter that follows it. tsc keeps the names, webpack prefixes them, esbuild mangles
|
|
// them, writes ternaries in place of the switch, and records the real name in the export
|
|
// assignment. Each carries both reads of the results URL, as the real getter does.
|
|
const (
|
|
urlTSC = `function getCacheServiceURL() {` + "\n" + ` switch (getCacheServiceVersion()) {` + "\n" + ` case 'v1':` + "\n" + ` return (process.env['ACTIONS_CACHE_URL'] || process.env['ACTIONS_RESULTS_URL'] || '');` + "\n" + ` case 'v2':` + "\n" + ` return process.env['ACTIONS_RESULTS_URL'] || '';` + "\n" + ` }` + "\n" + `}`
|
|
urlEsbuild = `function YK(){let e=XK();return e==="v1"?process.env.ACTIONS_CACHE_URL||process.env.ACTIONS_RESULTS_URL||"":e==="v2"?process.env.ACTIONS_RESULTS_URL||"":""}`
|
|
|
|
isGhesTSC = `function isGhes(){const h=new URL(process.env['GITHUB_SERVER_URL']||'https://github.com').hostname.toUpperCase();return h!=='GITHUB.COM'&&!h.endsWith('.GHE.COM')&&!h.endsWith('.LOCALHOST')}`
|
|
gateTSC = isGhesTSC + "\n" + `function getCacheServiceVersion() {` + "\n" + ` if (isGhes())` + "\n" + ` return 'v1';` + "\n" + ` return process.env['ACTIONS_CACHE_SERVICE_V2'] ? 'v2' : 'v1';` + "\n" + `}` + "\n" + urlTSC
|
|
gateWebpack = `function config_isGhes(){const h=new URL(process.env['GITHUB_SERVER_URL']||'https://github.com').hostname.toUpperCase();return h!=='GITHUB.COM'&&!h.endsWith('.GHE.COM')&&!h.endsWith('.LOCALHOST')}` + "\n" + `function config_getCacheServiceVersion() {` + "\n" + ` if (config_isGhes())` + "\n" + ` return 'v1';` + "\n" + ` return process.env['ACTIONS_CACHE_SERVICE_V2'] ? 'v2' : 'v1';` + "\n" + `}` + "\n" + urlTSC
|
|
gateEsbuild = `vu.isGhes=$K;vu.getCacheServiceVersion=XK;function $K(){let e=new URL(process.env.GITHUB_SERVER_URL||"https://github.com").hostname.toUpperCase(),r=e==="GITHUB.COM",n=e.endsWith(".GHE.COM"),i=e.endsWith(".LOCALHOST");return!r&&!n&&!i}function XK(){return $K()?"v1":process.env.ACTIONS_CACHE_SERVICE_V2?"v2":"v1"}` + urlEsbuild
|
|
)
|
|
|
|
func TestPatchedBundle(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, body string
|
|
wantPatched bool
|
|
}{
|
|
{"tsc keeps the names", gateTSC, true},
|
|
{"webpack prefixes them", gateWebpack, true},
|
|
{"esbuild mangles and minifies them", gateEsbuild, true},
|
|
// A bundler picks its own quoting; gateTSC is single-quoted already.
|
|
{"double-quoted", requoted(`"`), true},
|
|
{"backtick-quoted", requoted("`"), true},
|
|
// sccache-action sets the variable itself; there is no gate to open.
|
|
{"mentions the variable without the gate", `core.exportVariable("ACTIONS_CACHE_SERVICE_V2","on")`, false},
|
|
// Both edits or neither: a gate patched without the URL would send the client to a
|
|
// results URL that serves no cache service.
|
|
{"gate without a recognisable url getter", strings.TrimSuffix(gateTSC, "\n"+urlTSC), false},
|
|
// And the other way round: an action that reads both variables but has no gate to open.
|
|
{"url getter without a gate", urlTSC, false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
out, patched := patchedBundle([]byte(tc.body))
|
|
assert.Equal(t, tc.wantPatched, patched)
|
|
if !tc.wantPatched {
|
|
assert.Equal(t, tc.body, string(out), "an unpatched bundle must come back byte for byte")
|
|
return
|
|
}
|
|
assert.True(t, gateOpened(string(out)))
|
|
// The other two hostname tests are left alone, so a host that really is GitHub or
|
|
// GHES is still recognised as such.
|
|
assert.NotContains(t, string(out), ".LOCALHOST", "the localhost test is the one that opens")
|
|
assert.Contains(t, string(out), ".GHE.COM")
|
|
|
|
// Every read of the results URL now prefers the cache URL, and none was lost: the
|
|
// results URL stays the fallback, so a runner not serving the cache still works.
|
|
assert.Equal(t, strings.Count(tc.body, "ACTIONS_RESULTS_URL"), strings.Count(string(out), "ACTIONS_RESULTS_URL"))
|
|
assert.Equal(t, strings.Count(tc.body, "ACTIONS_RESULTS_URL"),
|
|
strings.Count(string(out), "(process.env.ACTIONS_CACHE_URL||process.env"))
|
|
})
|
|
}
|
|
}
|
|
|
|
// undici, bundled into every one of these actions, decides whether to trust a URL with a
|
|
// lowercase test that reads almost the same. Opening it would tell the HTTP client that every URL
|
|
// is trustworthy, so the uppercase the toolkit produces is what separates them.
|
|
func TestPatchedBundleLeavesTrustworthyURLCheckAlone(t *testing.T) {
|
|
const undici = `if(n.hostname==="localhost"||n.hostname.includes("localhost.")||n.hostname.endsWith(".localhost")){return true}`
|
|
|
|
out, patched := patchedBundle([]byte(undici + gateTSC))
|
|
require.True(t, patched)
|
|
assert.Contains(t, string(out), undici, "the trustworthy-URL check must survive byte for byte")
|
|
assert.True(t, gateOpened(string(out)))
|
|
}
|
|
|
|
// The artifact toolkit puts the same gate in front of a plain refusal, with no URL to move, so
|
|
// opening it is what lets the stock upload-artifact work against Gitea instead of aborting.
|
|
func TestPatchedBundleOpensTheArtifactRefusal(t *testing.T) {
|
|
const artifact = isGhesTSC + "\n" + `uploadArtifact(){if(isGhes()){throw new GHESNotSupportedError()}}`
|
|
|
|
out, patched := patchedBundle([]byte(artifact))
|
|
assert.True(t, patched)
|
|
assert.True(t, gateOpened(string(out)))
|
|
assert.Contains(t, string(out), "GHESNotSupportedError", "the refusal itself is left in place, it just stops firing")
|
|
|
|
// A bundle using the gate for something this runner has not accounted for is not touched.
|
|
unknown := strings.Replace(artifact, "GHESNotSupportedError", "SomeOtherError", 1)
|
|
out, patched = patchedBundle([]byte(unknown))
|
|
assert.False(t, patched)
|
|
assert.Equal(t, unknown, string(out))
|
|
}
|
|
|
|
// requoted respells gateTSC's string literals with another quote character.
|
|
func requoted(quote string) string {
|
|
gate := strings.ReplaceAll(gateTSC, `'.LOCALHOST'`, quote+".LOCALHOST"+quote)
|
|
gate = strings.ReplaceAll(gate, `['ACTIONS_RESULTS_URL']`, "["+quote+"ACTIONS_RESULTS_URL"+quote+"]")
|
|
return strings.ReplaceAll(gate, `['ACTIONS_CACHE_URL']`, "["+quote+"ACTIONS_CACHE_URL"+quote+"]")
|
|
}
|
|
|
|
// gateOpened reports whether the hostname test was emptied, in whatever quoting the bundle used.
|
|
func gateOpened(body string) bool {
|
|
return strings.Contains(body, "endsWith(") && !strings.Contains(body, ".LOCALHOST")
|
|
}
|
|
|
|
// The patched bundle must still be JavaScript, and must resolve the way the runner needs: v2 for
|
|
// an ordinary Gitea host, the cache server for the service URL, and the results URL when there is
|
|
// no cache server. Unpatched, the same bundle must still choose v1, or the patch proves nothing.
|
|
func TestPatchedBundleBehavesInNode(t *testing.T) {
|
|
requireHostTools(t, "node")
|
|
|
|
eval := func(t *testing.T, bundle, prelude, cacheURL string) string {
|
|
t.Helper()
|
|
|
|
script := prelude + bundle + "\nprocess.stdout.write(getCacheServiceVersion()+' '+getCacheServiceURL())"
|
|
cmd := exec.CommandContext(t.Context(), "node", "-e", script)
|
|
cmd.Env = append(os.Environ(),
|
|
"ACTIONS_CACHE_SERVICE_V2=true",
|
|
"ACTIONS_CACHE_URL="+cacheURL,
|
|
"ACTIONS_RESULTS_URL=https://gitea.example",
|
|
"GITHUB_SERVER_URL=https://gitea.example",
|
|
)
|
|
out, err := cmd.CombinedOutput()
|
|
require.NoError(t, err, "%s", out)
|
|
return string(out)
|
|
}
|
|
|
|
for _, tc := range []struct{ name, bundle, prelude string }{
|
|
{"tsc", gateTSC, ""},
|
|
{"webpack", gateWebpack, "const getCacheServiceVersion=()=>config_getCacheServiceVersion();"},
|
|
{"esbuild", gateEsbuild, "var vu={};const getCacheServiceVersion=()=>XK(),getCacheServiceURL=()=>YK();"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Unpatched, a Gitea host is taken for GHES: v1, whose branch already reads the
|
|
// cache URL. The patch has to move the version without moving that.
|
|
assert.Equal(t, "v1 http://cache:8088/", eval(t, tc.bundle, tc.prelude, "http://cache:8088/"))
|
|
|
|
patched, ok := patchedBundle([]byte(tc.bundle))
|
|
require.True(t, ok)
|
|
|
|
assert.Equal(t, "v2 http://cache:8088/", eval(t, string(patched), tc.prelude, "http://cache:8088/"))
|
|
assert.Equal(t, "v2 https://gitea.example", eval(t, string(patched), tc.prelude, ""),
|
|
"with no cache server the results URL is still the fallback")
|
|
})
|
|
}
|
|
}
|
|
|
|
// A bundler that embeds module sources as strings, such as webpack with devtool: eval, carries
|
|
// the gate inside a double-quoted literal. Rewriting the call rather than emptying its argument
|
|
// would end that string early and leave the bundle unparseable.
|
|
func TestPatchedBundleSurvivesInsideAStringLiteral(t *testing.T) {
|
|
requireHostTools(t, "node")
|
|
|
|
escaped := strings.ReplaceAll(gateTSC, `"`, `\"`)
|
|
embedded := `eval("` + strings.ReplaceAll(escaped, "\n", `\n`) + `");`
|
|
out, patched := patchedBundle([]byte(embedded))
|
|
require.True(t, patched)
|
|
|
|
file := filepath.Join(t.TempDir(), "bundle.js")
|
|
require.NoError(t, os.WriteFile(file, out, 0o600))
|
|
checked, err := exec.CommandContext(t.Context(), "node", "--check", file).CombinedOutput()
|
|
require.NoError(t, err, "%s", checked)
|
|
}
|
|
|
|
func TestPatchBundleKeepsTheOriginal(t *testing.T) {
|
|
dir, script := bundleFile(t, gateTSC)
|
|
original := originalFor(dir, script)
|
|
|
|
require.NoError(t, patchBundle(script, original))
|
|
patched, err := os.ReadFile(script)
|
|
require.NoError(t, err)
|
|
assert.True(t, gateOpened(string(patched)))
|
|
|
|
kept, err := os.ReadFile(original)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, gateTSC, string(kept), "the untouched bundle is kept outside the action tree")
|
|
assert.NotContains(t, original, dir+string(filepath.Separator), "originals must not ship into job containers")
|
|
|
|
// Patching again must not stack, and must not overwrite the kept original.
|
|
require.NoError(t, patchBundle(script, original))
|
|
again, err := os.ReadFile(script)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, string(patched), string(again))
|
|
kept, err = os.ReadFile(original)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, gateTSC, string(kept))
|
|
}
|
|
|
|
// A bundle with nothing to patch is left exactly as it was, with no original kept beside it.
|
|
func TestPatchBundleLeavesOtherActionsAlone(t *testing.T) {
|
|
dir, script := bundleFile(t, `console.log("checkout")`)
|
|
original := originalFor(dir, script)
|
|
|
|
require.NoError(t, patchBundle(script, original))
|
|
body, err := os.ReadFile(script)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, `console.log("checkout")`, string(body))
|
|
_, err = os.Stat(original)
|
|
assert.True(t, os.IsNotExist(err), "no original is kept for a bundle that was not patched")
|
|
}
|
|
|
|
// bundleFile writes one entrypoint into a fresh action directory.
|
|
func bundleFile(t *testing.T, body string) (dir, script string) {
|
|
t.Helper()
|
|
|
|
dir = t.TempDir()
|
|
script = filepath.Join(dir, "index.js")
|
|
require.NoError(t, os.WriteFile(script, []byte(body), 0o600))
|
|
return dir, script
|
|
}
|
|
|
|
func TestActionScriptPaths(t *testing.T) {
|
|
node := &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "dist/restore/index.js", Post: "dist/save/index.js"}}
|
|
assert.Equal(t, []string{"/a/dist/restore/index.js", "/a/dist/save/index.js"}, actionScriptPaths("/a", node))
|
|
|
|
// Only a node action has a bundle to patch.
|
|
assert.Nil(t, actionScriptPaths("/a", &model.Action{Runs: model.ActionRuns{Using: "docker", Image: "alpine"}}))
|
|
assert.Nil(t, actionScriptPaths("/a", nil))
|
|
}
|
|
|
|
// A step that fails with a patched bundle gets the untouched bundle back, and the action is not
|
|
// patched again, so later jobs run it exactly as its author shipped it.
|
|
func TestRevertToolkit(t *testing.T) {
|
|
dir, script := bundleFile(t, gateTSC)
|
|
scripts := []string{script}
|
|
|
|
patchToolkit(t.Context(), dir, scripts)
|
|
body, err := os.ReadFile(script)
|
|
require.NoError(t, err)
|
|
require.True(t, gateOpened(string(body)), "precondition: the bundle is patched")
|
|
|
|
revertToolkit(t.Context(), dir, scripts)
|
|
body, err = os.ReadFile(script)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, gateTSC, string(body), "the original bundle is back")
|
|
|
|
// The skip marker survives, so the action stays unpatched from now on.
|
|
patchToolkit(t.Context(), dir, scripts)
|
|
body, err = os.ReadFile(script)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, gateTSC, string(body), "a reverted action stays unpatched")
|
|
}
|
|
|
|
// An action whose ref moves is checked out over the patched bundle. The kept original then
|
|
// belongs to the version before the move, and must not be restored over the new one.
|
|
func TestPatchBundleAfterTheActionMoved(t *testing.T) {
|
|
dir, script := bundleFile(t, gateTSC)
|
|
original := originalFor(dir, script)
|
|
scripts := []string{script}
|
|
|
|
patchToolkit(t.Context(), dir, scripts)
|
|
require.NoError(t, os.WriteFile(script, []byte(gateWebpack), 0o600)) // the new version lands
|
|
|
|
// Reverting must not roll the action back to the version the original came from.
|
|
revertToolkit(t.Context(), dir, scripts)
|
|
body, err := os.ReadFile(script)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, gateWebpack, string(body))
|
|
|
|
// Nothing was reverted, so the action is not marked off either: the new version is patched
|
|
// in its own right, and keeps its own original.
|
|
require.NoFileExists(t, filepath.Join(sidecarDir(dir), skipMarker))
|
|
require.NoError(t, patchBundle(script, original))
|
|
body, err = os.ReadFile(script)
|
|
require.NoError(t, err)
|
|
assert.True(t, gateOpened(string(body)))
|
|
kept, err := os.ReadFile(original)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, gateWebpack, string(kept))
|
|
}
|
|
|
|
// The wiring: a step patches its own bundles only when the runner serves the v2 API, and a step
|
|
// that fails gets them back. The action's path inside its repository is part of where they live.
|
|
func TestStepActionRemoteToolkitPatch(t *testing.T) {
|
|
newStep := func(t *testing.T, env map[string]string) (*stepActionRemote, string) {
|
|
t.Helper()
|
|
|
|
sar := &stepActionRemote{
|
|
Step: &model.Step{Uses: "owner/repo/sub@v1"},
|
|
remoteAction: &remoteAction{Org: "owner", Repo: "repo", Path: "sub", Ref: "v1"},
|
|
action: &model.Action{Runs: model.ActionRuns{Using: "node20", Main: "index.js"}},
|
|
RunContext: &RunContext{
|
|
Env: env,
|
|
Config: &Config{ActionCacheDir: t.TempDir()},
|
|
},
|
|
}
|
|
script := filepath.Join(sar.actionDir(), "sub", "index.js")
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(script), 0o755))
|
|
require.NoError(t, os.WriteFile(script, []byte(gateTSC), 0o600))
|
|
return sar, script
|
|
}
|
|
|
|
t.Run("left alone when the runner does not serve the v2 API", func(t *testing.T) {
|
|
sar, script := newStep(t, map[string]string{})
|
|
require.NoError(t, sar.patchActionToolkit(t.Context()))
|
|
|
|
body, err := os.ReadFile(script)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, gateTSC, string(body))
|
|
})
|
|
|
|
t.Run("patched, and put back when the step fails", func(t *testing.T) {
|
|
sar, script := newStep(t, map[string]string{CacheServiceV2Env: "true"})
|
|
require.NoError(t, sar.patchActionToolkit(t.Context()))
|
|
|
|
body, err := os.ReadFile(script)
|
|
require.NoError(t, err)
|
|
require.True(t, gateOpened(string(body)))
|
|
|
|
failed := errors.New("the step failed")
|
|
require.ErrorIs(t, sar.revertToolkitOnFailure(func(context.Context) error { return failed })(t.Context()), failed)
|
|
|
|
body, err = os.ReadFile(script)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, gateTSC, string(body))
|
|
})
|
|
}
|