From 54fc4a147cd59e9a8c3616dd892a4b19765e7930 Mon Sep 17 00:00:00 2001 From: Francisco Junior Date: Wed, 29 Jul 2026 17:53:45 -0300 Subject: [PATCH 1/3] fix: discover outputs from nested GitHub Action paths Parse action.yml from the subdirectory in uses (owner/repo/path@ref) so path-based composite actions export outputs to Harness. Co-authored-by: Cursor --- plugin.go | 14 ++++--- utils/parse.go | 42 +++++++++++++++++---- utils/parse_test.go | 90 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 13 deletions(-) diff --git a/plugin.go b/plugin.go index 5642818..801d81f 100644 --- a/plugin.go +++ b/plugin.go @@ -51,11 +51,11 @@ func (p Plugin) Exec() error { } ctx := context.Background() - repoURL, ref, ok := utils.ParseLookup(p.Action.Uses) + repoURL, ref, actionPath, ok := utils.ParseLookup(p.Action.Uses) if !ok { logrus.Warnf("Invalid 'uses' format: %s", p.Action.Uses) } - logrus.Infof("Parsed 'uses' string. Repo: %s, Ref: %s", repoURL, ref) + logrus.Infof("Parsed 'uses' string. Repo: %s, Ref: %s, Path: %s", repoURL, ref, actionPath) // Clone the GH Action repository using `cloner` with parsed repo and ref clone := cloner.NewCache(cloner.NewDefault()) @@ -70,10 +70,14 @@ func (p Plugin) Exec() error { outputVars := []string{} if codedir != "" { - var err error - outputVars, err = utils.ParseActionOutputs(codedir) + actionDir, err := utils.ActionDir(codedir, actionPath) if err != nil { - logrus.Warnf("Could not parse action.yml outputs from %s: %v", codedir, err) + logrus.Warnf("Invalid action path %q: %v", actionPath, err) + } else { + outputVars, err = utils.ParseActionOutputs(actionDir) + if err != nil { + logrus.Warnf("Could not parse action.yml outputs from %s: %v", actionDir, err) + } } } diff --git a/utils/parse.go b/utils/parse.go index 6a958dc..aef6969 100644 --- a/utils/parse.go +++ b/utils/parse.go @@ -60,14 +60,40 @@ func fileExists(path string) bool { return !info.IsDir() } +// ActionDir joins cloneDir with an optional action subdirectory. +// Returns an error if actionPath escapes cloneDir. +func ActionDir(cloneDir, actionPath string) (string, error) { + if actionPath == "" { + return cloneDir, nil + } + + clean := filepath.Clean(actionPath) + if clean == "." || clean == "" { + return cloneDir, nil + } + if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("invalid action path: %s", actionPath) + } + + actionDir := filepath.Join(cloneDir, clean) + rel, err := filepath.Rel(cloneDir, actionDir) + if err != nil { + return "", fmt.Errorf("invalid action path: %w", err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return "", fmt.Errorf("action path escapes clone dir: %s", actionPath) + } + return actionDir, nil +} + // ParseLookup parses the step string and returns the -// associated repository and ref. -func ParseLookup(s string) (repo string, ref string, ok bool) { - org, repo, _, ref, err := parseActionName(s) +// associated repository, ref, and optional action subdirectory path. +func ParseLookup(s string) (repo string, ref string, path string, ok bool) { + org, repoName, actionPath, ref, err := parseActionName(s) if err == nil { - url := fmt.Sprintf("https://github.com/%s/%s", org, repo) - slog.Debug(fmt.Sprintf("parsed repo: %s, ref: %s", url, ref)) - return url, ref, true + url := fmt.Sprintf("https://github.com/%s/%s", org, repoName) + slog.Debug(fmt.Sprintf("parsed repo: %s, ref: %s, path: %s", url, ref, actionPath)) + return url, ref, actionPath, true } slog.Warn(fmt.Sprintf("failed to parse action name: %s with err: %v", s, err)) @@ -77,9 +103,9 @@ func ParseLookup(s string) (repo string, ref string, ok bool) { slog.Debug("parsed repo", s) if parts := strings.SplitN(s, "@", 2); len(parts) == 2 { - return parts[0], parts[1], true + return parts[0], parts[1], "", true } - return s, "", true + return s, "", "", true } func parseActionName(action string) (org, repo, path, ref string, err error) { diff --git a/utils/parse_test.go b/utils/parse_test.go index a6b8855..0b61d28 100644 --- a/utils/parse_test.go +++ b/utils/parse_test.go @@ -43,3 +43,93 @@ outputs: assert.NoError(t, err) assert.Empty(t, outputs) } + +func TestParseLookup(t *testing.T) { + tests := []struct { + name string + uses string + repo string + ref string + path string + ok bool + }{ + { + name: "root action", + uses: "mathieudutour/github-tag-action@v6.2", + repo: "https://github.com/mathieudutour/github-tag-action", + ref: "v6.2", + path: "", + ok: true, + }, + { + name: "nested action path", + uses: "my-corp/my-up2-action-external-management/.github/actions/mathieudutour/github-tag-action@v1", + repo: "https://github.com/my-corp/my-up2-action-external-management", + ref: "v1", + path: ".github/actions/mathieudutour/github-tag-action", + ok: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo, ref, path, ok := ParseLookup(tt.uses) + assert.Equal(t, tt.ok, ok) + assert.Equal(t, tt.repo, repo) + assert.Equal(t, tt.ref, ref) + assert.Equal(t, tt.path, path) + }) + } +} + +func TestActionDir(t *testing.T) { + clone := t.TempDir() + + dir, err := ActionDir(clone, "") + assert.NoError(t, err) + assert.Equal(t, clone, dir) + + dir, err = ActionDir(clone, ".github/actions/foo") + assert.NoError(t, err) + assert.Equal(t, filepath.Join(clone, ".github/actions/foo"), dir) + + _, err = ActionDir(clone, "../outside") + assert.Error(t, err) +} + +func TestParseActionOutputsNested(t *testing.T) { + clone := t.TempDir() + nested := filepath.Join(clone, ".github", "actions", "tag") + assert.NoError(t, os.MkdirAll(nested, 0755)) + + content := ` +outputs: + new_tag: + description: "Generated tag" + new_version: + description: "Generated version" +` + assert.NoError(t, os.WriteFile(filepath.Join(nested, "action.yml"), []byte(content), 0644)) + + actionDir, err := ActionDir(clone, ".github/actions/tag") + assert.NoError(t, err) + + outputs, err := ParseActionOutputs(actionDir) + assert.NoError(t, err) + assert.ElementsMatch(t, outputs, []string{"new_tag", "new_version"}) + + // Root still empty when only nested action.yml exists + outputs, err = ParseActionOutputs(clone) + assert.NoError(t, err) + assert.Empty(t, outputs) +} + +func TestParseActionOutputsMissingNested(t *testing.T) { + clone := t.TempDir() + actionDir, err := ActionDir(clone, ".github/actions/missing") + assert.NoError(t, err) + + outputs, err := ParseActionOutputs(actionDir) + assert.NoError(t, err) + assert.Empty(t, outputs) +} From 2e05ffb3b17ab941db514dfdf87fdb531520a878 Mon Sep 17 00:00:00 2001 From: Francisco Junior Date: Thu, 30 Jul 2026 09:36:50 -0300 Subject: [PATCH 2/3] test: cover ActionDir traversal that escapes after Clean Co-authored-by: Cursor --- utils/parse_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/utils/parse_test.go b/utils/parse_test.go index 0b61d28..c1d61c5 100644 --- a/utils/parse_test.go +++ b/utils/parse_test.go @@ -95,6 +95,21 @@ func TestActionDir(t *testing.T) { _, err = ActionDir(clone, "../outside") assert.Error(t, err) + + // Traversal that only escapes after filepath.Clean + _, err = ActionDir(clone, "foo/../../etc") + assert.Error(t, err) + + _, err = ActionDir(clone, "foo/../..") + assert.Error(t, err) + + _, err = ActionDir(clone, "foo/bar/../../../outside") + assert.Error(t, err) + + // Clean keeps the result inside cloneDir — should succeed + dir, err = ActionDir(clone, "foo/../.github/actions/bar") + assert.NoError(t, err) + assert.Equal(t, filepath.Join(clone, ".github/actions/bar"), dir) } func TestParseActionOutputsNested(t *testing.T) { From c3e5e4547e17cc1443819deb9b5846be65a24c19 Mon Sep 17 00:00:00 2001 From: Francisco Junior Date: Mon, 3 Aug 2026 09:05:41 -0300 Subject: [PATCH 3/3] fix: pin DinD base image to docker:29.6.0-dind Avoid Docker 29.7.0 CopyToContainer regression that breaks act when copying actions under /var/run/act. Co-authored-by: Cursor --- docker/Dockerfile.linux.amd64 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile.linux.amd64 b/docker/Dockerfile.linux.amd64 index fed781f..fb4a9f6 100644 --- a/docker/Dockerfile.linux.amd64 +++ b/docker/Dockerfile.linux.amd64 @@ -1,4 +1,4 @@ -FROM docker:dind +FROM docker:29.6.0-dind ENV DOCKER_HOST=unix:///var/run/docker.sock