From cdcea87a459394efb082b223b85c1b4f1ed2c25e Mon Sep 17 00:00:00 2001 From: bircni Date: Mon, 29 Jun 2026 06:15:37 +0000 Subject: [PATCH] fix: namespace local docker action image tags per repository (#1051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1039 — local docker actions referenced with `uses: ./` are incorrectly served from a cached image built for a *different* repository. ## Root cause For a local docker action, the built image tag is derived from `actionName`, which is the **workspace-relative** path of the action. For a self-referencing action that is always `"./"`, regardless of which repository it lives in. In `execAsDocker` this collapsed to the constant tag `act-dockeraction:latest` for *every* repository. Before building, the runner checks `ContainerImageExistsLocally` and skips the rebuild if a matching tag is present. On a shared docker daemon, once repository A built its image, repository B's `uses: ./` found the same tag, skipped its rebuild, and **ran repository A's action image** — the reported "the first action is run again, because the path is cached", including the cross-repository concern raised in the issue. ## Fix Extracted the tag computation into a small, testable `dockerActionImageTag(repository, actionName, localAction)` function. For local actions the tag is now namespaced with the repository (`path.Join(repository, actionName)`): - different repositories no longer collide (`act-owner-repo-a-dockeraction:latest` vs `act-owner-repo-b-dockeraction:latest`); - image caching is preserved within a repository (tag stays stable); - remote actions are unchanged — they already carry a unique, ref-scoped `actionName` (the uses hash). --------- Co-authored-by: Zettat123 <39446+zettat123@noreply.gitea.com> Co-authored-by: Zettat123 Reviewed-on: https://gitea.com/gitea/runner/pulls/1051 Reviewed-by: Zettat123 <39446+zettat123@noreply.gitea.com> Co-authored-by: bircni Co-committed-by: bircni --- act/runner/action.go | 37 ++++++++++++++++++++++++++---- act/runner/action_test.go | 47 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/act/runner/action.go b/act/runner/action.go index 61793d9e..ea93c571 100644 --- a/act/runner/action.go +++ b/act/runner/action.go @@ -6,7 +6,9 @@ package runner import ( "context" + "crypto/sha256" "embed" + "encoding/hex" "errors" "fmt" "io" @@ -272,6 +274,36 @@ func removeGitIgnore(ctx context.Context, directory string) error { return nil } +// dockerActionImageTag derives the local docker image tag used when an action +// is built from a Dockerfile. +// +// For Gitea: a local action (`uses: ./` or `uses: ./path`) has an actionName +// that is the workspace-relative path of the action. That path is identical +// across repositories (e.g. "./" for a self-referencing action), so without +// namespacing, every repository's local docker action would build and reuse the +// same `act-dockeraction:latest` image on a shared docker daemon. A subsequent +// repository would then silently run the image built for an earlier one. +// Including the repository keeps the tag stable for caching within a repository +// while preventing cross-repository collisions. +// See https://gitea.com/gitea/runner/issues/1039. +func dockerActionImageTag(repository, actionName string, localAction bool) string { + name := actionName + if localAction { + name = path.Join(repository, actionName) + } + // The human-readable name is sanitized by collapsing every non-alphanumeric character to "-". + sanitized := regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(name, "-") + if localAction { + // For local actions a short hash of the raw repository and action path is appended so the tag stays unique per repository. + sum := sha256.Sum256([]byte(repository + "\x00" + actionName)) + sanitized += "-" + hex.EncodeToString(sum[:])[:12] + } + // "-dockeraction" ensures that "./", "./test " won't get converted to "act-:latest", "act-test-:latest" which are invalid docker image names + image := fmt.Sprintf("%s-dockeraction:%s", sanitized, "latest") + image = "act-" + strings.TrimLeft(image, "-") + return strings.ToLower(image) +} + // TODO: break out parts of function to reduce complexicity func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, basedir string, localAction bool) error { logger := common.Logger(ctx) @@ -286,10 +318,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b // Apply forcePull only for prebuild docker images forcePull = rc.Config.ForcePull } else { - // "-dockeraction" enshures that "./", "./test " won't get converted to "act-:latest", "act-test-:latest" which are invalid docker image names - image = fmt.Sprintf("%s-dockeraction:%s", regexp.MustCompile("[^a-zA-Z0-9]").ReplaceAllString(actionName, "-"), "latest") - image = "act-" + strings.TrimLeft(image, "-") - image = strings.ToLower(image) + image = dockerActionImageTag(step.getGithubContext(ctx).Repository, actionName, localAction) contextDir, fileName := filepath.Split(filepath.Join(basedir, action.Runs.Image)) anyArchExists, err := ContainerImageExistsLocally(ctx, image, "any") diff --git a/act/runner/action_test.go b/act/runner/action_test.go index c5dc2360..9122e025 100644 --- a/act/runner/action_test.go +++ b/act/runner/action_test.go @@ -455,3 +455,50 @@ func TestExecAsDockerHoldsCloneLockForRemoteUncached(t *testing.T) { t.Fatal("execAsDocker did not return after inner was released and ctx was canceled") } } + +func TestDockerActionImageTag(t *testing.T) { + // Remote actions already carry a unique, ref-scoped actionName (the uses + // hash), so the tag must be left untouched for backwards compatibility. + assert.Equal(t, + "act-abc123-dockeraction:latest", + dockerActionImageTag("owner/repo", "abc123", false), + ) + + // Local actions keep a human-readable, repository-namespaced prefix and gain a short hash suffix that makes the tag unique per (repository, actionName). + // See https://gitea.com/gitea/runner/issues/1039. + assert.Equal(t, + "act-owner-repo-baca2daaa2fe-dockeraction:latest", + dockerActionImageTag("owner/repo", "./", true), + ) + assert.Equal(t, + "act-owner-repo-sub-e847b61255a8-dockeraction:latest", + dockerActionImageTag("owner/repo", "./sub", true), + ) + + // Sanitizing every non-alphanumeric character to "-" is lossy, so distinct inputs can collapse to the same readable prefix. + // The hash suffix must keep such cases apart, otherwise an image built for one repository is reused for another. + collisions := [][2]struct { + repoName string + actionName string + }{ + // Two different repositories, both `uses: ./`: "a/b-c" and "a-b/c" both sanitize to "a-b-c". + {{"a/b-c", "./"}, {"a-b/c", "./"}}, + // A repository's root action vs another repository's sub-path action: + // "owner/repo-a" + "./" and "owner/repo" + "./a" both sanitize to "owner-repo-a". + {{"owner/repo-a", "./"}, {"owner/repo", "./a"}}, + } + for _, c := range collisions { + assert.NotEqual(t, + dockerActionImageTag(c[0].repoName, c[0].actionName, true), + dockerActionImageTag(c[1].repoName, c[1].actionName, true), + "local docker action tags must differ for %q/%q vs %q/%q", + c[0].repoName, c[0].actionName, c[1].repoName, c[1].actionName, + ) + } + + // Distinct local actions within the same repository keep distinct tags. + assert.NotEqual(t, + dockerActionImageTag("owner/repo", "./", true), + dockerActionImageTag("owner/repo", "./sub", true), + ) +}