enhance(cache): scope cache entries by ref like GitHub (#1361)

Cache entries were only scoped by repository, so an entry a pull request saved could be restored on the default branch, for example through a `restore-keys` prefix.

Entries now carry the ref of the job that saved them. As on GitHub ([Restrictions for accessing a cache](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#restrictions-for-accessing-a-cache)), a job restores entries of its own ref first, then of its pull request's base branch, then of the default branch. Pull request events are scoped by their pull request number.

Entries saved before this change are no longer restored and expire through `cache.retention`, so caches start cold once after the upgrade. Runners sharing a cache, and an external `cache-server`, should be upgraded together.

Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1361
Reviewed-by: silverwind <[email protected]>
Co-authored-by: JorgeDLMV <[email protected]>
This commit is contained in:
authored and silverwind committed 2026-10-30 22:34:45 +00:00
1 parent ca8e9c3c64
commit 261ab374ce
10 files changed
+226 -119

No files matched your search

+34 -14
View File
@@ -533,7 +533,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
// is removed when the task finishes, so a leaked token stops working as
// soon as the job ends rather than remaining valid for the runner's
// lifetime.
revokeCache, resultsURL := r.registerCacheForTask(giteaRuntimeToken, preset.Repository, cacheURL, reporter)
revokeCache, resultsURL := r.registerCacheForTask(giteaRuntimeToken, cacheCredential(preset, cacheURL), reporter)
defer revokeCache()
eventJSON, err := json.Marshal(preset.Event)
@@ -690,34 +690,54 @@ func (r *Runner) instanceOutOfReach(instance string) bool {
//
// Safe with an empty token (older Gitea did not issue one).
// It also returns the URL to advertise as ACTIONS_RESULTS_URL.
func (r *Runner) registerCacheForTask(token, repo, publicURL string, reporter *report.Reporter) (func(), string) {
func (r *Runner) registerCacheForTask(token string, cred artifactcache.JobCredential, reporter *report.Reporter) (func(), string) {
if token == "" || r.cacheHandler == nil {
return func() {}, ""
}
cred := artifactcache.JobCredential{
Repo: repo,
PublicURL: publicURL,
Results: r.envs["ACTIONS_RESULTS_URL"], // the instance as the job would reach it
InsecureTLS: r.cfg.Runner.Insecure,
}
cred.Results = r.envs["ACTIONS_RESULTS_URL"] // the instance as the job would reach it
cred.InsecureTLS = r.cfg.Runner.Insecure
revoke := r.cacheHandler.RegisterJob(token, cred)
if r.cfg.Cache.ExternalServer != "" {
revokeLocal, revokeExternal := revoke, r.registerExternalCacheJob(token, repo, publicURL, reporter)
revokeLocal, revokeExternal := revoke, r.registerExternalCacheJob(token, cred, reporter)
revoke = func() { revokeLocal(); revokeExternal() }
}
return revoke, r.cacheHandler.ResultsURL(cred)
}
// cacheCredential scopes the job's cache entries by ref, as GitHub does.
func cacheCredential(preset *model.GithubContext, publicURL string) artifactcache.JobCredential {
ref := preset.Ref
if strings.HasPrefix(preset.EventName, "pull_request") && preset.EventName != "pull_request_target" {
number, _ := preset.Event["number"].(float64)
ref = fmt.Sprintf("refs/pull/%d/head", int64(number)) // review events take github.ref from the reviewer's commit_id
}
return artifactcache.JobCredential{
Repo: preset.Repository,
Ref: ref,
BaseRef: branchRef(preset.BaseRef),
DefaultRef: branchRef(model.AsString(model.NestedMapLookup(preset.Event, "repository", "default_branch"))),
PublicURL: publicURL,
}
}
func branchRef(branch string) string {
if branch == "" {
return ""
}
return "refs/heads/" + branch
}
// registerExternalCacheJob POSTs to the remote cache-server's control-plane.
// Failures are logged but not fatal: if registration fails, the cache will
// 401 the job's requests — better than failing the whole task for a cache
// outage. The warning is mirrored to the job log so users can see why their
// cache calls 401, instead of having to read the runner daemon's stderr.
func (r *Runner) registerExternalCacheJob(token, repo, publicURL string, reporter *report.Reporter) func() {
func (r *Runner) registerExternalCacheJob(token string, cred artifactcache.JobCredential, reporter *report.Reporter) func() {
base := strings.TrimRight(r.cfg.Cache.ExternalServer, "/")
if err := postInternalCache(base+"/_internal/register", r.cfg.Cache.ExternalSecret, map[string]any{
"token": token, "repo": repo, "public_url": publicURL,
}); err != nil {
if err := postInternalCache(base+"/_internal/register", r.cfg.Cache.ExternalSecret, struct {
Token string `json:"token"`
artifactcache.JobCredential
}{token, cred}); err != nil {
logging.Warnf("cache external_server register failed (%s): %v", base, err)
if reporter != nil {
reporter.Logf("::warning::%s", runner.EscapeCommandData(fmt.Sprintf(
@@ -736,7 +756,7 @@ func (r *Runner) registerExternalCacheJob(token, repo, publicURL string, reporte
}
}
func postInternalCache(url, secret string, body map[string]any) error {
func postInternalCache(url, secret string, body any) error {
buf, err := json.Marshal(body)
if err != nil {
return err
+28 -3
View File
@@ -17,6 +17,7 @@ import (
clientmocks "gitea.com/gitea/runner/internal/pkg/client/mocks"
"gitea.com/gitea/runner/internal/pkg/config"
"gitea.dev/actionslib/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -24,7 +25,7 @@ import (
func TestRunner_registerCacheForTask_NoOps(t *testing.T) {
for token, handler := range map[string]*artifactcache.Handler{"tok": nil, "": {}} {
r := &Runner{cacheHandler: handler}
unregister, _ := r.registerCacheForTask(token, "owner/repo", "", nil)
unregister, _ := r.registerCacheForTask(token, artifactcache.JobCredential{Repo: "owner/repo"}, nil)
require.NotNil(t, unregister, token)
unregister()
}
@@ -39,7 +40,7 @@ func TestRunner_CacheFullFlow_MatchesToolkit(t *testing.T) {
r := &Runner{cfg: &config.Config{}, cacheHandler: handler, envs: map[string]string{"ACTIONS_RESULTS_URL": "https://gitea.example"}}
const publicURL = "http://a1b2c3d4e5f6:8088"
token := "full-flow-token"
unregister, resultsURL := r.registerCacheForTask(token, "owner/repo", publicURL, nil)
unregister, resultsURL := r.registerCacheForTask(token, artifactcache.JobCredential{Repo: "owner/repo", PublicURL: publicURL}, nil)
assert.Equal(t, publicURL, resultsURL)
base := handler.ExternalURL() + "/_apis/artifactcache"
@@ -112,6 +113,24 @@ func decodeJSON(resp *http.Response, v any) error {
return json.UnmarshalRead(resp.Body, v)
}
func TestCacheCredential(t *testing.T) {
repository := map[string]any{"default_branch": "main"}
review := &model.GithubContext{
Repository: "owner/repo", EventName: "pull_request_review", Ref: "refs/heads/main", BaseRef: "release",
Event: map[string]any{"repository": repository, "number": float64(3)},
}
assert.Equal(t, artifactcache.JobCredential{
Repo: "owner/repo", Ref: "refs/pull/3/head", BaseRef: "refs/heads/release", DefaultRef: "refs/heads/main", PublicURL: "http://cache",
}, cacheCredential(review, "http://cache"))
review.EventName, review.Ref = "pull_request_target", "refs/heads/release"
assert.Equal(t, "refs/heads/release", cacheCredential(review, "").Ref)
assert.Equal(t, artifactcache.JobCredential{
Repo: "owner/repo", Ref: "refs/heads/feature", DefaultRef: "refs/heads/main",
}, cacheCredential(&model.GithubContext{Repository: "owner/repo", Ref: "refs/heads/feature", Event: map[string]any{"repository": repository}}, ""))
}
func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
const secret = "shared-secret-for-tests"
remote, err := artifactcache.StartHandler(artifactcache.Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1", InternalSecret: secret})
@@ -151,7 +170,7 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
require.Equal(t, http.StatusUnauthorized, probe(),
"token must be unknown to the remote server before registration")
unregister, resultsURL := r.registerCacheForTask(token, repo, publicURL, nil)
unregister, resultsURL := r.registerCacheForTask(token, artifactcache.JobCredential{Repo: repo, Ref: "refs/heads/main", PublicURL: publicURL}, nil)
require.NotEqual(t, http.StatusUnauthorized, probe(),
"token must be accepted after registerCacheForTask")
@@ -194,6 +213,12 @@ func TestRunner_ExternalCacheServer_RegisterRevoke(t *testing.T) {
require.NoError(t, decodeJSON(resp, &hit))
require.True(t, strings.HasPrefix(hit.ArchiveLocation, publicURL+"/"), hit.ArchiveLocation)
unregisterOther, _ := r.registerCacheForTask("external-other-token", artifactcache.JobCredential{Repo: repo, Ref: "refs/heads/other"}, nil)
defer unregisterOther()
resp = cacheRequest(t, "external-other-token", http.MethodGet, base+"/cache?keys=ext-key&version=v", "", "", "")
resp.Body.Close()
assert.Equal(t, http.StatusNoContent, resp.StatusCode, "the remote server must scope the entry by the ref the runner sent")
dl, err := http.Get(strings.Replace(hit.ArchiveLocation, publicURL, r.cacheHandler.ExternalURL(), 1))
require.NoError(t, err)
defer dl.Body.Close()
+2 -1
View File
@@ -18,6 +18,7 @@ import (
"testing"
"time"
"gitea.com/gitea/runner/act/artifactcache"
"gitea.com/gitea/runner/act/common"
"gitea.com/gitea/runner/act/common/logging"
"gitea.com/gitea/runner/act/runner"
@@ -325,7 +326,7 @@ func TestNewRunnerCacheServiceV2(t *testing.T) {
// The registration is what makes it true: the cache server takes the results service over,
// having been told which instance to forward the artifact half to.
revoke, resultsURL := r.registerCacheForTask(token, "owner/repo", r.cacheHandler.ExternalURL(), nil)
revoke, resultsURL := r.registerCacheForTask(token, artifactcache.JobCredential{Repo: "owner/repo", PublicURL: r.cacheHandler.ExternalURL()}, nil)
defer revoke()
require.Equal(t, r.cacheHandler.ExternalURL(), resultsURL)