fix: stop artifact uploads depending on the cache server reaching Gitea (#1216)

Cache v2 makes the cache server the `ACTIONS_RESULTS_URL` origin, so artifact calls arrived there and were proxied on to Gitea, failing whenever it could not reach the instance.

- Artifact calls are answered with a redirect, so the cache server opens no connection to Gitea. A scheme change or an untrusted instance is still proxied, but there the cache server is the runner itself, which already reaches Gitea.
- Failures answer in twirp, not an empty `502` that clients report as `Unexpected end of JSON input`.
- `cache.v2: false` really points artifacts at Gitea now.
- Cache reservations are bound to the job that made them, so two jobs saving one key cannot commit against each other's upload, and a retry after a lost answer no longer fails a saved entry.
- The toolkit patch, which edits the GitHub-host check out of an action's bundle, was left in the shared checkout where a job running with `runner.patch_actions: false` could inherit it. It is put back after the job's copy.
- `exec` names an origin for the cache v2 it advertises, and masks its runtime token.

Behaviour changes: `no_proxy` no longer exempts `cache.external_server`, and `cache.enabled: false` also stops external registration.

Fixes https://gitea.com/gitea/runner/issues/1208
Fixes https://gitea.com/gitea/runner/issues/1211

Assisted by Claude (Opus 5).

Reviewed-on: https://gitea.com/gitea/runner/pulls/1216
Reviewed-by: bircni <bircni@icloud.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
silverwind
2026-09-08 18:45:10 +00:00
committed by bircni
parent ff9965e940
commit 2ed8cdb76e
22 changed files with 533 additions and 82 deletions
+24 -16
View File
@@ -28,8 +28,8 @@ import (
// 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.
// reads ACTIONS_CACHE_URL first. Both reads there are given the same preference, so a patched cache
// client reaches the cache server by its own address rather than through the results origin.
//
// Either of these landing upstream makes this file deletable:
//
@@ -105,37 +105,45 @@ func actionScriptPaths(dir string, action *model.Action) []string {
return paths
}
// patchActions edits the toolkit in an action's bundles. The caller holds the action directory's
// clone lock, which is what keeps another job's checkout from resetting them before the copy.
func patchActions(ctx context.Context, scripts []string) {
// patchActions returns a restore: the tree is shared, so a job with patching off gets no edits.
func patchActions(ctx context.Context, scripts []string) func() {
restore := map[string][]byte{}
for _, script := range scripts {
switch patched, err := patchBundle(script); {
case err != nil:
original, err := patchBundle(script)
if original != nil {
restore[script] = original // also when the write failed part way through
}
if err != nil {
common.Logger(ctx).Warnf("actions toolkit: %s left unpatched: %v", script, err)
case patched:
common.Logger(ctx).Debugf("actions toolkit: patched %s", script)
}
}
return func() {
for script, original := range restore {
if err := os.WriteFile(script, original, 0o644); err != nil { //nolint:gosec // as the checkout wrote it
common.Logger(ctx).Warnf("actions toolkit: %s left patched in the shared copy: %v", script, err)
}
}
}
}
func patchBundle(script string) (bool, error) {
// patchBundle returns the bytes it replaced, or nil when it left the bundle alone.
func patchBundle(script string) ([]byte, error) {
info, err := os.Stat(script)
if err != nil {
return false, err
return nil, err
}
if info.Size() > maxBundleSize {
return false, nil
return nil, nil
}
data, err := os.ReadFile(script)
if err != nil {
return false, err
return nil, err
}
patched, ok := patchedBundle(data)
if !ok {
return false, nil
return nil, nil
}
// No atomic write needed: every prepare checks the action out and hard resets it.
return true, os.WriteFile(script, patched, info.Mode().Perm())
return data, os.WriteFile(script, patched, info.Mode().Perm())
}
// patchedBundle opens the GHES gate, and where the cache toolkit is present, points the cache