3 Commits

Author SHA1 Message Date
Zettat123
ef6ca957b5 fix(artifactcache): preserve cache key case to stop redundant uploads (#947)
## Summary

`artifactcache.Handler` was lowercasing cache keys before storing and returning them. This caused actions like `actions/setup-go` to treat every restore as a partial hit and re-upload the cache on every job run.

Similar issue: [act#2497](https://github.com/nektos/act/issues/2497)

## Root Cause

These actions build cache keys that include `RUNNER_OS` (e.g. `setup-go-Linux-x64-...` See [setup-go/cache-restore.ts](78961f6f84/src/cache-restore.ts (L11-L51)) ). In `gitea/runner`,  `RUNNER_OS` is always `Linux` by default (See https://gitea.com/gitea/runner/search?q=RUNNER_OS).

These actions decide whether to save the cache data in their post step using **strict** `===` comparison between the primary key and the key returned from the runner. See [setup-go cache-save.ts](78961f6f84/src/cache-save.ts (L44-L86)) .

|State | Value|
|--- | ---|
|CachePrimaryKey | `setup-go-Linux-x64-ubuntu-22.04-go-1.24.9-abc123` |
|CacheMatchedKey | `setup-go-linux-x64-ubuntu-22.04-go-1.24.9-abc123` |

Because the runner's cache server lowercased the stored key, the response carried `setup-go-linux-...` while the action's primary key was `setup-go-Linux-...`. Strict equality failed, then the actions updated same data again. This repeated on every run, wasting disk and bandwidth. The duplicate blobs accumulate until GC .

https://gitea.com/gitea/runner/actions/runs/462560/jobs/737401#jobstep-2-15
![image.png](/attachments/d3487457-1d09-44b5-9937-a0b8cab1bcc5)

https://gitea.com/gitea/runner/actions/runs/462560/jobs/737401#jobstep-6-22
![image.png](/attachments/9217dc71-cb0c-456b-a516-0017458123c7)

## Fix
Drop the `strings.ToLower` calls in `find` and `reserve` so the original key case is preserved end-to-end. This fix will invalidate existing "case insensitive" keys.

## Notes

The [original act review](https://github.com/nektos/act/pull/1770/changes/BASE..d44b8d15649d9d09d1d891130b8f3962097a81f3#r1177624608) suggested making cache keys case-insensitive because `isExactKeyMatch` compares cache key ignoring case. However, the actions (`setup-go` / `setup-node` / `setup-ruby`) compare with strict `===` rather than `isExactKeyMatch`.

---------

Co-authored-by: Nicolas <bircni@icloud.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/947
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
Reviewed-by: Nicolas <bircni@icloud.com>
Co-authored-by: Zettat123 <zettat123@gmail.com>
Co-committed-by: Zettat123 <zettat123@gmail.com>
2026-05-09 12:27:52 +00:00
Renovate Bot
8088df52b9 fix(deps): update module golang.org/x/term to v0.43.0 (#948)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [golang.org/x/term](https://pkg.go.dev/golang.org/x/term) | [`v0.42.0` → `v0.43.0`](https://cs.opensource.google/go/x/term/+/refs/tags/v0.42.0...refs/tags/v0.43.0) | ![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2fterm/v0.43.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2fterm/v0.42.0/v0.43.0?slim=true) |

---

> ⚠️ **Warning**
>
> Some dependencies could not be looked up. Check the [Dependency Dashboard](issues/856) for more information.

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNjkuNCIsInVwZGF0ZWRJblZlciI6IjQzLjE2OS40IiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Reviewed-on: https://gitea.com/gitea/runner/pulls/948
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Renovate Bot <renovate-bot@gitea.com>
Co-committed-by: Renovate Bot <renovate-bot@gitea.com>
2026-05-09 05:28:44 +00:00
Nicolas
3ea7d39690 fix: overwrite read-only files when copying action directories (#942)
## Summary

  - `CopyCollector.WriteFile` now removes any existing destination file
    before writing, handling read-only modes (e.g. git pack files at
    `0444`) that cause `EACCES`/`ERROR_ACCESS_DENIED` on macOS and Windows.
  - Added `O_TRUNC` to the `OpenFile` flags as a safety net.

  ## Root cause

  When a composite action with a post step runs on a host runner,
  `runPostStep` calls `maybeCopyToActionDir`, which re-copies the action
  into `miscpath/act/actions/<name>/`. The first copy (main step) writes
  `.git/objects/pack/*.idx` at the destination with mode `0444` (as set
  by go-git). The second copy (post step) calls
  `os.OpenFile(dest, O_CREATE|O_WRONLY, …)` on that existing `0444` file,
  which fails immediately:

  - macOS: `open <path>: permission denied`
  - Windows: `open <path>: Access is denied`

Fixes: https://gitea.com/gitea/runner/issues/941
Fixes: https://gitea.com/gitea/runner/issues/876

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com>
Reviewed-on: https://gitea.com/gitea/runner/pulls/942
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Nicolas <bircni@icloud.com>
Co-committed-by: Nicolas <bircni@icloud.com>
2026-05-08 04:11:42 +00:00
6 changed files with 71 additions and 16 deletions

View File

@@ -325,10 +325,6 @@ func (h *Handler) openDB() (*bolthold.Store, error) {
func (h *Handler) find(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
cred := credFromContext(r.Context())
keys := strings.Split(r.URL.Query().Get("keys"), ",")
// cache keys are case insensitive
for i, key := range keys {
keys[i] = strings.ToLower(key)
}
version := r.URL.Query().Get("version")
db, err := h.openDB()
@@ -371,8 +367,6 @@ func (h *Handler) reserve(w http.ResponseWriter, r *http.Request, _ httprouter.P
h.responseJSON(w, r, 400, err)
return
}
// cache keys are case insensitive
api.Key = strings.ToLower(api.Key)
cache := api.ToCache()
cache.Repo = cred.Repo

View File

@@ -459,17 +459,20 @@ func TestHandler(t *testing.T) {
assert.Equal(t, contents[except], content)
})
t.Run("case insensitive", func(t *testing.T) {
t.Run("case preserved", func(t *testing.T) {
// Some actions (e.g. actions/setup-go, actions/setup-node) build cache keys that contain mixed-case fragments such as RUNNER_OS=Linux,
// then compare the cacheKey returned by the cache server to their original key with case-sensitive equality to decide whether the
// cache was a complete hit. The server must therefore preserve the original key case.
version := "c19da02a2bd7e77277f1ac29ab45c09b7d46a4ee758284e26bb3045ad11d9d20"
key := strings.ToLower(t.Name())
key := strings.ToLower(t.Name()) + "_ABC"
content := make([]byte, 100)
_, err := rand.Read(content)
require.NoError(t, err)
uploadCacheNormally(t, base, key+"_ABC", version, content)
uploadCacheNormally(t, base, key, version, content)
{
reqKey := key + "_aBc"
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, reqKey, version))
resp, err := testClient.Get(fmt.Sprintf("%s/cache?keys=%s&version=%s", base, key, version))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, 200, resp.StatusCode)
@@ -480,7 +483,8 @@ func TestHandler(t *testing.T) {
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.Equal(t, "hit", got.Result)
assert.Equal(t, key+"_abc", got.CacheKey)
assert.Equal(t, key, got.CacheKey)
assert.NotEqual(t, strings.ToLower(key), got.CacheKey)
}
})
@@ -643,7 +647,7 @@ func uploadCacheNormally(t *testing.T, base, key, version string, content []byte
}{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
assert.Equal(t, "hit", got.Result)
assert.Equal(t, strings.ToLower(key), got.CacheKey)
assert.Equal(t, key, got.CacheKey)
archiveLocation = got.ArchiveLocation
}
{

View File

@@ -73,10 +73,16 @@ func (cc *CopyCollector) WriteFile(fpath string, fi fs.FileInfo, linkName string
if err := os.MkdirAll(filepath.Dir(fdestpath), 0o777); err != nil {
return err
}
// Remove any existing destination so we can overwrite read-only files
// (e.g. git pack files at mode 0444 trip EACCES on macOS and "Access is
// denied" on Windows when reopened with O_WRONLY) and so os.Symlink does
// not fail with EEXIST. os.Remove clears the Windows read-only attribute
// internally; on Unix unlink only needs write permission on the parent.
_ = os.Remove(fdestpath)
if f == nil {
return os.Symlink(linkName, fdestpath)
}
df, err := os.OpenFile(fdestpath, os.O_CREATE|os.O_WRONLY, fi.Mode())
df, err := os.OpenFile(fdestpath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, fi.Mode())
if err != nil {
return err
}

View File

@@ -8,7 +8,9 @@ import (
"archive/tar"
"context"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
@@ -20,6 +22,7 @@ import (
"github.com/go-git/go-git/v5/plumbing/format/index"
"github.com/go-git/go-git/v5/storage/filesystem"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type memoryFs struct {
@@ -174,3 +177,47 @@ func TestSymlinks(t *testing.T) {
assert.Equal(t, ".env", files["test.env"].Linkname)
assert.ErrorIs(t, err, io.EOF, "tar must be read cleanly to EOF")
}
// Regression for https://gitea.com/gitea/runner/issues/876 and /941:
// re-copying an action directory must overwrite a pre-existing read-only
// file (e.g. a git pack .idx at mode 0444) instead of failing with EACCES
// on macOS or "Access is denied" on Windows.
func TestCopyCollectorWriteFileOverwritesReadOnlyFile(t *testing.T) {
dst := t.TempDir()
target := filepath.Join(dst, "sub", "pack.idx")
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755))
require.NoError(t, os.WriteFile(target, []byte("old"), 0o444))
src := filepath.Join(t.TempDir(), "pack.idx")
require.NoError(t, os.WriteFile(src, []byte("new"), 0o444))
fi, err := os.Stat(src)
require.NoError(t, err)
cc := &CopyCollector{DstDir: dst}
require.NoError(t, cc.WriteFile("sub/pack.idx", fi, "", strings.NewReader("new")))
got, err := os.ReadFile(target)
require.NoError(t, err)
assert.Equal(t, "new", string(got))
}
// Without the destination removal, os.Symlink fails with EEXIST when the
// path already holds a regular file from an earlier copy of the action.
func TestCopyCollectorWriteFileOverwritesFileWithSymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("creating symlinks requires elevated privileges on Windows")
}
dst := t.TempDir()
target := filepath.Join(dst, "link")
require.NoError(t, os.WriteFile(target, []byte("stale"), 0o644))
fi, err := os.Lstat(target)
require.NoError(t, err)
cc := &CopyCollector{DstDir: dst}
require.NoError(t, cc.WriteFile("link", fi, "target", nil))
resolved, err := os.Readlink(target)
require.NoError(t, err)
assert.Equal(t, "target", resolved)
}

4
go.mod
View File

@@ -13,7 +13,7 @@ require (
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
go.yaml.in/yaml/v4 v4.0.0-rc.3
golang.org/x/term v0.42.0
golang.org/x/term v0.43.0
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 // indirect
@@ -108,7 +108,7 @@ require (
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/sys v0.44.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/grpc v1.67.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect

4
go.sum
View File

@@ -323,6 +323,8 @@ golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
@@ -330,6 +332,8 @@ golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=