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:
bircni
2026-07-31 12:08:44 +00:00
parent 2398d4a527
commit 47d5b5ad03
18 changed files with 1585 additions and 75 deletions

View File

@@ -158,12 +158,13 @@ func StartHandler(dir, outboundIP string, port uint16, internalSecret string, lo
router.POST(apiPath+"/clean", h.bearerAuth(h.clean))
// Artifact GET is signed via query-string HMAC because @actions/cache
// does not attach Authorization when downloading archiveLocation.
router.GET(apiPath+"/artifacts/:id", h.signedURLAuth(h.get))
router.GET(apiPath+"/artifacts/:id", h.signedAuth("", h.get))
// Control-plane: a remote runner registers/revokes per-job tokens so the
// cache API can authenticate them. Always wired so the routes exist; the
// handlers themselves 401 when internalSecret is unset.
router.POST(internalPath+"/register", h.internalAuth(h.internalRegister))
router.POST(internalPath+"/revoke", h.internalAuth(h.internalRevoke))
h.registerV2Routes(router)
h.router = router
@@ -339,7 +340,7 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
}
defer db.Close()
cache, err := findCache(db, cred.Repo, keys, version)
cache, err := h.lookupCache(db, cred.Repo, keys, version)
if err != nil {
h.responseJSON(w, r, 500, err)
return
@@ -348,15 +349,6 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
h.responseJSON(w, r, 204)
return
}
if ok, err := h.storage.Exist(cache.ID); err != nil {
h.responseJSON(w, r, 500, err)
return
} else if !ok {
_ = db.Delete(cache.ID, cache)
h.responseJSON(w, r, 204)
return
}
h.responseJSON(w, r, 200, map[string]any{
"result": "hit",
"archiveLocation": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
@@ -364,6 +356,25 @@ func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Para
})
}
// lookupCache returns the entry to restore for these keys, or (nil, nil) when there is none:
// either nothing matched, or the match had lost its blob to a prune, in which case the dangling
// entry is dropped on the way out.
func (h *Handler) lookupCache(db *bolthold.Store, repo string, keys []string, version string) (*Cache, error) {
cache, err := findCache(db, repo, keys, version)
if err != nil || cache == nil {
return nil, err
}
ok, err := h.storage.Exist(cache.ID)
if err != nil {
return nil, err
}
if !ok {
_ = db.Delete(cache.ID, cache)
return nil, nil //nolint:nilnil // absence is not an error here
}
return cache, nil
}
// POST /_apis/artifactcache/caches
func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
cred := credFromContext(r.Context())
@@ -438,7 +449,7 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request, params httprout
h.responseJSON(w, r, 500, err)
return
}
h.useCache(id)
_ = h.touchCache(uint64(id), false)
h.responseJSON(w, r, 200)
}
@@ -479,23 +490,7 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
db.Close()
size, err := h.storage.Commit(cache.ID, cache.Size)
if err != nil {
h.responseJSON(w, r, 500, err)
return
}
// write real size back to cache, it may be different from the current value when the request doesn't specify it.
cache.Size = size
db, err = h.openDB()
if err != nil {
h.responseJSON(w, r, 500, err)
return
}
defer db.Close()
cache.Complete = true
if err := db.Update(cache.ID, cache); err != nil {
if err := h.commitCache(cache); err != nil {
h.responseJSON(w, r, 500, err)
return
}
@@ -503,8 +498,28 @@ func (h *Handler) commit(w http.ResponseWriter, r *http.Request, params httprout
h.responseJSON(w, r, 200)
}
// commitCache assembles the uploaded parts and marks the entry complete. The caller must
// have closed its store first: Commit concatenates the whole archive and would otherwise
// hold bolt's exclusive file lock for the duration.
func (h *Handler) commitCache(cache *Cache) error {
written, err := h.storage.Commit(cache.ID, cache.Size)
if err != nil {
return err
}
// write real size back to cache, it may be different from the current value when the request doesn't specify it.
cache.Size = written
cache.Complete = true
db, err := h.openDB()
if err != nil {
return err
}
defer db.Close()
return db.Update(cache.ID, cache)
}
// GET /_apis/artifactcache/artifacts/:id
// Authenticated via signed URL (see signedURLAuth), not bearer, because the
// Authenticated via signed URL (see signedAuth), not bearer, because the
// @actions/cache toolkit downloads archiveLocation without Authorization.
// Repository scoping is already enforced at find() time; the signature binds
// the URL to the specific cache ID and an expiry.
@@ -514,7 +529,7 @@ func (h *Handler) get(w http.ResponseWriter, r *http.Request, params httprouter.
h.responseJSON(w, r, 400, err)
return
}
h.useCache(id)
_ = h.touchCache(uint64(id), false)
h.storage.Serve(w, r, uint64(id))
}
@@ -548,7 +563,9 @@ func (h *Handler) bearerAuth(handler httprouter.Handle) httprouter.Handle {
}
}
func (h *Handler) signedURLAuth(handler httprouter.Handle) httprouter.Handle {
// signedAuth authenticates a signed URL. purpose separates the flavours of URL the
// handler hands out, so one cannot be replayed as another; see computeSignature.
func (h *Handler) signedAuth(purpose string, handler httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
h.logger.Debugf("%s %s", r.Method, r.URL.Path)
id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
@@ -571,7 +588,7 @@ func (h *Handler) signedURLAuth(handler httprouter.Handle) httprouter.Handle {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("signature expired"))
return
}
expected := h.computeSignature(id, exp)
expected := h.computeSignature(purpose, id, exp)
if !hmac.Equal([]byte(sig), []byte(expected)) {
h.responseJSON(w, r, http.StatusUnauthorized, errors.New("bad signature"))
return
@@ -655,19 +672,26 @@ func credFromContext(ctx context.Context) JobCredential {
return JobCredential{}
}
func (h *Handler) computeSignature(cacheID, exp int64) string {
// computeSignature signs a URL for one cache entry and expiry. purpose is mixed into the
// message so a URL handed out for writing an entry cannot be replayed to read one, and the
// other way round. Downloads use the empty purpose, the message v1 has always signed.
func (h *Handler) computeSignature(purpose string, cacheID, exp int64) string {
mac := hmac.New(sha256.New, h.secret)
fmt.Fprintf(mac, "%d:%d", cacheID, exp)
fmt.Fprintf(mac, "%s%d:%d", purpose, cacheID, exp)
return hex.EncodeToString(mac.Sum(nil))
}
func (h *Handler) signedArtifactURL(cacheID uint64, exp time.Time) string {
// signedURL builds a URL under path that signedAuth accepts for the same purpose.
func (h *Handler) signedURL(path, purpose string, cacheID uint64, exp time.Time) string {
expUnix := exp.Unix()
sig := h.computeSignature(int64(cacheID), expUnix)
q := url.Values{}
q.Set("exp", strconv.FormatInt(expUnix, 10))
q.Set("sig", sig)
return fmt.Sprintf("%s%s/artifacts/%d?%s", h.ExternalURL(), apiPath, cacheID, q.Encode())
q.Set("sig", h.computeSignature(purpose, int64(cacheID), expUnix))
return fmt.Sprintf("%s%s/%d?%s", h.ExternalURL(), path, cacheID, q.Encode())
}
func (h *Handler) signedArtifactURL(cacheID uint64, exp time.Time) string {
return h.signedURL(apiPath+"/artifacts", "", cacheID, exp)
}
// if not found, return (nil, nil) instead of an error.
@@ -675,16 +699,12 @@ func findCache(db *bolthold.Store, repo string, keys []string, version string) (
cache := &Cache{}
for _, prefix := range keys {
// if a key in the list matches exactly, don't return partial matches
if err := db.FindOne(cache,
bolthold.Where("Repo").Eq(repo).
And("Key").Eq(prefix).
And("Version").Eq(version).
And("Complete").Eq(true).
SortBy("CreatedAt").Reverse()); err == nil || !errors.Is(err, bolthold.ErrNotFound) {
if err != nil {
return nil, fmt.Errorf("find cache: %w", err)
}
return cache, nil
exact, err := findExactCache(db, repo, prefix, version, true)
if err != nil {
return nil, err
}
if exact != nil {
return exact, nil
}
prefixPattern := "^" + regexp.QuoteMeta(prefix)
re, err := regexp.Compile(prefixPattern)
@@ -707,6 +727,34 @@ func findCache(db *bolthold.Store, repo string, keys []string, version string) (
return nil, nil //nolint:nilnil // pre-existing issue from nektos/act
}
// findExactCache returns the entry for exactly this key and version, or (nil, nil) if there is
// none. Unlike findCache it never falls back to a prefix (restore-key) match, which is what both
// its callers need: a new key that is only a prefix of an existing key is not the same entry.
//
// A completed entry is the one to restore, sorted by when it was written. An incomplete one is a
// reservation being uploaded to, sorted by when it was last written to, because the upload route
// touches UsedAt on every part.
func findExactCache(db *bolthold.Store, repo, key, version string, complete bool) (*Cache, error) {
sortBy := "UsedAt"
if complete {
sortBy = "CreatedAt"
}
cache := &Cache{}
err := db.FindOne(cache,
bolthold.Where("Repo").Eq(repo).
And("Key").Eq(key).
And("Version").Eq(version).
And("Complete").Eq(complete).
SortBy(sortBy).Reverse())
if errors.Is(err, bolthold.ErrNotFound) {
return nil, nil //nolint:nilnil // absence is not an error here
}
if err != nil {
return nil, fmt.Errorf("find cache: %w", err)
}
return cache, nil
}
func insertCache(db *bolthold.Store, cache *Cache) error {
if err := db.Insert(bolthold.NextSequence(), cache); err != nil {
return fmt.Errorf("insert cache: %w", err)
@@ -718,18 +766,30 @@ func insertCache(db *bolthold.Store, cache *Cache) error {
return nil
}
func (h *Handler) useCache(id int64) {
// touchCache stamps UsedAt so gcCache does not reap an entry mid-upload. With requireIncomplete
// it also refuses an entry that is already complete, which is what the v2 blob route needs: its
// upload URL outlives the finalize call, and overwriting a finished entry would leave the blob
// other jobs restore no longer matching its recorded size. An entry missing from the store is
// accepted, since the signature proves the id was handed out.
func (h *Handler) touchCache(id uint64, requireIncomplete bool) error {
db, err := h.openDB()
if err != nil {
return
return err
}
defer db.Close()
cache := &Cache{}
if err := db.Get(id, cache); err != nil {
return
if errors.Is(err, bolthold.ErrNotFound) {
return nil
}
return err
}
if requireIncomplete && cache.Complete {
return fmt.Errorf("cache %d: already complete", id)
}
cache.UsedAt = time.Now().Unix()
_ = db.Update(cache.ID, cache)
return db.Update(cache.ID, cache)
}
const (

View File

@@ -1041,14 +1041,14 @@ func TestHandler_SecretPersistsAcrossRestarts(t *testing.T) {
first, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
require.NoError(t, err)
exp := time.Now().Add(artifactURLTTL).Unix()
sig := first.computeSignature(42, exp)
sig := first.computeSignature("", 42, exp)
require.NoError(t, first.Close())
second, err := StartHandler(dir, "127.0.0.1", 0, "", nil)
require.NoError(t, err)
defer second.Close()
assert.Equal(t, sig, second.computeSignature(42, exp))
assert.Equal(t, sig, second.computeSignature("", 42, exp))
}
// TestHandler_ArtifactSignatureDownload is a happy-path round trip that

View File

@@ -0,0 +1,268 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifactcache
import (
"cmp"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/julienschmidt/httprouter"
)
// The cache service v2 API. A client on this version talks twirp to
// `github.actions.results.api.v1.CacheService` instead of the /_apis/artifactcache
// endpoints, and uploads the archive to the returned URL with the Azure blob protocol.
// Both API versions are served from the same store, so a repository keeps its cache
// when a workflow moves between action versions.
const (
cacheServiceV2Path = "/twirp/github.actions.results.api.v1.CacheService"
// blobPath authenticates by signature, because the client uploads without an
// Authorization header. Downloads are handed the v1 artifact URL instead.
blobPath = apiPath + "/blobs"
// blobUploadPurpose keeps an upload URL from being replayed to read an entry.
blobUploadPurpose = "upload:"
blobUploadURLTTL = time.Hour
// twirpInternal is the only error code that is not the client's fault.
twirpInternal = "internal"
)
func (h *Handler) registerV2Routes(router *httprouter.Router) {
router.POST(cacheServiceV2Path+"/CreateCacheEntry", h.bearerAuth(h.v2CreateCacheEntry))
router.POST(cacheServiceV2Path+"/FinalizeCacheEntryUpload", h.bearerAuth(h.v2FinalizeCacheEntryUpload))
router.POST(cacheServiceV2Path+"/GetCacheEntryDownloadURL", h.bearerAuth(h.v2GetCacheEntryDownloadURL))
router.PUT(blobPath+"/:id", h.signedAuth(blobUploadPurpose, h.v2UploadBlob))
}
// An entry that already exists is reported as not ok, which is how the client learns to skip
// the upload.
func (h *Handler) v2CreateCacheEntry(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
cred := credFromContext(r.Context())
req, err := decodeTwirpRequest[v2CreateRequest](r)
if err != nil {
h.twirpError(w, r, "malformed_request", err)
return
}
if req.Key == "" || req.Version == "" {
h.twirpError(w, r, "invalid_argument", errors.New("key and version are required"))
return
}
db, err := h.openDB()
if err != nil {
h.twirpError(w, r, twirpInternal, err)
return
}
defer db.Close()
// An exact (key, version) match means the entry is already cached; the client then skips
// the upload. A prefix match must not count here, or a shorter key would be reported as
// existing and silently never saved.
if existing, err := findExactCache(db, cred.Repo, req.Key, req.Version, true); err != nil {
h.twirpError(w, r, twirpInternal, err)
return
} else if existing != nil {
h.twirpNotOK(w, r)
return
}
now := time.Now().Unix()
cache := &Cache{
Repo: cred.Repo,
Key: req.Key,
Version: req.Version,
Size: -1, // the size is only known at finalize time
CreatedAt: now,
UsedAt: now,
}
if err := insertCache(db, cache); err != nil {
h.twirpError(w, r, twirpInternal, err)
return
}
h.responseJSON(w, r, http.StatusOK, map[string]any{
"ok": true,
"signedUploadUrl": h.signedURL(blobPath, blobUploadPurpose, cache.ID, time.Now().Add(blobUploadURLTTL)),
})
}
func (h *Handler) v2FinalizeCacheEntryUpload(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
cred := credFromContext(r.Context())
req, err := decodeTwirpRequest[v2FinalizeRequest](r)
if err != nil {
h.twirpError(w, r, "malformed_request", err)
return
}
db, err := h.openDB()
if err != nil {
h.twirpError(w, r, twirpInternal, err)
return
}
defer db.Close()
cache, err := findExactCache(db, cred.Repo, req.Key, req.Version, false)
if err != nil {
h.twirpError(w, r, twirpInternal, err)
return
}
if cache == nil {
h.twirpNotOK(w, r)
return
}
db.Close() // commitCache needs the store closed
cache.Size, _ = cmp.Or(req.SizeBytes, req.SizeBytesCamel).Int64()
if err := h.commitCache(cache); err != nil {
h.logger.Errorf("finalize cache %d (%s): %v", cache.ID, cache.Key, err)
h.twirpNotOK(w, r)
return
}
h.responseJSON(w, r, http.StatusOK, map[string]any{
"ok": true,
// int64 fields travel as strings in the proto JSON mapping.
"entryId": strconv.FormatUint(cache.ID, 10),
})
}
func (h *Handler) v2GetCacheEntryDownloadURL(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
cred := credFromContext(r.Context())
req, err := decodeTwirpRequest[v2DownloadRequest](r)
if err != nil {
h.twirpError(w, r, "malformed_request", err)
return
}
db, err := h.openDB()
if err != nil {
h.twirpError(w, r, twirpInternal, err)
return
}
defer db.Close()
cache, err := h.lookupCache(db, cred.Repo, req.keys(), req.Version)
if err != nil {
h.twirpError(w, r, twirpInternal, err)
return
}
if cache == nil {
h.twirpNotOK(w, r)
return
}
h.responseJSON(w, r, http.StatusOK, map[string]any{
"ok": true,
"signedDownloadUrl": h.signedArtifactURL(cache.ID, time.Now().Add(artifactURLTTL)),
"matchedKey": cache.Key,
})
}
// The archive arrives over the subset of the Azure blob API the toolkit uses: a small
// cache is a single PUT, a large one is staged as blocks that a final block list puts
// in order.
func (h *Handler) v2UploadBlob(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
id, err := strconv.ParseUint(params.ByName("id"), 10, 64)
if err != nil {
h.responseJSON(w, r, http.StatusBadRequest, err)
return
}
if err := h.touchCache(id, true); err != nil {
h.responseJSON(w, r, http.StatusBadRequest, err)
return
}
query := r.URL.Query()
switch strings.ToLower(query.Get("comp")) {
case "block":
blockID := query.Get("blockid")
if blockID == "" {
h.responseJSON(w, r, http.StatusBadRequest, errors.New("missing blockid"))
return
}
err = h.storage.WriteBlock(id, blockID, r.Body)
case "blocklist":
var list struct{ Latest []string }
if err := xml.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&list); err != nil {
h.responseJSON(w, r, http.StatusBadRequest, fmt.Errorf("malformed block list: %w", err))
return
}
err = h.storage.OrderBlocks(id, list.Latest)
default:
err = h.storage.Write(id, 0, r.Body)
}
if err != nil {
h.responseJSON(w, r, http.StatusInternalServerError, err)
return
}
w.WriteHeader(http.StatusCreated)
}
// twirpNotOK is the negative answer all three endpoints share: no such entry to restore, no
// reservation to finalize, or an entry that already exists and need not be uploaded again.
func (h *Handler) twirpNotOK(w http.ResponseWriter, r *http.Request) {
h.responseJSON(w, r, http.StatusOK, map[string]any{"ok": false})
}
// twirpError reports in the shape a twirp client expects, so the toolkit surfaces the message
// instead of a parse error.
func (h *Handler) twirpError(w http.ResponseWriter, r *http.Request, code string, err error) {
h.logger.Debugf("%s %s: %v", r.Method, r.URL.Path, err)
status := http.StatusBadRequest
if code == twirpInternal {
status = http.StatusInternalServerError
}
h.responseJSON(w, r, status, map[string]any{"code": code, "msg": err.Error()})
}
// The twirp request bodies. The toolkit's client serialises with useProtoFieldName, so the proto
// names are what arrive; the camelCase spellings of the same mapping are accepted too, as are
// int64s sent as a bare number rather than the string the mapping prescribes.
type (
v2CreateRequest struct {
Key string `json:"key"`
Version string `json:"version"`
}
v2FinalizeRequest struct {
Key string `json:"key"`
Version string `json:"version"`
SizeBytes json.Number `json:"size_bytes"`
SizeBytesCamel json.Number `json:"sizeBytes"`
}
v2DownloadRequest struct {
Key string `json:"key"`
Version string `json:"version"`
RestoreKeys []string `json:"restore_keys"`
RestoreKeysCamel []string `json:"restoreKeys"`
}
)
func (d v2DownloadRequest) keys() []string {
restoreKeys := d.RestoreKeys
if len(restoreKeys) == 0 {
restoreKeys = d.RestoreKeysCamel
}
return append([]string{d.Key}, restoreKeys...)
}
func decodeTwirpRequest[T any](r *http.Request) (T, error) {
var req T
err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req)
return req, err
}

View File

@@ -0,0 +1,236 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package artifactcache
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// v2Call posts a twirp request to the cache service and returns the decoded response.
// Field names are the proto ones, which is what the toolkit's client sends.
func v2Call(t *testing.T, handler *Handler, client *http.Client, method string, request any) map[string]any {
t.Helper()
body, err := json.Marshal(request)
require.NoError(t, err)
resp, err := client.Post(handler.ExternalURL()+cacheServiceV2Path+"/"+method, "application/json", bytes.NewReader(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
got := map[string]any{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
return got
}
// putBlob uploads to a signed URL and returns the status, so a test can assert a refusal.
func putBlob(t *testing.T, url string, content []byte) int {
t.Helper()
req, err := http.NewRequestWithContext(t.Context(), http.MethodPut, url, bytes.NewReader(content))
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
return resp.StatusCode
}
func getURL(t *testing.T, url string) []byte {
t.Helper()
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, url, nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
return body
}
func startTestHandler(t *testing.T) *Handler {
t.Helper()
handler, err := StartHandler(filepath.Join(t.TempDir(), "artifactcache"), "127.0.0.1", 0, "", nil)
require.NoError(t, err)
t.Cleanup(func() { _ = handler.Close() })
handler.RegisterJob(testToken, testRepo)
return handler
}
// saveV2 runs the reserve/upload/finalize sequence and returns the finalize response along
// with the upload URL it used.
func saveV2(t *testing.T, handler *Handler, key, version string, content []byte) (finalized map[string]any, uploadURL string) {
t.Helper()
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": key, "version": version})
require.Equal(t, true, created["ok"])
uploadURL, _ = created["signedUploadUrl"].(string)
require.NotEmpty(t, uploadURL)
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL, content))
return v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
"key": key, "version": version,
"size_bytes": strconv.Itoa(len(content)),
}), uploadURL
}
// The whole round trip an actions/cache v2 client makes, plus the guarantees on the signed
// URLs it is handed: unsigned requests are refused, an upload URL cannot be replayed to read
// or to replace a finalized entry.
func TestCacheServiceV2RoundTrip(t *testing.T) {
handler := startTestHandler(t)
content := []byte("the cached archive")
unsigned := fmt.Sprintf("%s%s/1", handler.ExternalURL(), blobPath)
assert.Equal(t, http.StatusUnauthorized, putBlob(t, unsigned, content))
finalized, uploadURL := saveV2(t, handler, "deps-v1", "abc123", content)
require.Equal(t, true, finalized["ok"])
assert.NotEmpty(t, finalized["entryId"])
// The upload URL outlives the finalize call, so replaying it must not poison the entry,
// and it is an upload URL only: nothing reads a blob back through it.
assert.Equal(t, http.StatusBadRequest, putBlob(t, uploadURL, []byte("poisoned")))
resp, err := http.Get(uploadURL) //nolint:noctx // the URL is the server under test
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-v1", "version": "abc123"})
require.Equal(t, true, got["ok"])
assert.Equal(t, "deps-v1", got["matchedKey"])
downloadURL, _ := got["signedDownloadUrl"].(string)
require.NotEmpty(t, downloadURL)
assert.Equal(t, content, getURL(t, downloadURL))
}
// A large archive is staged as blocks and only put in order by the final block list, so
// blocks that arrive out of order must still be assembled the way the client asked.
func TestCacheServiceV2BlockUpload(t *testing.T) {
handler := startTestHandler(t)
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "blocks", "version": "v1"})
uploadURL, _ := created["signedUploadUrl"].(string)
require.NotEmpty(t, uploadURL)
blocks := map[string][]byte{}
var order []string
for i, part := range []string{"hello ", "world", "!"} {
blockID := base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "block-%d", i))
blocks[blockID] = []byte(part)
order = append(order, blockID)
}
// Upload in an order that is not the block list order.
for _, blockID := range []string{order[2], order[0], order[1]} {
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=block&blockid="+blockID, blocks[blockID]))
}
var list bytes.Buffer
list.WriteString(`<?xml version="1.0" encoding="utf-8"?><BlockList>`)
for _, blockID := range order {
fmt.Fprintf(&list, "<Latest>%s</Latest>", blockID)
}
list.WriteString(`</BlockList>`)
require.Equal(t, http.StatusCreated, putBlob(t, uploadURL+"&comp=blocklist", list.Bytes()))
finalized := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
"key": "blocks", "version": "v1", "size_bytes": len("hello world!"),
})
require.Equal(t, true, finalized["ok"])
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "blocks", "version": "v1"})
require.Equal(t, true, got["ok"])
assert.Equal(t, "hello world!", string(getURL(t, got["signedDownloadUrl"].(string))))
}
func TestCacheServiceV2Lookups(t *testing.T) {
handler := startTestHandler(t)
saved, _ := saveV2(t, handler, "deps-abc", "v1", []byte("x"))
require.Equal(t, true, saved["ok"])
t.Run("reports a miss for an unknown key", func(t *testing.T) {
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{"key": "nothing", "version": "v1"})
assert.Equal(t, false, got["ok"])
})
// The toolkit serialises with the proto field names; the camelCase spellings of the same
// proto JSON mapping are accepted alongside them.
for _, field := range []string{"restore_keys", "restoreKeys"} {
t.Run("restore keys match by prefix, spelled "+field, func(t *testing.T) {
got := v2Call(t, handler, testClient, "GetCacheEntryDownloadURL", map[string]any{
"key": "deps-zzz", field: []string{"deps-"}, "version": "v1",
})
require.Equal(t, true, got["ok"])
assert.Equal(t, "deps-abc", got["matchedKey"])
})
}
t.Run("an existing entry is not reserved twice", func(t *testing.T) {
again := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps-abc", "version": "v1"})
assert.Equal(t, false, again["ok"])
})
// A key that is only a prefix of an existing one is a different entry, so the
// reservation check must be exact and not a restore-key prefix match, or the shorter
// key would be reported as existing and silently never saved.
t.Run("a prefix of an existing key is still reserved", func(t *testing.T) {
reserved := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "deps", "version": "v1"})
require.Equal(t, true, reserved["ok"])
assert.NotEmpty(t, reserved["signedUploadUrl"])
})
t.Run("finalizing without a reservation is not ok", func(t *testing.T) {
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
"key": "never-reserved", "version": "v1", "size_bytes": 1,
})
assert.Equal(t, false, got["ok"])
})
// The size the client declares is what Commit validates the assembled archive against.
t.Run("finalizing with the wrong size is not ok", func(t *testing.T) {
created := v2Call(t, handler, testClient, "CreateCacheEntry", map[string]any{"key": "wrong-size", "version": "v1"})
require.Equal(t, http.StatusCreated, putBlob(t, created["signedUploadUrl"].(string), []byte("four")))
got := v2Call(t, handler, testClient, "FinalizeCacheEntryUpload", map[string]any{
"key": "wrong-size", "version": "v1", "size_bytes": 99,
})
assert.Equal(t, false, got["ok"])
})
// Both API versions are served from one store, so an entry written through v2 is a hit for
// a v1 client asking for the same key and version.
t.Run("a v1 client sees an entry written through v2", func(t *testing.T) {
resp, err := testClient.Get(fmt.Sprintf("%s%s/cache?keys=deps-abc&version=v1", handler.ExternalURL(), apiPath))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
got := map[string]any{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.Equal(t, "deps-abc", got["cacheKey"])
assert.NotEmpty(t, got["archiveLocation"])
})
// The cache of one repository must stay invisible to another, as it does for the v1 API.
t.Run("another repository sees nothing", func(t *testing.T) {
handler.RegisterJob("other-runtime-token", "other/repo")
otherClient := &http.Client{Transport: &bearerTransport{token: "other-runtime-token"}}
got := v2Call(t, handler, otherClient, "GetCacheEntryDownloadURL", map[string]any{"key": "deps-abc", "version": "v1"})
assert.Equal(t, false, got["ok"])
})
}

View File

@@ -5,12 +5,15 @@
package artifactcache
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
)
type Storage struct {
@@ -37,7 +40,10 @@ func (s *Storage) Exist(id uint64) (bool, error) {
}
func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error {
name := s.tempName(id, offset)
return s.writeFile(s.tempName(id, offset), reader)
}
func (s *Storage) writeFile(name string, reader io.Reader) error {
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
return err
}
@@ -51,6 +57,26 @@ func (s *Storage) Write(id uint64, offset int64, reader io.Reader) error {
return err
}
func (s *Storage) WriteBlock(id uint64, blockID string, reader io.Reader) error {
return s.writeFile(s.blockName(id, blockID), reader)
}
// OrderBlocks renames the staged blocks into the order the block list gives. A block the list
// does not name keeps its staged name, which is how Commit leaves it out, as Azure drops it. One
// rename pass is safe because a staged name always carries blockFilePrefix and a target name
// never does, so no rename can collide with a block not yet moved.
func (s *Storage) OrderBlocks(id uint64, blockIDs []string) error {
for i, blockID := range blockIDs {
if err := os.Rename(s.blockName(id, blockID), s.tempName(id, int64(i))); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("block %q of cache %d was never uploaded: %w", blockID, id, err)
}
return err
}
}
return nil
}
func (s *Storage) Commit(id uint64, size int64) (int64, error) {
defer func() {
_ = os.RemoveAll(s.tempDir(id))
@@ -65,6 +91,31 @@ func (s *Storage) Commit(id uint64, size int64) (int64, error) {
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
return 0, err
}
written, err := assemble(name, tempNames)
if err != nil {
return 0, err
}
// If size is less than 0, it means the size is unknown.
// We can't check the size of the file, just skip the check.
// It happens when the request comes from old versions of actions, like `actions/cache@v2`.
if size >= 0 && written != size {
_ = os.Remove(name)
return 0, fmt.Errorf("broken file: %v != %v", written, size)
}
return written, nil
}
// assemble concatenates the uploaded parts into name. A single part, which is what the v2 API
// produces below the client's block threshold, is already the whole archive and is moved.
func assemble(name string, tempNames []string) (int64, error) {
if len(tempNames) == 1 {
info, err := os.Stat(tempNames[0])
if err != nil {
return 0, err
}
return info.Size(), os.Rename(tempNames[0], name)
}
file, err := os.Create(name)
if err != nil {
return 0, err
@@ -84,16 +135,6 @@ func (s *Storage) Commit(id uint64, size int64) (int64, error) {
}
written += n
}
// If size is less than 0, it means the size is unknown.
// We can't check the size of the file, just skip the check.
// It happens when the request comes from old versions of actions, like `actions/cache@v2`.
if size >= 0 && written != size {
_ = file.Close()
_ = os.Remove(name)
return 0, fmt.Errorf("broken file: %v != %v", written, size)
}
return written, nil
}
@@ -119,6 +160,17 @@ func (s *Storage) tempName(id uint64, offset int64) string {
return filepath.Join(s.tempDir(id), fmt.Sprintf("%016x", offset))
}
// blockFilePrefix marks a staged, not yet ordered block, so that tempNames can keep it out of
// Commit's name-ordered concatenation.
const blockFilePrefix = "block-"
func (s *Storage) blockName(id uint64, blockID string) string {
// The block id is client-chosen (base64), so it is hashed rather than trusted as a
// path element.
sum := sha256.Sum256([]byte(blockID))
return filepath.Join(s.tempDir(id), blockFilePrefix+hex.EncodeToString(sum[:]))
}
func (s *Storage) tempNames(id uint64) ([]string, error) {
dir := s.tempDir(id)
files, err := os.ReadDir(dir)
@@ -127,7 +179,7 @@ func (s *Storage) tempNames(id uint64) ([]string, error) {
}
var names []string
for _, v := range files {
if !v.IsDir() {
if !v.IsDir() && !strings.HasPrefix(v.Name(), blockFilePrefix) {
names = append(names, filepath.Join(dir, v.Name()))
}
}

View File

@@ -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)

View File

@@ -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
View 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
}

View 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")
})
}
}

View 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))
})
}