Skip to content

fix: resolve Dockerfile context from action.yml location for remote actions#6085

Open
Raj-glitch-max wants to merge 1 commit intonektos:masterfrom
Raj-glitch-max:fix/action-path-docker-subpath
Open

fix: resolve Dockerfile context from action.yml location for remote actions#6085
Raj-glitch-max wants to merge 1 commit intonektos:masterfrom
Raj-glitch-max:fix/action-path-docker-subpath

Conversation

@Raj-glitch-max
Copy link
Copy Markdown

When a remote action declares runs.using: docker with a relative runs.image (e.g. image: Dockerfile), the build context must be resolved relative to the directory containing action.yml, not from the repository root.

Previously readActionImpl dropped the actionPath after reading metadata, so execAsDocker always used the uses: subpath to resolve runs.image. If action.yml lives in a subdirectory but uses: has no explicit path component, the Dockerfile was resolved from the wrong location.

Changes:

  • Add ActionPath string (yaml:"-") to model.Action to carry the resolved subpath through the runner without affecting YAML parsing
  • Set action.ActionPath in all return paths of readActionImpl
  • When action.yml is not found at the repo root, scan immediate subdirectories of actionDir/actionPath and read from the unique match; return an error if zero or multiple matches are found
  • Use action.ActionPath as dockerSubpath at all three execAsDocker call sites when running remote actions (main, pre, post)

Local actions and the ActionCache tar-stream path are unaffected. Added TestActionReaderDiscoverSubdir to cover the new discovery path.

Test output

![TestActionReaderDiscoverSubdir passing]
Screenshot from 2026-05-01 17-07-36
Screenshot from 2026-05-01 17-07-36

Fixes #739

Copilot AI review requested due to automatic review settings May 1, 2026 11:40
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes resolution of Dockerfile build context for remote Docker actions by carrying the resolved action.yml subpath through the runner, ensuring relative runs.image paths are interpreted relative to the metadata file location. Adds a fallback discovery mechanism to locate action.yml in a single immediate subdirectory when not found at the expected location.

Changes:

  • Add ActionPath to model.Action (ignored by YAML) and populate it during action metadata reads.
  • Update Docker action execution (main/pre/post) to use action.ActionPath as the Docker context subpath for remote actions.
  • Add a new test covering action metadata discovery from a subdirectory.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
pkg/runner/action.go Propagates ActionPath, adds subdir discovery for action.yml, and uses ActionPath for Docker context resolution in remote action execution paths.
pkg/runner/action_test.go Updates expectations to include ActionPath and adds TestActionReaderDiscoverSubdir.
pkg/model/action.go Introduces ActionPath field on model.Action with yaml:"-" to avoid affecting YAML parsing.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread pkg/runner/action.go Outdated
Comment on lines +111 to +115
if allErrors != nil {
baseDir := filepath.Join(actionDir, actionPath)
if info, statErr := os.Stat(baseDir); statErr == nil && info.IsDir() {
entries, readErr := os.ReadDir(baseDir)
if readErr == nil {
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new subdirectory discovery path uses os.Stat/os.ReadDir/os.Open against filepath.Join(actionDir, actionPath). This will never work when the caller's readFile is backed by the ActionCache tar reader (step_action_remote.go passes actionDir as the resolved SHA, not a real directory), so remote actions with action.yml in a subdir will still fail whenever ActionCache is enabled. Consider implementing discovery using the provided readFile abstraction (e.g., by adding a directory-listing capability / scanning the tar index), or explicitly returning a clear error in the ActionCache case rather than silently falling back to the original not-found errors.

Copilot uses AI. Check for mistakes.
Comment thread pkg/runner/action.go Outdated
Comment on lines +125 to +137
if _, err := os.Stat(actionYml); err == nil {
if discoveredFile != "" {
return nil, errors.Join(allErrors...)
}
discoveredFile = actionYml
discoveredSubdir = subdir
continue
}
if _, err := os.Stat(actionYaml); err == nil {
if discoveredFile != "" {
return nil, errors.Join(allErrors...)
}
discoveredFile = actionYaml
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When multiple action.yml/action.yaml files are found, the code returns errors.Join(allErrors...) (same as the zero-match case). That means the error reported is just the earlier "failed to read action.yml" not-found errors, and doesn't communicate that multiple metadata files were discovered. Return a dedicated error for the multiple-match case (and ideally include the candidate paths) so users can understand how to fix it.

Copilot uses AI. Check for mistakes.
Comment thread pkg/runner/action.go Outdated
Comment on lines +113 to +155
if info, statErr := os.Stat(baseDir); statErr == nil && info.IsDir() {
entries, readErr := os.ReadDir(baseDir)
if readErr == nil {
var discoveredFile string
var discoveredSubdir string
for _, entry := range entries {
if !entry.IsDir() {
continue
}
subdir := entry.Name()
actionYml := filepath.Join(baseDir, subdir, "action.yml")
actionYaml := filepath.Join(baseDir, subdir, "action.yaml")
if _, err := os.Stat(actionYml); err == nil {
if discoveredFile != "" {
return nil, errors.Join(allErrors...)
}
discoveredFile = actionYml
discoveredSubdir = subdir
continue
}
if _, err := os.Stat(actionYaml); err == nil {
if discoveredFile != "" {
return nil, errors.Join(allErrors...)
}
discoveredFile = actionYaml
discoveredSubdir = subdir
}
}
if discoveredFile != "" {
file, openErr := os.Open(discoveredFile)
if openErr != nil {
return nil, openErr
}
defer file.Close()
action, readErr := model.ReadAction(file)
if readErr != nil {
return nil, readErr
}
action.ActionPath = path.Join(actionPath, discoveredSubdir)
logger.Debugf("Read action %v from '%s'", action, discoveredFile)
return action, nil
}
}
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If os.ReadDir(baseDir) fails (permissions, transient I/O, etc.), the error is currently ignored and the function falls through to returning errors.Join(allErrors...), which hides the real failure reason. Propagate readErr (and potentially statErr) with context so troubleshooting isn't masked by unrelated "failed to read action.yml" errors.

Suggested change
if info, statErr := os.Stat(baseDir); statErr == nil && info.IsDir() {
entries, readErr := os.ReadDir(baseDir)
if readErr == nil {
var discoveredFile string
var discoveredSubdir string
for _, entry := range entries {
if !entry.IsDir() {
continue
}
subdir := entry.Name()
actionYml := filepath.Join(baseDir, subdir, "action.yml")
actionYaml := filepath.Join(baseDir, subdir, "action.yaml")
if _, err := os.Stat(actionYml); err == nil {
if discoveredFile != "" {
return nil, errors.Join(allErrors...)
}
discoveredFile = actionYml
discoveredSubdir = subdir
continue
}
if _, err := os.Stat(actionYaml); err == nil {
if discoveredFile != "" {
return nil, errors.Join(allErrors...)
}
discoveredFile = actionYaml
discoveredSubdir = subdir
}
}
if discoveredFile != "" {
file, openErr := os.Open(discoveredFile)
if openErr != nil {
return nil, openErr
}
defer file.Close()
action, readErr := model.ReadAction(file)
if readErr != nil {
return nil, readErr
}
action.ActionPath = path.Join(actionPath, discoveredSubdir)
logger.Debugf("Read action %v from '%s'", action, discoveredFile)
return action, nil
}
}
info, statErr := os.Stat(baseDir)
if statErr != nil {
return nil, errors.Join(errors.Join(allErrors...), fmt.Errorf("failed to stat action directory %q: %w", baseDir, statErr))
}
if info.IsDir() {
entries, readErr := os.ReadDir(baseDir)
if readErr != nil {
return nil, errors.Join(errors.Join(allErrors...), fmt.Errorf("failed to read action directory %q: %w", baseDir, readErr))
}
var discoveredFile string
var discoveredSubdir string
for _, entry := range entries {
if !entry.IsDir() {
continue
}
subdir := entry.Name()
actionYml := filepath.Join(baseDir, subdir, "action.yml")
actionYaml := filepath.Join(baseDir, subdir, "action.yaml")
if _, err := os.Stat(actionYml); err == nil {
if discoveredFile != "" {
return nil, errors.Join(allErrors...)
}
discoveredFile = actionYml
discoveredSubdir = subdir
continue
}
if _, err := os.Stat(actionYaml); err == nil {
if discoveredFile != "" {
return nil, errors.Join(allErrors...)
}
discoveredFile = actionYaml
discoveredSubdir = subdir
}
}
if discoveredFile != "" {
file, openErr := os.Open(discoveredFile)
if openErr != nil {
return nil, openErr
}
defer file.Close()
action, readErr := model.ReadAction(file)
if readErr != nil {
return nil, readErr
}
action.ActionPath = path.Join(actionPath, discoveredSubdir)
logger.Debugf("Read action %v from '%s'", action, discoveredFile)
return action, nil
}

Copilot uses AI. Check for mistakes.
Comment thread pkg/runner/action.go Outdated
Comment on lines +144 to +149
return nil, openErr
}
defer file.Close()
action, readErr := model.ReadAction(file)
if readErr != nil {
return nil, readErr
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the discoveredFile branch, openErr/readErr are returned directly without the step/action context that addError() includes. Wrapping these errors similarly (including step.String() and actionPath/baseDir) would make debugging consistent with the other failure paths.

Suggested change
return nil, openErr
}
defer file.Close()
action, readErr := model.ReadAction(file)
if readErr != nil {
return nil, readErr
return nil, fmt.Errorf("%s failed to open discovered action file '%s' (action path '%s', base dir '%s'): %w", step.String(), discoveredFile, actionPath, baseDir, openErr)
}
defer file.Close()
action, readErr := model.ReadAction(file)
if readErr != nil {
return nil, fmt.Errorf("%s failed to read discovered action file '%s' (action path '%s', base dir '%s'): %w", step.String(), discoveredFile, actionPath, baseDir, readErr)

Copilot uses AI. Check for mistakes.
Comment thread pkg/runner/action_test.go
assert.Nil(t, err)
assert.Equal(t, "github-action", action.ActionPath)
}

Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new discovery behavior is only tested for a single subdir match on the real filesystem. It would be useful to add coverage for (1) multiple subdir matches (to ensure a clear error is returned) and (2) the ActionCache/tar-backed reader path (to ensure discovery works or fails with an intentional, actionable error when ActionCache is enabled).

Suggested change
func TestActionReaderDiscoverSubdirMultipleMatches(t *testing.T) {
yaml := `
name: 'name'
runs:
using: 'node16'
main: 'main.js'
`
baseDir := t.TempDir()
for _, dir := range []string{"github-action", "nested-action"} {
subdir := filepath.Join(baseDir, dir)
if err := os.MkdirAll(subdir, 0o755); err != nil {
t.Fatalf("failed to create subdir %q: %v", dir, err)
}
if err := os.WriteFile(filepath.Join(subdir, "action.yml"), []byte(yaml), 0o644); err != nil {
t.Fatalf("failed to write action.yml in %q: %v", dir, err)
}
}
readFile := func(filename string) (io.Reader, io.Closer, error) {
return nil, nil, fs.ErrNotExist
}
writeFile := func(filename string, _ []byte, perm fs.FileMode) error {
t.Fatalf("unexpected writeFile call: %s %v", filename, perm)
return nil
}
action, err := readActionImpl(context.Background(), &model.Step{}, baseDir, "", readFile, writeFile)
assert.Nil(t, action)
if assert.Error(t, err) {
assert.NotEmpty(t, err.Error())
assert.True(t,
strings.Contains(strings.ToLower(err.Error()), "multiple") ||
strings.Contains(strings.ToLower(err.Error()), "more than one") ||
strings.Contains(strings.ToLower(err.Error()), "ambiguous"),
"expected a clear error for multiple discovered action subdirectories, got: %v", err,
)
}
}
func TestActionReaderDiscoverSubdirActionCacheReader(t *testing.T) {
yaml := `
name: 'name'
runs:
using: 'node16'
main: 'main.js'
`
baseDir := t.TempDir()
discoveredPath := filepath.Join(baseDir, "github-action", "action.yml")
readFile := func(filename string) (io.Reader, io.Closer, error) {
switch filename {
case filepath.Join(baseDir, "action.yml"), filepath.Join(baseDir, "action.yaml"):
return nil, nil, fs.ErrNotExist
case discoveredPath:
return strings.NewReader(yaml), io.NopCloser(strings.NewReader("")), nil
default:
return nil, nil, fs.ErrNotExist
}
}
writeFile := func(filename string, _ []byte, perm fs.FileMode) error {
t.Fatalf("unexpected writeFile call: %s %v", filename, perm)
return nil
}
action, err := readActionImpl(context.Background(), &model.Step{}, baseDir, "", readFile, writeFile)
if err == nil {
assert.Equal(t, "github-action", action.ActionPath)
return
}
assert.Nil(t, action)
assert.True(t,
strings.Contains(strings.ToLower(err.Error()), "actioncache") ||
strings.Contains(strings.ToLower(err.Error()), "cache") ||
strings.Contains(strings.ToLower(err.Error()), "tar") ||
strings.Contains(strings.ToLower(err.Error()), "discover") ||
strings.Contains(strings.ToLower(err.Error()), "subdir"),
"expected action-cache/tar-backed discovery to either work or fail with an actionable error, got: %v", err,
)
}

Copilot uses AI. Check for mistakes.
…ctions

When a remote action declares runs.using: docker with a relative
runs.image (e.g. image: Dockerfile), the build context must be
resolved relative to the directory containing action.yml, not from
the repository root.

Previously readActionImpl dropped the actionPath after reading
metadata, so execAsDocker always used the uses: subpath to resolve
runs.image. If action.yml lives in a subdirectory but uses: has no
explicit path component, the Dockerfile was resolved from the wrong
location.

Changes:
- Add ActionPath string (yaml:"-") to model.Action to carry the
  resolved subpath through the runner without affecting YAML parsing
- Set action.ActionPath in all return paths of readActionImpl
- When action.yml is not found at the repo root, scan immediate
  subdirectories of actionDir/actionPath and read from the unique
  match; return an error if zero or multiple matches are found
- Use action.ActionPath as dockerSubpath at all three execAsDocker
  call sites when running remote actions (main, pre, post)

Local actions and the ActionCache tar-stream path are unaffected.
Added TestActionReaderDiscoverSubdir to cover the new discovery path.

Fixes nektos#739
@Raj-glitch-max Raj-glitch-max force-pushed the fix/action-path-docker-subpath branch from 3ca4ad1 to 1b85d90 Compare May 1, 2026 11:57
@Raj-glitch-max
Copy link
Copy Markdown
Author

Addressed all Copilot review comments:
ActionCache (Comment #1): Added explicit error with explanation when os.Stat fails — clarifies that tar-backed ActionCache readers are incompatible with filesystem discovery
Multiple match error (Comment #2): Now returns fmt.Errorf("ambiguous action: found action.yml in multiple subdirectories...") with candidate paths
ReadDir error propagation (Comment #3): readErr now wrapped with context and returned instead of silently falling through
Error context (Comment #4): openErr/readErr now include step.String() + path info for consistent debugging
Test coverage (Comment #5): Added TestActionReaderDiscoverSubdirMultipleMatches and TestActionReaderDiscoverSubdirActionCacheUnsupported

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Issue: Dockerfile context relative path

3 participants