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), + ) +}