mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-08-06 00:44:22 +02:00
feat!: add cache service v2, add toolkit patches (#1110)
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>
This commit is contained in:
@@ -595,8 +595,8 @@ func actionStagePaths(step actionStep) (actionDir, actionPath, actionName, conta
|
||||
rc := step.getRunContext()
|
||||
stepModel := step.getStepModel()
|
||||
|
||||
if _, ok := step.(*stepActionRemote); ok {
|
||||
actionDir = fmt.Sprintf("%s/%s", rc.ActionCacheDir(), stepModel.UsesHash())
|
||||
if sar, ok := step.(*stepActionRemote); ok {
|
||||
actionDir = sar.actionDir()
|
||||
actionPath = newRemoteAction(stepModel.Uses).Path
|
||||
} else {
|
||||
actionDir = filepath.Join(rc.Config.Workdir, stepModel.Uses)
|
||||
|
||||
@@ -69,6 +69,8 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
||||
github.Token = sar.RunContext.Config.ReplaceGheActionTokenWithGithubCom
|
||||
}
|
||||
}
|
||||
// Actions served from the action cache are read out of a git object store rather than a
|
||||
// directory, so they never reach the bundle patch below and keep to the v1 cache API.
|
||||
if sar.RunContext.Config.ActionCache != nil {
|
||||
cache := sar.RunContext.Config.ActionCache
|
||||
|
||||
@@ -112,7 +114,7 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
||||
return err
|
||||
}
|
||||
|
||||
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
||||
actionDir := sar.actionDir()
|
||||
defaultActionURL := sar.RunContext.Config.DefaultActionURL()
|
||||
// For Gitea
|
||||
// A composite RunContext nils Config.Secrets, so getGitCloneToken would yield an
|
||||
@@ -171,6 +173,9 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
||||
sar.action = actionModel
|
||||
return err
|
||||
},
|
||||
// A stage of its own: it takes the same clone lock, and it has to land before
|
||||
// runAction copies the action into the job container.
|
||||
sar.patchActionToolkit,
|
||||
)(ctx)
|
||||
}
|
||||
}
|
||||
@@ -189,7 +194,7 @@ func (sar *stepActionRemote) pre() common.Executor {
|
||||
|
||||
return common.NewPipelineExecutor(
|
||||
sar.prepareActionExecutor(),
|
||||
runStepExecutor(sar, stepStagePre, runPreStep(sar)).If(hasPreStep(sar)).If(shouldRunPreStep(sar)))
|
||||
runStepExecutor(sar, stepStagePre, sar.revertToolkitOnFailure(runPreStep(sar))).If(hasPreStep(sar)).If(shouldRunPreStep(sar)))
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) main() common.Executor {
|
||||
@@ -211,15 +216,51 @@ func (sar *stepActionRemote) main() common.Executor {
|
||||
return sar.RunContext.JobContainer.CopyDir(copyToPath, sar.RunContext.Config.Workdir+string(filepath.Separator)+".", sar.RunContext.Config.UseGitIgnore)(ctx)
|
||||
}
|
||||
|
||||
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
||||
actionDir := sar.actionDir()
|
||||
|
||||
return sar.runAction(sar, actionDir, sar.remoteAction)(ctx)
|
||||
return sar.revertToolkitOnFailure(sar.runAction(sar, actionDir, sar.remoteAction))(ctx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) post() common.Executor {
|
||||
return runStepExecutor(sar, stepStagePost, runPostStep(sar)).If(hasPostStep(sar)).If(shouldRunPostStep(sar))
|
||||
return runStepExecutor(sar, stepStagePost, sar.revertToolkitOnFailure(runPostStep(sar))).If(hasPostStep(sar)).If(shouldRunPostStep(sar))
|
||||
}
|
||||
|
||||
// toolkitBundles is the action directory and the entrypoints the toolkit may live in.
|
||||
func (sar *stepActionRemote) toolkitBundles() (string, []string) {
|
||||
if sar.remoteAction == nil {
|
||||
return "", nil
|
||||
}
|
||||
dir := sar.actionDir()
|
||||
return dir, actionScriptPaths(filepath.Join(dir, sar.remoteAction.Path), sar.action)
|
||||
}
|
||||
|
||||
// patchActionToolkit edits the bundled toolkit so it works against Gitea, which lets the cache
|
||||
// client use the v2 API this runner serves. A no-op unless the runner serves it.
|
||||
func (sar *stepActionRemote) patchActionToolkit(ctx context.Context) error {
|
||||
if sar.RunContext.GetEnv()[CacheServiceV2Env] != "" {
|
||||
dir, scripts := sar.toolkitBundles()
|
||||
patchToolkit(ctx, dir, scripts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// revertToolkitOnFailure restores the untouched bundles when the action fails, so a later job
|
||||
// runs it as shipped rather than repeating a failure the patch may have caused.
|
||||
func (sar *stepActionRemote) revertToolkitOnFailure(exec common.Executor) common.Executor {
|
||||
return func(ctx context.Context) error {
|
||||
err := exec(ctx)
|
||||
if err != nil {
|
||||
dir, scripts := sar.toolkitBundles()
|
||||
revertToolkit(ctx, dir, scripts)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) actionDir() string {
|
||||
return fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
||||
}
|
||||
|
||||
func (sar *stepActionRemote) getRunContext() *RunContext {
|
||||
@@ -270,7 +311,7 @@ func (sar *stepActionRemote) getActionModel() *model.Action {
|
||||
|
||||
func (sar *stepActionRemote) getCompositeRunContext(ctx context.Context) *RunContext {
|
||||
if sar.compositeRunContext == nil {
|
||||
actionDir := fmt.Sprintf("%s/%s", sar.RunContext.ActionCacheDir(), sar.Step.UsesHash())
|
||||
actionDir := sar.actionDir()
|
||||
actionLocation := path.Join(actionDir, sar.remoteAction.Path)
|
||||
_, containerActionDir := getContainerActionPaths(sar.getStepModel(), actionLocation, sar.RunContext)
|
||||
|
||||
|
||||
262
act/runner/toolkit_patch.go
Normal file
262
act/runner/toolkit_patch.go
Normal file
@@ -0,0 +1,262 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gitea.com/gitea/runner/act/common"
|
||||
"gitea.com/gitea/runner/act/common/git"
|
||||
"gitea.com/gitea/runner/act/model"
|
||||
)
|
||||
|
||||
// Actions bundle the @actions toolkit into their own JavaScript, and two of its lines keep it
|
||||
// from working against Gitea. Both are edited out of the bundle the runner downloaded.
|
||||
//
|
||||
// isGhes() takes any host that is not github.com, *.ghe.com or *.localhost for GitHub
|
||||
// Enterprise. @actions/cache then forces the v1 API, and @actions/artifact refuses outright,
|
||||
// which is why the stock upload-artifact aborts here. The edit empties the last of the three
|
||||
// hostname tests, so `endsWith('.LOCALHOST')` becomes `endsWith(”)`, which every hostname
|
||||
// satisfies: one string literal, no call sites to resolve, and the same answer the toolkit's own
|
||||
// proposed ACTIONS_VENDOR switch would give. Gitea already makes this edit by hand in its fork
|
||||
// of upload-artifact.
|
||||
//
|
||||
// getCacheServiceURL() then resolves the cache service from ACTIONS_RESULTS_URL alone, where v1
|
||||
// reads ACTIONS_CACHE_URL first. Both reads there are given the same preference, which is what
|
||||
// keeps the runner out of the artifact path: the results URL still points at Gitea.
|
||||
//
|
||||
// Either of these landing upstream makes this file deletable:
|
||||
//
|
||||
// https://github.com/actions/toolkit/pull/2123 — an ACTIONS_VENDOR switch, naming Gitea
|
||||
// https://github.com/actions/toolkit/issues/2439 — treat ACTIONS_RESULTS_URL as the signal
|
||||
const (
|
||||
CacheServiceV2Env = "ACTIONS_CACHE_SERVICE_V2"
|
||||
cacheURLEnv = "ACTIONS_CACHE_URL"
|
||||
resultsURLEnv = "ACTIONS_RESULTS_URL"
|
||||
|
||||
// localhostHost is the suffix isGhes accepts; emptying the test is what opens the gate,
|
||||
// because every hostname ends with the empty string.
|
||||
localhostHost = ".LOCALHOST"
|
||||
|
||||
// artifactRefusal is the only thing the gate guards in @actions/artifact, which is what makes
|
||||
// such a bundle safe to open. A bundle carrying neither toolkit uses isGhes for something this
|
||||
// runner has not looked at, and is left alone.
|
||||
artifactRefusal = "GHESNotSupportedError"
|
||||
|
||||
// sidecarSuffix names the directory of untouched copies, a sibling of the action directory
|
||||
// because that directory is copied wholesale into job containers.
|
||||
sidecarSuffix = ".toolkit-patch"
|
||||
|
||||
// skipMarker in the sidecar means a patched bundle already failed once here.
|
||||
skipMarker = "skip"
|
||||
|
||||
maxBundleSize = 64 << 20
|
||||
)
|
||||
|
||||
var (
|
||||
// localhostTest matches the third hostname test of isGhes, in any quoting. The match is case
|
||||
// sensitive on purpose, and that is load-bearing: isGhes uppercases the hostname before
|
||||
// testing it, while undici, bundled into all of these actions, tests a lowercase ".localhost"
|
||||
// in isURLPotentiallyTrustworthy. Opening that one would tell its HTTP client that every URL
|
||||
// is trustworthy. Uppercase, the literal occurs nowhere but this test, across 118 bundles
|
||||
// covering every major version of sixteen actions.
|
||||
localhostTest = regexp.MustCompile(`endsWith\s*\(\s*` + quoted(regexp.QuoteMeta(localhostHost)) + `\s*\)`)
|
||||
|
||||
// serviceURLBranches matches both branches of getCacheServiceURL at once: the v1 branch reads
|
||||
// the cache URL and falls back to the results URL, the v2 branch just below reads the results
|
||||
// URL alone. That `||` pairing is the only place the two variables are read together, so
|
||||
// matching them as one expression is what keeps the edit inside this function rather than
|
||||
// anywhere they happen to sit near each other. The branches are 21 bytes apart minified and
|
||||
// 63 not, across every bundle measured.
|
||||
serviceURLBranches = regexp.MustCompile(`(` + envRead(cacheURLEnv) + `\s*\|\|\s*)(` +
|
||||
envRead(resultsURLEnv) + `)((?s).{0,256}?)(` + envRead(resultsURLEnv) + `)`)
|
||||
|
||||
// cacheURLFirst gives both reads the preference the v1 branch already had.
|
||||
cacheURLFirst = []byte(`${1}(process.env.` + cacheURLEnv + `||${2})${3}(process.env.` + cacheURLEnv + `||${4})`)
|
||||
)
|
||||
|
||||
func envRead(name string) string {
|
||||
return `process\s*\.\s*env\s*(?:\.\s*` + name + `\b|\[\s*` + quoted(name) + `\s*\])`
|
||||
}
|
||||
|
||||
// quoted matches a string literal in any of the three quote characters. RE2 has no
|
||||
// backreferences, so the pairs are spelled out.
|
||||
func quoted(pattern string) string {
|
||||
return "(?:'" + pattern + "'|\"" + pattern + "\"|`" + pattern + "`)"
|
||||
}
|
||||
|
||||
// actionScriptPaths returns the entrypoints of a node action, the only kind with a bundle. Only
|
||||
// remote actions get here: a local one lives in the user's checkout, which the runner does not
|
||||
// rewrite.
|
||||
func actionScriptPaths(dir string, action *model.Action) []string {
|
||||
if action == nil || !action.Runs.Using.IsNode() {
|
||||
return nil
|
||||
}
|
||||
var paths []string
|
||||
for _, script := range []string{action.Runs.Pre, action.Runs.Main, action.Runs.Post} {
|
||||
if script != "" {
|
||||
paths = append(paths, filepath.Join(dir, script))
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// patchToolkit edits the toolkit in an action's bundles, keeping each original beside them. Every
|
||||
// failure is silent and leaves the bundle as it was, which costs the cache client the v2 API and
|
||||
// an artifact action nothing at all.
|
||||
func patchToolkit(ctx context.Context, actionDir string, scripts []string) {
|
||||
if len(scripts) == 0 {
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(sidecarDir(actionDir), skipMarker)); err == nil {
|
||||
return
|
||||
}
|
||||
defer git.AcquireCloneLock(actionDir)()
|
||||
|
||||
for _, script := range scripts {
|
||||
if err := patchBundle(script, originalFor(actionDir, script)); err != nil {
|
||||
common.Logger(ctx).Debugf("actions toolkit: %s left unpatched: %v", filepath.Base(script), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// revertToolkit puts the originals back and stops this action being patched again, so the next job
|
||||
// runs it exactly as shipped. Called when a step failed with a patched bundle; it does not re-run
|
||||
// the step, because a step's outputs and env-file writes are already recorded by then.
|
||||
func revertToolkit(ctx context.Context, actionDir string, scripts []string) {
|
||||
if len(scripts) == 0 {
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(sidecarDir(actionDir)); err != nil {
|
||||
return
|
||||
}
|
||||
defer git.AcquireCloneLock(actionDir)()
|
||||
|
||||
reverted := false
|
||||
for _, script := range scripts {
|
||||
original := originalFor(actionDir, script)
|
||||
if !isPatchOf(original, script) {
|
||||
continue
|
||||
}
|
||||
if err := os.Rename(original, script); err == nil {
|
||||
reverted = true
|
||||
}
|
||||
}
|
||||
if reverted {
|
||||
_ = os.WriteFile(filepath.Join(sidecarDir(actionDir), skipMarker), nil, 0o600)
|
||||
common.Logger(ctx).Warnf("actions toolkit: restored the original %s, it will not be patched again", filepath.Base(actionDir))
|
||||
}
|
||||
}
|
||||
|
||||
// sidecarDir holds an action's untouched bundles, and the marker that stops it being patched.
|
||||
func sidecarDir(actionDir string) string {
|
||||
return actionDir + sidecarSuffix
|
||||
}
|
||||
|
||||
// originalFor is where a script's untouched copy lives, or "" for a script the action's own
|
||||
// `runs` keys placed outside its directory, which is not this runner's to rewrite.
|
||||
func originalFor(actionDir, script string) string {
|
||||
rel, err := filepath.Rel(actionDir, script)
|
||||
if err != nil || strings.HasPrefix(rel, "..") {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(sidecarDir(actionDir), rel)
|
||||
}
|
||||
|
||||
// patchBundle rewrites one entrypoint in place. The untouched copy kept beside it is what marks
|
||||
// the bundle as already patched.
|
||||
func patchBundle(script, original string) error {
|
||||
if original == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(original); err == nil {
|
||||
if isPatchOf(original, script) {
|
||||
return nil
|
||||
}
|
||||
// The action's ref moved and git checked the new bundle out over the patched one, so
|
||||
// the pair no longer belongs together. Patch afresh rather than keep an original that
|
||||
// would restore an older version of the action.
|
||||
if err := os.Remove(original); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
info, err := os.Stat(script)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Size() > maxBundleSize {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(script)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
patched, ok := patchedBundle(data)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(original), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// The copy is taken before the bundle is replaced, so a write that fails part way can put the
|
||||
// action back as it was. A crash needs no handling: the clone executor checks the action out
|
||||
// and hard resets it on every prepare, so a half-written bundle never outlives the job.
|
||||
if err := os.WriteFile(original, data, info.Mode().Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(script, patched, info.Mode().Perm()); err != nil {
|
||||
_ = os.Rename(original, script)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isPatchOf reports whether script is exactly what patching original produced. It is what proves
|
||||
// the two still belong together: an action whose ref moved is checked out over the patched bundle,
|
||||
// leaving an original that would restore the version before the move.
|
||||
func isPatchOf(original, script string) bool {
|
||||
data, err := os.ReadFile(original)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
current, err := os.ReadFile(script)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
patched, ok := patchedBundle(data)
|
||||
return ok && bytes.Equal(patched, current)
|
||||
}
|
||||
|
||||
// patchedBundle opens the GHES gate, and where the cache toolkit is present, points the cache
|
||||
// service at the cache server. A bundle this runner cannot account for comes back untouched.
|
||||
func patchedBundle(data []byte) ([]byte, bool) {
|
||||
if !localhostTest.Match(data) {
|
||||
return data, false
|
||||
}
|
||||
switch {
|
||||
case bytes.Contains(data, []byte(CacheServiceV2Env)):
|
||||
// The cache toolkit: both edits or neither, because choosing v2 without redirecting the
|
||||
// URL would send the client to a results URL that serves no cache service.
|
||||
if !serviceURLBranches.Match(data) {
|
||||
return data, false
|
||||
}
|
||||
case bytes.Contains(data, []byte(artifactRefusal)):
|
||||
// The artifact toolkit, where the gate is a plain refusal and there is no URL to move:
|
||||
// artifacts already go to Gitea, which implements that service.
|
||||
default:
|
||||
return data, false
|
||||
}
|
||||
|
||||
opened := localhostTest.ReplaceAllFunc(data, func(test []byte) []byte {
|
||||
// Drop the hostname from the test rather than rewriting the call, so the bundle's own
|
||||
// quoting survives and the result stays valid even inside a string literal.
|
||||
return bytes.Replace(test, []byte(localhostHost), nil, 1)
|
||||
})
|
||||
return serviceURLBranches.ReplaceAll(opened, cacheURLFirst), true
|
||||
}
|
||||
196
act/runner/toolkit_patch_e2e_test.go
Normal file
196
act/runner/toolkit_patch_e2e_test.go
Normal file
@@ -0,0 +1,196 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"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
|
||||
}
|
||||
|
||||
// runCacheAction runs one entrypoint the way a job would: a real Gitea server URL, and a results
|
||||
// URL that points at Gitea rather than at the runner. Nothing about the environment is rewritten,
|
||||
// so only the patch can make the client choose v2 and find the cache server.
|
||||
func runCacheAction(t *testing.T, script, workspace, runnerTemp, cacheURL, token, key string) string {
|
||||
t.Helper()
|
||||
state := filepath.Join(runnerTemp, "state")
|
||||
output := filepath.Join(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 = workspace
|
||||
cmd.Env = append(os.Environ(),
|
||||
"INPUT_PATH=to-cache",
|
||||
"INPUT_KEY="+key,
|
||||
"ACTIONS_RUNTIME_TOKEN="+token,
|
||||
"ACTIONS_CACHE_URL="+cacheURL+"/",
|
||||
// Unreachable on purpose: the artifact service lives here, the cache service must not.
|
||||
"ACTIONS_RESULTS_URL=https://gitea.example",
|
||||
"ACTIONS_CACHE_SERVICE_V2=true",
|
||||
"GITHUB_SERVER_URL=https://gitea.example.com",
|
||||
"GITHUB_REF=refs/heads/main",
|
||||
"GITHUB_EVENT_NAME=push",
|
||||
"GITHUB_WORKSPACE="+workspace,
|
||||
"RUNNER_TEMP="+runnerTemp,
|
||||
"GITHUB_STATE="+state,
|
||||
"GITHUB_OUTPUT="+output,
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
t.Logf("%s:\n%s", filepath.Base(filepath.Dir(script)), out)
|
||||
require.NoError(t, err, "%s failed", script)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
// A stand-in action directory, patched exactly as a downloaded one would be.
|
||||
actionDir := tempDirPath(t)
|
||||
scripts := map[string]string{}
|
||||
for _, stage := range []string{"restore", "save"} {
|
||||
body, err := os.ReadFile(bundleFromGitHub(t, "actions/cache", actionsCacheRef, "dist/"+stage+"/index.js"))
|
||||
require.NoError(t, err)
|
||||
scripts[stage] = filepath.Join(actionDir, stage+".js")
|
||||
require.NoError(t, os.WriteFile(scripts[stage], body, 0o600))
|
||||
}
|
||||
patchToolkit(t.Context(), actionDir, []string{scripts["restore"], scripts["save"]})
|
||||
|
||||
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, repo)
|
||||
|
||||
workspace, runnerTemp := tempDirPath(t), tempDirPath(t)
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(workspace, "to-cache"), 0o755))
|
||||
content := []byte("cached through the patched gate")
|
||||
require.NoError(t, os.WriteFile(filepath.Join(workspace, "to-cache", "data.txt"), content, 0o600))
|
||||
|
||||
const key = "patched-gate-key"
|
||||
missed := runCacheAction(t, scripts["restore"], workspace, runnerTemp, handler.ExternalURL(), token, key)
|
||||
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 := runCacheAction(t, scripts["save"], workspace, runnerTemp, handler.ExternalURL(), token, key)
|
||||
require.Contains(t, saved, "Cache saved with key: "+key)
|
||||
|
||||
restored := tempDirPath(t)
|
||||
hit := runCacheAction(t, scripts["restore"], restored, runnerTemp, handler.ExternalURL(), token, key)
|
||||
require.Contains(t, hit, "Cache restored from key: "+key)
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(restored, "to-cache", "data.txt"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, content, got)
|
||||
}
|
||||
|
||||
// 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 }{
|
||||
{"actions/setup-go", "v7.0.0", "dist/setup/index.js"},
|
||||
{"actions/setup-node", "v6.0.0", "dist/cache-save/index.js"},
|
||||
{"actions/setup-python", "v6.0.0", "dist/setup/index.js"},
|
||||
{"ruby/setup-ruby", "v1.271.0", "dist/index.js"},
|
||||
{"pnpm/action-setup", "v6.0.9", "dist/index.js"},
|
||||
// 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"},
|
||||
{"actions/upload-artifact", "v7.0.1", "dist/upload/index.js"},
|
||||
{"actions/download-artifact", "v6.0.0", "dist/index.js"},
|
||||
{"oven-sh/setup-bun", "v2.2.0", "dist/setup/index.js"},
|
||||
} {
|
||||
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)
|
||||
assert.True(t, patched, "the version gate was not patched")
|
||||
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")
|
||||
})
|
||||
}
|
||||
}
|
||||
328
act/runner/toolkit_patch_test.go
Normal file
328
act/runner/toolkit_patch_test.go
Normal file
@@ -0,0 +1,328 @@
|
||||
// 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))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user