From 0bf3442088a1a2e3aedbee8313bd9714db199c24 Mon Sep 17 00:00:00 2001 From: William Easton Date: Sun, 15 Feb 2026 14:55:13 -0600 Subject: [PATCH 1/5] Resolve nested imports and symlinks --- pkg/parser/import_processor.go | 106 +++++-- pkg/parser/import_remote_nested_test.go | 375 ++++++++++++++++++++++++ pkg/parser/remote_fetch.go | 105 ++++++- 3 files changed, 567 insertions(+), 19 deletions(-) create mode 100644 pkg/parser/import_remote_nested_test.go diff --git a/pkg/parser/import_processor.go b/pkg/parser/import_processor.go index efe9a1abf53..e76150f6735 100644 --- a/pkg/parser/import_processor.go +++ b/pkg/parser/import_processor.go @@ -83,13 +83,54 @@ func ProcessImportsFromFrontmatter(frontmatter map[string]any, baseDir string) ( return result.MergedTools, result.MergedEngines, nil } +// remoteImportOrigin tracks the remote repository context for an imported file. +// When a file is fetched from a remote GitHub repository via workflowspec, +// its nested relative imports must be resolved against the same remote repo. +type remoteImportOrigin struct { + Owner string // Repository owner (e.g., "elastic") + Repo string // Repository name (e.g., "ai-github-actions") + Ref string // Git ref - branch, tag, or SHA (e.g., "main", "v1.0.0", "abc123...") +} + // importQueueItem represents a file to be imported with its context type importQueueItem struct { - importPath string // Original import path (e.g., "file.md" or "file.md#Section") - fullPath string // Resolved absolute file path - sectionName string // Optional section name (from file.md#Section syntax) - baseDir string // Base directory for resolving nested imports - inputs map[string]any // Optional input values from parent import + importPath string // Original import path (e.g., "file.md" or "file.md#Section") + fullPath string // Resolved absolute file path + sectionName string // Optional section name (from file.md#Section syntax) + baseDir string // Base directory for resolving nested imports + inputs map[string]any // Optional input values from parent import + remoteOrigin *remoteImportOrigin // Remote origin context (non-nil when imported from a remote repo) +} + +// parseRemoteOrigin extracts the remote origin (owner, repo, ref) from a workflowspec path. +// Returns nil if the path is not a valid workflowspec. +// Format: owner/repo/path[@ref] where ref defaults to "main" if not specified. +func parseRemoteOrigin(spec string) *remoteImportOrigin { + // Remove section reference if present + cleanSpec := spec + if idx := strings.Index(spec, "#"); idx != -1 { + cleanSpec = spec[:idx] + } + + // Split on @ to get path and ref + parts := strings.SplitN(cleanSpec, "@", 2) + pathPart := parts[0] + ref := "main" + if len(parts) == 2 { + ref = parts[1] + } + + // Parse path: owner/repo/path/to/file.md + slashParts := strings.Split(pathPart, "/") + if len(slashParts) < 3 { + return nil + } + + return &remoteImportOrigin{ + Owner: slashParts[0], + Repo: slashParts[1], + Ref: ref, + } } // ProcessImportsFromFrontmatterWithManifest processes imports field from frontmatter @@ -253,15 +294,26 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a return nil, fmt.Errorf("cannot import .lock.yml files: '%s'. Lock files are compiled outputs from gh-aw. Import the source .md file instead", importPath) } + // Track remote origin for workflowspec imports so nested relative imports + // can be resolved against the same remote repository + var origin *remoteImportOrigin + if isWorkflowSpec(filePath) { + origin = parseRemoteOrigin(filePath) + if origin != nil { + importLog.Printf("Tracking remote origin for workflowspec: %s/%s@%s", origin.Owner, origin.Repo, origin.Ref) + } + } + // Check for duplicates before adding to queue if !visited[fullPath] { visited[fullPath] = true queue = append(queue, importQueueItem{ - importPath: importPath, - fullPath: fullPath, - sectionName: sectionName, - baseDir: baseDir, - inputs: importSpec.Inputs, + importPath: importPath, + fullPath: fullPath, + sectionName: sectionName, + baseDir: baseDir, + inputs: importSpec.Inputs, + remoteOrigin: origin, }) log.Printf("Queued import: %s (resolved to %s)", importPath, fullPath) } else { @@ -415,8 +467,8 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a } // Add nested imports to queue (BFS: append to end) - // Use the original baseDir for resolving nested imports, not the nested file's directory - // This ensures that all imports are resolved relative to the workflows directory + // For local imports: resolve relative to the workflows directory (baseDir) + // For remote imports: resolve relative to .github/workflows/ in the remote repo for _, nestedImportPath := range nestedImports { // Handle section references var nestedFilePath, nestedSectionName string @@ -428,8 +480,25 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a nestedFilePath = nestedImportPath } - // Resolve nested import path relative to the workflows directory, not the nested file's directory - nestedFullPath, err := ResolveIncludePath(nestedFilePath, baseDir, cache) + // Determine the resolution path and propagate remote origin context + resolvedPath := nestedFilePath + var nestedRemoteOrigin *remoteImportOrigin + + if item.remoteOrigin != nil && !isWorkflowSpec(nestedFilePath) { + // Parent was fetched from a remote repo and nested path is relative. + // Convert to a workflowspec that resolves against the remote repo's + // .github/workflows/ directory (mirrors local compilation behavior). + cleanPath := strings.TrimPrefix(nestedFilePath, "./") + resolvedPath = fmt.Sprintf("%s/%s/.github/workflows/%s@%s", + item.remoteOrigin.Owner, item.remoteOrigin.Repo, cleanPath, item.remoteOrigin.Ref) + nestedRemoteOrigin = item.remoteOrigin + importLog.Printf("Resolving nested import as remote workflowspec: %s -> %s", nestedFilePath, resolvedPath) + } else if isWorkflowSpec(nestedFilePath) { + // Nested import is itself a workflowspec - parse its remote origin + nestedRemoteOrigin = parseRemoteOrigin(nestedFilePath) + } + + nestedFullPath, err := ResolveIncludePath(resolvedPath, baseDir, cache) if err != nil { // If we have source information for the parent workflow, create a structured error if workflowFilePath != "" && yamlContent != "" { @@ -453,10 +522,11 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a if !visited[nestedFullPath] { visited[nestedFullPath] = true queue = append(queue, importQueueItem{ - importPath: nestedImportPath, - fullPath: nestedFullPath, - sectionName: nestedSectionName, - baseDir: baseDir, // Use original baseDir, not nestedBaseDir + importPath: nestedImportPath, + fullPath: nestedFullPath, + sectionName: nestedSectionName, + baseDir: baseDir, // Use original baseDir, not nestedBaseDir + remoteOrigin: nestedRemoteOrigin, }) log.Printf("Discovered nested import: %s -> %s (queued)", item.fullPath, nestedFullPath) } else { diff --git a/pkg/parser/import_remote_nested_test.go b/pkg/parser/import_remote_nested_test.go new file mode 100644 index 00000000000..9eae85ab9bb --- /dev/null +++ b/pkg/parser/import_remote_nested_test.go @@ -0,0 +1,375 @@ +//go:build !integration + +package parser + +import ( + "fmt" + "os" + pathpkg "path" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseRemoteOrigin(t *testing.T) { + tests := []struct { + name string + spec string + expected *remoteImportOrigin + }{ + { + name: "basic workflowspec with ref", + spec: "elastic/ai-github-actions/gh-agent-workflows/mention-in-pr/rwxp.md@main", + expected: &remoteImportOrigin{ + Owner: "elastic", + Repo: "ai-github-actions", + Ref: "main", + }, + }, + { + name: "workflowspec with SHA ref", + spec: "elastic/ai-github-actions/gh-agent-workflows/mention-in-pr/rwxp.md@160c33700227b5472dc3a08aeea1e774389a1a84", + expected: &remoteImportOrigin{ + Owner: "elastic", + Repo: "ai-github-actions", + Ref: "160c33700227b5472dc3a08aeea1e774389a1a84", + }, + }, + { + name: "workflowspec without ref defaults to main", + spec: "elastic/ai-github-actions/gh-agent-workflows/file.md", + expected: &remoteImportOrigin{ + Owner: "elastic", + Repo: "ai-github-actions", + Ref: "main", + }, + }, + { + name: "workflowspec with section reference", + spec: "owner/repo/path/file.md@v1.0#SectionName", + expected: &remoteImportOrigin{ + Owner: "owner", + Repo: "repo", + Ref: "v1.0", + }, + }, + { + name: "too few parts returns nil", + spec: "owner/repo", + expected: nil, + }, + { + name: "single part returns nil", + spec: "file.md", + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseRemoteOrigin(tt.spec) + if tt.expected == nil { + assert.Nil(t, result, "Expected nil for spec: %s", tt.spec) + } else { + require.NotNil(t, result, "Expected non-nil for spec: %s", tt.spec) + assert.Equal(t, tt.expected.Owner, result.Owner, "Owner mismatch") + assert.Equal(t, tt.expected.Repo, result.Repo, "Repo mismatch") + assert.Equal(t, tt.expected.Ref, result.Ref, "Ref mismatch") + } + }) + } +} + +func TestNestedRemoteImportResolution(t *testing.T) { + // This test verifies that nested relative imports from remote files + // are converted to workflowspecs resolving against the remote repo's + // .github/workflows/ directory. + + // Create a temp directory structure simulating .github/workflows/ + tmpDir := t.TempDir() + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + err := os.MkdirAll(workflowsDir, 0o755) + require.NoError(t, err, "Failed to create workflows directory") + + // Create a shared directory with a local file (this simulates the local repo) + sharedDir := filepath.Join(workflowsDir, "shared") + err = os.MkdirAll(sharedDir, 0o755) + require.NoError(t, err, "Failed to create shared directory") + + localSharedFile := filepath.Join(sharedDir, "local-tools.md") + err = os.WriteFile(localSharedFile, []byte("# Local tools\n"), 0o644) + require.NoError(t, err, "Failed to create local shared file") + + // Create a main workflow that imports a remote file + mainWorkflow := filepath.Join(workflowsDir, "test-workflow.md") + err = os.WriteFile(mainWorkflow, []byte(`--- +imports: + - shared/local-tools.md +--- +# Test workflow +`), 0o644) + require.NoError(t, err, "Failed to create main workflow") + + // Test: local imports resolve correctly (baseline) + frontmatter := map[string]any{ + "imports": []any{"shared/local-tools.md"}, + } + cache := NewImportCache(tmpDir) + result, err := ProcessImportsFromFrontmatterWithManifest(frontmatter, workflowsDir, cache) + require.NoError(t, err, "Local import resolution should succeed") + assert.NotNil(t, result, "Result should not be nil") +} + +func TestRemoteOriginPropagation(t *testing.T) { + // Test that the remote origin is correctly tracked on queue items + // when a top-level import is a workflowspec + + // We can't easily test the full remote fetch flow in a unit test, + // but we can verify the parsing and propagation logic + + t.Run("workflowspec import gets remote origin", func(t *testing.T) { + spec := "elastic/ai-github-actions/gh-agent-workflows/mention-in-pr/rwxp.md@main" + assert.True(t, isWorkflowSpec(spec), "Should be recognized as workflowspec") + + origin := parseRemoteOrigin(spec) + require.NotNil(t, origin, "Should parse remote origin") + assert.Equal(t, "elastic", origin.Owner, "Owner should be elastic") + assert.Equal(t, "ai-github-actions", origin.Repo, "Repo should be ai-github-actions") + assert.Equal(t, "main", origin.Ref, "Ref should be main") + }) + + t.Run("local import does not get remote origin", func(t *testing.T) { + localPath := "shared/tools.md" + assert.False(t, isWorkflowSpec(localPath), "Should not be recognized as workflowspec") + + origin := parseRemoteOrigin(localPath) + assert.Nil(t, origin, "Local paths should not produce remote origin") + }) + + t.Run("nested relative path from remote parent produces correct workflowspec", func(t *testing.T) { + origin := &remoteImportOrigin{ + Owner: "elastic", + Repo: "ai-github-actions", + Ref: "main", + } + nestedPath := "shared/elastic-tools.md" + + // This is the logic from the import processor: + // When parent is remote and nested path is not a workflowspec, + // construct a workflowspec resolving against .github/workflows/ + expectedSpec := fmt.Sprintf("%s/%s/.github/workflows/%s@%s", + origin.Owner, origin.Repo, nestedPath, origin.Ref) + + assert.Equal(t, + "elastic/ai-github-actions/.github/workflows/shared/elastic-tools.md@main", + expectedSpec, + "Nested relative import should resolve to remote .github/workflows/ path", + ) + + // The constructed spec should be recognized as a workflowspec + assert.True(t, isWorkflowSpec(expectedSpec), "Constructed path should be a valid workflowspec") + }) + + t.Run("nested relative path with ./ prefix is cleaned", func(t *testing.T) { + origin := &remoteImportOrigin{ + Owner: "org", + Repo: "repo", + Ref: "v1.0", + } + nestedPath := "./shared/tools.md" + + // Clean the ./ prefix as the import processor does + cleanPath := nestedPath + if len(cleanPath) > 2 && cleanPath[:2] == "./" { + cleanPath = cleanPath[2:] + } + + expectedSpec := fmt.Sprintf("%s/%s/.github/workflows/%s@%s", + origin.Owner, origin.Repo, cleanPath, origin.Ref) + + assert.Equal(t, + "org/repo/.github/workflows/shared/tools.md@v1.0", + expectedSpec, + "Dot-prefix should be stripped when constructing remote spec", + ) + }) + + t.Run("nested workflowspec from remote parent gets its own origin", func(t *testing.T) { + // If a remote file references another workflowspec, it should + // get its own origin, not inherit the parent's + nestedSpec := "other-org/other-repo/path/file.md@v2.0" + assert.True(t, isWorkflowSpec(nestedSpec), "Should be recognized as workflowspec") + + origin := parseRemoteOrigin(nestedSpec) + require.NotNil(t, origin, "Should parse remote origin for nested workflowspec") + assert.Equal(t, "other-org", origin.Owner, "Should use nested spec's owner") + assert.Equal(t, "other-repo", origin.Repo, "Should use nested spec's repo") + assert.Equal(t, "v2.0", origin.Ref, "Should use nested spec's ref") + }) + + t.Run("SHA ref is preserved in nested resolution", func(t *testing.T) { + sha := "160c33700227b5472dc3a08aeea1e774389a1a84" + origin := &remoteImportOrigin{ + Owner: "elastic", + Repo: "ai-github-actions", + Ref: sha, + } + nestedPath := "shared/formatting.md" + + expectedSpec := fmt.Sprintf("%s/%s/.github/workflows/%s@%s", + origin.Owner, origin.Repo, nestedPath, origin.Ref) + + assert.Equal(t, + "elastic/ai-github-actions/.github/workflows/shared/formatting.md@"+sha, + expectedSpec, + "SHA ref should be preserved for nested imports", + ) + }) +} + +func TestImportQueueItemRemoteOriginField(t *testing.T) { + // Verify the struct field exists and works correctly + + t.Run("queue item with nil remote origin", func(t *testing.T) { + item := importQueueItem{ + importPath: "shared/tools.md", + fullPath: "/tmp/tools.md", + sectionName: "", + baseDir: "/workspace/.github/workflows", + remoteOrigin: nil, + } + assert.Nil(t, item.remoteOrigin, "Local import should have nil remote origin") + }) + + t.Run("queue item with remote origin", func(t *testing.T) { + origin := &remoteImportOrigin{ + Owner: "elastic", + Repo: "ai-github-actions", + Ref: "main", + } + item := importQueueItem{ + importPath: "elastic/ai-github-actions/path/file.md@main", + fullPath: "/tmp/cache/file.md", + sectionName: "", + baseDir: "/workspace/.github/workflows", + remoteOrigin: origin, + } + require.NotNil(t, item.remoteOrigin, "Remote import should have non-nil remote origin") + assert.Equal(t, "elastic", item.remoteOrigin.Owner, "Owner should match") + assert.Equal(t, "ai-github-actions", item.remoteOrigin.Repo, "Repo should match") + assert.Equal(t, "main", item.remoteOrigin.Ref, "Ref should match") + }) +} + +func TestIsNotFoundError(t *testing.T) { + tests := []struct { + name string + errMsg string + expected bool + }{ + { + name: "HTTP 404 message", + errMsg: "HTTP 404: Not Found", + expected: true, + }, + { + name: "lowercase not found", + errMsg: "failed to fetch file: not found", + expected: true, + }, + { + name: "404 status code in message", + errMsg: "server returned 404 for request", + expected: true, + }, + { + name: "authentication error", + errMsg: "HTTP 401: Unauthorized", + expected: false, + }, + { + name: "server error", + errMsg: "HTTP 500: Internal Server Error", + expected: false, + }, + { + name: "empty string", + errMsg: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isNotFoundError(tt.errMsg) + assert.Equal(t, tt.expected, result, "isNotFoundError(%q)", tt.errMsg) + }) + } +} + +func TestResolveRemoteSymlinksPathConstruction(t *testing.T) { + // These tests verify the path construction logic of resolveRemoteSymlinks + // without making real API calls. The actual symlink resolution requires + // GitHub API access, which is tested in integration tests. + + t.Run("single component path returns error", func(t *testing.T) { + _, err := resolveRemoteSymlinks("owner", "repo", "file.md", "main") + assert.Error(t, err, "Single component path has no directories to resolve") + }) + + t.Run("symlink target resolution logic", func(t *testing.T) { + // Verify the path math that resolveRemoteSymlinks performs internally. + // Given a symlink at .github/workflows/shared -> ../../gh-agent-workflows/shared, + // the resolution should produce gh-agent-workflows/shared/file.md + + // Simulate: parts = [".github", "workflows", "shared", "elastic-tools.md"] + // Symlink at index 3 (parts[:3] = ".github/workflows/shared") + // Target: "../../gh-agent-workflows/shared" + // Parent: ".github/workflows" + + parentDir := ".github/workflows" + target := "../../gh-agent-workflows/shared" + + // This mirrors the logic in resolveRemoteSymlinks using path.Clean/path.Join + resolvedBase := pathpkg.Clean(pathpkg.Join(parentDir, target)) + remaining := "elastic-tools.md" + resolvedPath := resolvedBase + "/" + remaining + + assert.Equal(t, "gh-agent-workflows/shared/elastic-tools.md", resolvedPath, + "Symlink at .github/workflows/shared pointing to ../../gh-agent-workflows/shared should resolve correctly") + }) + + t.Run("symlink at first component", func(t *testing.T) { + // Simulate: parts = ["link-dir", "subdir", "file.md"] + // Symlink at index 1 (parts[:1] = "link-dir") + // Target: "actual-dir" + // Parent: "" (root) + + target := "actual-dir" + resolvedBase := pathpkg.Clean(target) + remaining := "subdir/file.md" + resolvedPath := resolvedBase + "/" + remaining + + assert.Equal(t, "actual-dir/subdir/file.md", resolvedPath, + "Symlink at root level should resolve correctly") + }) + + t.Run("nested symlink resolution", func(t *testing.T) { + // Simulate: parts = ["gh-agent-workflows", "gh-aw-workflows", "file.md"] + // Symlink at index 2 (parts[:2] = "gh-agent-workflows/gh-aw-workflows") + // Target: "../.github/workflows/gh-aw-workflows" + // Parent: "gh-agent-workflows" + + parentDir := "gh-agent-workflows" + target := "../.github/workflows/gh-aw-workflows" + + resolvedBase := pathpkg.Clean(pathpkg.Join(parentDir, target)) + remaining := "file.md" + resolvedPath := resolvedBase + "/" + remaining + + assert.Equal(t, ".github/workflows/gh-aw-workflows/file.md", resolvedPath, + "Nested symlink with ../ target should resolve correctly") + }) +} diff --git a/pkg/parser/remote_fetch.go b/pkg/parser/remote_fetch.go index e2fbc0b8155..9281a8331ae 100644 --- a/pkg/parser/remote_fetch.go +++ b/pkg/parser/remote_fetch.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/exec" + pathpkg "path" "path/filepath" "strings" @@ -470,7 +471,97 @@ func downloadFileViaGitClone(owner, repo, path, ref string) ([]byte, error) { return content, nil } +// isNotFoundError checks if an error message indicates a 404 Not Found response +func isNotFoundError(errMsg string) bool { + lowerMsg := strings.ToLower(errMsg) + return strings.Contains(lowerMsg, "404") || strings.Contains(lowerMsg, "not found") +} + +// checkRemoteSymlink checks if a path in a remote GitHub repository is a symlink. +// Returns the symlink target and true if it is a symlink, or empty string and false otherwise. +func checkRemoteSymlink(owner, repo, dirPath, ref string) (string, bool, error) { + client, err := api.DefaultRESTClient() + if err != nil { + return "", false, fmt.Errorf("failed to create REST client: %w", err) + } + + var result struct { + Type string `json:"type"` + Target string `json:"target"` + } + + endpoint := fmt.Sprintf("repos/%s/%s/contents/%s?ref=%s", owner, repo, dirPath, ref) + err = client.Get(endpoint, &result) + if err != nil { + return "", false, err + } + + if result.Type == "symlink" && result.Target != "" { + return result.Target, true, nil + } + + return "", false, nil +} + +// resolveRemoteSymlinks resolves symlinks in a remote GitHub repository path. +// The GitHub Contents API doesn't follow symlinks in path components. For example, +// if .github/workflows/shared is a symlink to ../../gh-agent-workflows/shared, +// fetching .github/workflows/shared/elastic-tools.md returns 404. +// This function walks the path components and resolves any symlinks found. +func resolveRemoteSymlinks(owner, repo, filePath, ref string) (string, error) { + parts := strings.Split(filePath, "/") + if len(parts) <= 1 { + return "", fmt.Errorf("no directory components to resolve in path: %s", filePath) + } + + // Check each directory prefix (not including the final filename) to find symlinks + for i := 1; i < len(parts); i++ { + dirPath := strings.Join(parts[:i], "/") + + target, isSymlink, err := checkRemoteSymlink(owner, repo, dirPath, ref) + if err != nil { + // Not a symlink or API error — skip and try the next component + continue + } + + if isSymlink { + // Resolve the symlink target relative to the symlink's parent directory. + // For example, if .github/workflows/shared is a symlink to ../../gh-agent-workflows/shared, + // the parent is .github/workflows and the resolved base is gh-agent-workflows/shared. + parentDir := "" + if i > 1 { + parentDir = strings.Join(parts[:i-1], "/") + } + + var resolvedBase string + if parentDir != "" { + resolvedBase = pathpkg.Clean(pathpkg.Join(parentDir, target)) + } else { + resolvedBase = pathpkg.Clean(target) + } + + // Reconstruct the full path with the resolved symlink + remaining := strings.Join(parts[i:], "/") + resolvedPath := resolvedBase + "/" + remaining + + remoteLog.Printf("Resolved symlink in remote path: %s -> %s (full: %s -> %s)", + dirPath, target, filePath, resolvedPath) + + return resolvedPath, nil + } + } + + return "", fmt.Errorf("no symlinks found in path: %s", filePath) +} + func downloadFileFromGitHub(owner, repo, path, ref string) ([]byte, error) { + return downloadFileFromGitHubWithDepth(owner, repo, path, ref, 0) +} + +// maxSymlinkDepth limits recursive symlink resolution to prevent infinite loops +const maxSymlinkDepth = 5 + +func downloadFileFromGitHubWithDepth(owner, repo, path, ref string, symlinkDepth int) ([]byte, error) { // Create REST client client, err := api.DefaultRESTClient() if err != nil { @@ -487,8 +578,9 @@ func downloadFileFromGitHub(owner, repo, path, ref string) ([]byte, error) { // Fetch file content from GitHub API err = client.Get(fmt.Sprintf("repos/%s/%s/contents/%s?ref=%s", owner, repo, path, ref), &fileContent) if err != nil { - // Check if this is an authentication error errStr := err.Error() + + // Check if this is an authentication error if gitutil.IsAuthError(errStr) { remoteLog.Printf("GitHub API authentication failed, attempting git fallback for %s/%s/%s@%s", owner, repo, path, ref) // Try fallback using git commands for public repositories @@ -499,6 +591,17 @@ func downloadFileFromGitHub(owner, repo, path, ref string) ([]byte, error) { } return content, nil } + + // Check if this is a 404 — the path may traverse a symlink that the API doesn't follow + if isNotFoundError(errStr) && symlinkDepth < maxSymlinkDepth { + remoteLog.Printf("File not found at %s/%s/%s@%s, checking for symlinks in path (depth: %d)", owner, repo, path, ref, symlinkDepth) + resolvedPath, resolveErr := resolveRemoteSymlinks(owner, repo, path, ref) + if resolveErr == nil && resolvedPath != path { + remoteLog.Printf("Retrying download with symlink-resolved path: %s -> %s", path, resolvedPath) + return downloadFileFromGitHubWithDepth(owner, repo, resolvedPath, ref, symlinkDepth+1) + } + } + return nil, fmt.Errorf("failed to fetch file content from %s/%s/%s@%s: %w", owner, repo, path, ref, err) } From b9ce27aa27ebb91d44a297faccfb5e0e27e7d3c2 Mon Sep 17 00:00:00 2001 From: William Easton Date: Sun, 15 Feb 2026 15:21:56 -0600 Subject: [PATCH 2/5] Fixes from PR Feedback --- pkg/parser/import_processor.go | 9 ++++- pkg/parser/import_remote_nested_test.go | 44 +++++++++++------------ pkg/parser/remote_fetch.go | 47 +++++++++++++++++++------ 3 files changed, 65 insertions(+), 35 deletions(-) diff --git a/pkg/parser/import_processor.go b/pkg/parser/import_processor.go index e76150f6735..90baba22ac3 100644 --- a/pkg/parser/import_processor.go +++ b/pkg/parser/import_processor.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "path" "sort" "strings" @@ -488,7 +489,13 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a // Parent was fetched from a remote repo and nested path is relative. // Convert to a workflowspec that resolves against the remote repo's // .github/workflows/ directory (mirrors local compilation behavior). - cleanPath := strings.TrimPrefix(nestedFilePath, "./") + cleanPath := path.Clean(strings.TrimPrefix(nestedFilePath, "./")) + + // Reject paths that escape .github/workflows/ (e.g., ../../../etc/passwd) + if cleanPath == ".." || strings.HasPrefix(cleanPath, "../") || path.IsAbs(cleanPath) { + return nil, fmt.Errorf("nested import '%s' from remote file '%s' escapes .github/workflows/ base directory", nestedFilePath, item.importPath) + } + resolvedPath = fmt.Sprintf("%s/%s/.github/workflows/%s@%s", item.remoteOrigin.Owner, item.remoteOrigin.Repo, cleanPath, item.remoteOrigin.Ref) nestedRemoteOrigin = item.remoteOrigin diff --git a/pkg/parser/import_remote_nested_test.go b/pkg/parser/import_remote_nested_test.go index 9eae85ab9bb..8562c5c1152 100644 --- a/pkg/parser/import_remote_nested_test.go +++ b/pkg/parser/import_remote_nested_test.go @@ -5,8 +5,9 @@ package parser import ( "fmt" "os" - pathpkg "path" + "path" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -71,9 +72,9 @@ func TestParseRemoteOrigin(t *testing.T) { t.Run(tt.name, func(t *testing.T) { result := parseRemoteOrigin(tt.spec) if tt.expected == nil { - assert.Nil(t, result, "Expected nil for spec: %s", tt.spec) + assert.Nilf(t, result, "Expected nil for spec: %s", tt.spec) } else { - require.NotNil(t, result, "Expected non-nil for spec: %s", tt.spec) + require.NotNilf(t, result, "Expected non-nil for spec: %s", tt.spec) assert.Equal(t, tt.expected.Owner, result.Owner, "Owner mismatch") assert.Equal(t, tt.expected.Repo, result.Repo, "Repo mismatch") assert.Equal(t, tt.expected.Ref, result.Ref, "Ref mismatch") @@ -82,18 +83,16 @@ func TestParseRemoteOrigin(t *testing.T) { } } -func TestNestedRemoteImportResolution(t *testing.T) { - // This test verifies that nested relative imports from remote files - // are converted to workflowspecs resolving against the remote repo's - // .github/workflows/ directory. +func TestLocalImportResolutionBaseline(t *testing.T) { + // Baseline test: verifies local relative imports resolve correctly. + // This ensures the import processor still works for non-remote imports + // after the remote origin tracking changes. - // Create a temp directory structure simulating .github/workflows/ tmpDir := t.TempDir() workflowsDir := filepath.Join(tmpDir, ".github", "workflows") err := os.MkdirAll(workflowsDir, 0o755) require.NoError(t, err, "Failed to create workflows directory") - // Create a shared directory with a local file (this simulates the local repo) sharedDir := filepath.Join(workflowsDir, "shared") err = os.MkdirAll(sharedDir, 0o755) require.NoError(t, err, "Failed to create shared directory") @@ -102,17 +101,6 @@ func TestNestedRemoteImportResolution(t *testing.T) { err = os.WriteFile(localSharedFile, []byte("# Local tools\n"), 0o644) require.NoError(t, err, "Failed to create local shared file") - // Create a main workflow that imports a remote file - mainWorkflow := filepath.Join(workflowsDir, "test-workflow.md") - err = os.WriteFile(mainWorkflow, []byte(`--- -imports: - - shared/local-tools.md ---- -# Test workflow -`), 0o644) - require.NoError(t, err, "Failed to create main workflow") - - // Test: local imports resolve correctly (baseline) frontmatter := map[string]any{ "imports": []any{"shared/local-tools.md"}, } @@ -209,6 +197,16 @@ func TestRemoteOriginPropagation(t *testing.T) { assert.Equal(t, "v2.0", origin.Ref, "Should use nested spec's ref") }) + t.Run("path traversal in nested import is rejected", func(t *testing.T) { + // A nested import like ../../../etc/passwd should be rejected + // when constructing the remote workflowspec + nestedPath := "../../../etc/passwd" + cleanPath := path.Clean(strings.TrimPrefix(nestedPath, "./")) + + assert.True(t, strings.HasPrefix(cleanPath, ".."), + "Cleaned path should start with .. and be rejected by the import processor") + }) + t.Run("SHA ref is preserved in nested resolution", func(t *testing.T) { sha := "160c33700227b5472dc3a08aeea1e774389a1a84" origin := &remoteImportOrigin{ @@ -333,7 +331,7 @@ func TestResolveRemoteSymlinksPathConstruction(t *testing.T) { target := "../../gh-agent-workflows/shared" // This mirrors the logic in resolveRemoteSymlinks using path.Clean/path.Join - resolvedBase := pathpkg.Clean(pathpkg.Join(parentDir, target)) + resolvedBase := path.Clean(path.Join(parentDir, target)) remaining := "elastic-tools.md" resolvedPath := resolvedBase + "/" + remaining @@ -348,7 +346,7 @@ func TestResolveRemoteSymlinksPathConstruction(t *testing.T) { // Parent: "" (root) target := "actual-dir" - resolvedBase := pathpkg.Clean(target) + resolvedBase := path.Clean(target) remaining := "subdir/file.md" resolvedPath := resolvedBase + "/" + remaining @@ -365,7 +363,7 @@ func TestResolveRemoteSymlinksPathConstruction(t *testing.T) { parentDir := "gh-agent-workflows" target := "../.github/workflows/gh-aw-workflows" - resolvedBase := pathpkg.Clean(pathpkg.Join(parentDir, target)) + resolvedBase := path.Clean(path.Join(parentDir, target)) remaining := "file.md" resolvedPath := resolvedBase + "/" + remaining diff --git a/pkg/parser/remote_fetch.go b/pkg/parser/remote_fetch.go index 9281a8331ae..3c4c02ee421 100644 --- a/pkg/parser/remote_fetch.go +++ b/pkg/parser/remote_fetch.go @@ -2,6 +2,7 @@ package parser import ( "encoding/base64" + "encoding/json" "fmt" "os" "os/exec" @@ -479,21 +480,31 @@ func isNotFoundError(errMsg string) bool { // checkRemoteSymlink checks if a path in a remote GitHub repository is a symlink. // Returns the symlink target and true if it is a symlink, or empty string and false otherwise. -func checkRemoteSymlink(owner, repo, dirPath, ref string) (string, bool, error) { - client, err := api.DefaultRESTClient() +// A nil error with false means the path is not a symlink (e.g., it's a directory or file). +func checkRemoteSymlink(client *api.RESTClient, owner, repo, dirPath, ref string) (string, bool, error) { + endpoint := fmt.Sprintf("repos/%s/%s/contents/%s?ref=%s", owner, repo, dirPath, ref) + + // The Contents API returns a JSON object for files/symlinks but a JSON array for directories. + // Decode into json.RawMessage first to distinguish these cases without error-driven control flow. + var raw json.RawMessage + err := client.Get(endpoint, &raw) if err != nil { - return "", false, fmt.Errorf("failed to create REST client: %w", err) + return "", false, err } + // If the response is an array, this is a directory listing — not a symlink + trimmed := strings.TrimSpace(string(raw)) + if len(trimmed) > 0 && trimmed[0] == '[' { + return "", false, nil + } + + // Parse the object response to check the type var result struct { Type string `json:"type"` Target string `json:"target"` } - - endpoint := fmt.Sprintf("repos/%s/%s/contents/%s?ref=%s", owner, repo, dirPath, ref) - err = client.Get(endpoint, &result) - if err != nil { - return "", false, err + if err := json.Unmarshal(raw, &result); err != nil { + return "", false, fmt.Errorf("failed to parse contents response for %s: %w", dirPath, err) } if result.Type == "symlink" && result.Target != "" { @@ -514,14 +525,23 @@ func resolveRemoteSymlinks(owner, repo, filePath, ref string) (string, error) { return "", fmt.Errorf("no directory components to resolve in path: %s", filePath) } + client, err := api.DefaultRESTClient() + if err != nil { + return "", fmt.Errorf("failed to create REST client: %w", err) + } + // Check each directory prefix (not including the final filename) to find symlinks for i := 1; i < len(parts); i++ { dirPath := strings.Join(parts[:i], "/") - target, isSymlink, err := checkRemoteSymlink(owner, repo, dirPath, ref) + target, isSymlink, err := checkRemoteSymlink(client, owner, repo, dirPath, ref) if err != nil { - // Not a symlink or API error — skip and try the next component - continue + // Only ignore 404s (path component doesn't exist yet at this prefix level). + // Propagate real API failures (auth, rate limit, network) immediately. + if isNotFoundError(err.Error()) { + continue + } + return "", fmt.Errorf("failed to check path component %s for symlinks: %w", dirPath, err) } if isSymlink { @@ -540,6 +560,11 @@ func resolveRemoteSymlinks(owner, repo, filePath, ref string) (string, error) { resolvedBase = pathpkg.Clean(target) } + // Validate the resolved base doesn't escape the repository root + if resolvedBase == "" || resolvedBase == "." || pathpkg.IsAbs(resolvedBase) || strings.HasPrefix(resolvedBase, "..") { + return "", fmt.Errorf("symlink target %q at %s resolves outside repository root: %s", target, dirPath, resolvedBase) + } + // Reconstruct the full path with the resolved symlink remaining := strings.Join(parts[i:], "/") resolvedPath := resolvedBase + "/" + remaining From 319ec73a80b6fce9437846a1f5d5e867893d8751 Mon Sep 17 00:00:00 2001 From: William Easton Date: Sun, 15 Feb 2026 16:35:22 -0600 Subject: [PATCH 3/5] Add more path resolution logging --- pkg/parser/import_processor.go | 3 +++ pkg/parser/remote_fetch.go | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/pkg/parser/import_processor.go b/pkg/parser/import_processor.go index 90baba22ac3..32a74f478e1 100644 --- a/pkg/parser/import_processor.go +++ b/pkg/parser/import_processor.go @@ -503,6 +503,9 @@ func processImportsFromFrontmatterWithManifestAndSource(frontmatter map[string]a } else if isWorkflowSpec(nestedFilePath) { // Nested import is itself a workflowspec - parse its remote origin nestedRemoteOrigin = parseRemoteOrigin(nestedFilePath) + if nestedRemoteOrigin != nil { + importLog.Printf("Nested workflowspec import detected: %s (origin: %s/%s@%s)", nestedFilePath, nestedRemoteOrigin.Owner, nestedRemoteOrigin.Repo, nestedRemoteOrigin.Ref) + } } nestedFullPath, err := ResolveIncludePath(resolvedPath, baseDir, cache) diff --git a/pkg/parser/remote_fetch.go b/pkg/parser/remote_fetch.go index 3c4c02ee421..c89e3e2f837 100644 --- a/pkg/parser/remote_fetch.go +++ b/pkg/parser/remote_fetch.go @@ -483,18 +483,21 @@ func isNotFoundError(errMsg string) bool { // A nil error with false means the path is not a symlink (e.g., it's a directory or file). func checkRemoteSymlink(client *api.RESTClient, owner, repo, dirPath, ref string) (string, bool, error) { endpoint := fmt.Sprintf("repos/%s/%s/contents/%s?ref=%s", owner, repo, dirPath, ref) + remoteLog.Printf("Checking if path component is symlink: %s/%s/%s@%s", owner, repo, dirPath, ref) // The Contents API returns a JSON object for files/symlinks but a JSON array for directories. // Decode into json.RawMessage first to distinguish these cases without error-driven control flow. var raw json.RawMessage err := client.Get(endpoint, &raw) if err != nil { + remoteLog.Printf("Contents API error for %s: %v", dirPath, err) return "", false, err } // If the response is an array, this is a directory listing — not a symlink trimmed := strings.TrimSpace(string(raw)) if len(trimmed) > 0 && trimmed[0] == '[' { + remoteLog.Printf("Path component %s is a directory (not a symlink)", dirPath) return "", false, nil } @@ -508,9 +511,11 @@ func checkRemoteSymlink(client *api.RESTClient, owner, repo, dirPath, ref string } if result.Type == "symlink" && result.Target != "" { + remoteLog.Printf("Path component %s is a symlink -> %s", dirPath, result.Target) return result.Target, true, nil } + remoteLog.Printf("Path component %s is type=%s (not a symlink)", dirPath, result.Type) return "", false, nil } @@ -525,6 +530,8 @@ func resolveRemoteSymlinks(owner, repo, filePath, ref string) (string, error) { return "", fmt.Errorf("no directory components to resolve in path: %s", filePath) } + remoteLog.Printf("Attempting symlink resolution for %s/%s/%s@%s (%d path components)", owner, repo, filePath, ref, len(parts)) + client, err := api.DefaultRESTClient() if err != nil { return "", fmt.Errorf("failed to create REST client: %w", err) @@ -539,6 +546,7 @@ func resolveRemoteSymlinks(owner, repo, filePath, ref string) (string, error) { // Only ignore 404s (path component doesn't exist yet at this prefix level). // Propagate real API failures (auth, rate limit, network) immediately. if isNotFoundError(err.Error()) { + remoteLog.Printf("Path component %s returned 404, skipping", dirPath) continue } return "", fmt.Errorf("failed to check path component %s for symlinks: %w", dirPath, err) @@ -553,6 +561,8 @@ func resolveRemoteSymlinks(owner, repo, filePath, ref string) (string, error) { parentDir = strings.Join(parts[:i-1], "/") } + remoteLog.Printf("Resolving symlink: component=%s target=%s parentDir=%s", dirPath, target, parentDir) + var resolvedBase string if parentDir != "" { resolvedBase = pathpkg.Clean(pathpkg.Join(parentDir, target)) @@ -560,8 +570,11 @@ func resolveRemoteSymlinks(owner, repo, filePath, ref string) (string, error) { resolvedBase = pathpkg.Clean(target) } + remoteLog.Printf("Resolved base after path.Clean: %s", resolvedBase) + // Validate the resolved base doesn't escape the repository root if resolvedBase == "" || resolvedBase == "." || pathpkg.IsAbs(resolvedBase) || strings.HasPrefix(resolvedBase, "..") { + remoteLog.Printf("Rejecting resolved base %q (escapes repository root)", resolvedBase) return "", fmt.Errorf("symlink target %q at %s resolves outside repository root: %s", target, dirPath, resolvedBase) } @@ -576,6 +589,7 @@ func resolveRemoteSymlinks(owner, repo, filePath, ref string) (string, error) { } } + remoteLog.Printf("No symlinks found after checking all %d directory components of %s", len(parts)-1, filePath) return "", fmt.Errorf("no symlinks found in path: %s", filePath) } From e21554dc8bdc5c761c1e11357b0580af656873f2 Mon Sep 17 00:00:00 2001 From: William Easton Date: Sun, 15 Feb 2026 17:04:22 -0600 Subject: [PATCH 4/5] PR Feedback --- pkg/constants/constants.go | 6 ++ pkg/parser/remote_fetch.go | 6 +- pkg/parser/remote_fetch_integration_test.go | 95 +++++++++++++++++++++ 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index f80782a1f9d..97a5bfeeb51 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -870,6 +870,12 @@ func GetWorkflowDir() string { return filepath.Join(".github", "workflows") } +// MaxSymlinkDepth limits recursive symlink resolution when fetching remote files. +// The GitHub Contents API doesn't follow symlinks in path components, so gh-aw +// resolves them manually. This constant caps recursion to prevent infinite loops +// when symlinks chain to each other. +const MaxSymlinkDepth = 5 + // DefaultAllowedMemoryExtensions is the default list of allowed file extensions for cache-memory and repo-memory storage. // An empty slice means all file extensions are allowed. When this is empty, the validation step is not emitted. var DefaultAllowedMemoryExtensions = []string{} diff --git a/pkg/parser/remote_fetch.go b/pkg/parser/remote_fetch.go index c89e3e2f837..b2f8c4ce592 100644 --- a/pkg/parser/remote_fetch.go +++ b/pkg/parser/remote_fetch.go @@ -12,6 +12,7 @@ import ( "github.com/cli/go-gh/v2" "github.com/cli/go-gh/v2/pkg/api" + "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/gitutil" "github.com/github/gh-aw/pkg/logger" ) @@ -597,9 +598,6 @@ func downloadFileFromGitHub(owner, repo, path, ref string) ([]byte, error) { return downloadFileFromGitHubWithDepth(owner, repo, path, ref, 0) } -// maxSymlinkDepth limits recursive symlink resolution to prevent infinite loops -const maxSymlinkDepth = 5 - func downloadFileFromGitHubWithDepth(owner, repo, path, ref string, symlinkDepth int) ([]byte, error) { // Create REST client client, err := api.DefaultRESTClient() @@ -632,7 +630,7 @@ func downloadFileFromGitHubWithDepth(owner, repo, path, ref string, symlinkDepth } // Check if this is a 404 — the path may traverse a symlink that the API doesn't follow - if isNotFoundError(errStr) && symlinkDepth < maxSymlinkDepth { + if isNotFoundError(errStr) && symlinkDepth < constants.MaxSymlinkDepth { remoteLog.Printf("File not found at %s/%s/%s@%s, checking for symlinks in path (depth: %d)", owner, repo, path, ref, symlinkDepth) resolvedPath, resolveErr := resolveRemoteSymlinks(owner, repo, path, ref) if resolveErr == nil && resolvedPath != path { diff --git a/pkg/parser/remote_fetch_integration_test.go b/pkg/parser/remote_fetch_integration_test.go index 24e04fede87..834edda3982 100644 --- a/pkg/parser/remote_fetch_integration_test.go +++ b/pkg/parser/remote_fetch_integration_test.go @@ -5,6 +5,10 @@ package parser import ( "strings" "testing" + + "github.com/cli/go-gh/v2/pkg/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // TestDownloadFileFromGitHubRESTClient tests the REST client-based file download @@ -142,6 +146,97 @@ func TestResolveIncludePathWithWorkflowSpec(t *testing.T) { } } +// skipOnAuthError skips the test if the error indicates missing authentication. +func skipOnAuthError(t *testing.T, err error) { + t.Helper() + if err == nil { + return + } + errStr := err.Error() + if strings.Contains(errStr, "auth") || strings.Contains(errStr, "forbidden") || strings.Contains(errStr, "authentication token not found") { + t.Skip("Skipping test due to authentication requirements") + } +} + +// TestCheckRemoteSymlink verifies that checkRemoteSymlink correctly classifies +// directories, files, and nonexistent paths when called against the GitHub Contents API. +func TestCheckRemoteSymlink(t *testing.T) { + client, err := api.DefaultRESTClient() + if err != nil { + skipOnAuthError(t, err) + t.Fatalf("Failed to create REST client: %v", err) + } + + tests := []struct { + name string + dirPath string + wantSymlink bool + wantErr bool + }{ + { + name: "directory is not a symlink", + dirPath: "Global", + wantSymlink: false, + wantErr: false, + }, + { + name: "regular file is not a symlink", + dirPath: "Go.gitignore", + wantSymlink: false, + wantErr: false, + }, + { + name: "nonexistent path returns error", + dirPath: "nonexistent-path-xyz", + wantSymlink: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + target, isSymlink, err := checkRemoteSymlink(client, "github", "gitignore", tt.dirPath, "main") + if err != nil { + skipOnAuthError(t, err) + if !tt.wantErr { + t.Fatalf("Unexpected error for path %q: %v", tt.dirPath, err) + } + if tt.dirPath == "nonexistent-path-xyz" { + assert.True(t, isNotFoundError(err.Error()), "Expected a not-found error, got: %v", err) + } + return + } + + assert.False(t, tt.wantErr, "Expected error for path %q but got none", tt.dirPath) + assert.Equal(t, tt.wantSymlink, isSymlink, "Symlink mismatch for path %q (target=%q)", tt.dirPath, target) + }) + } +} + +// TestResolveRemoteSymlinksNoSymlinks verifies that resolveRemoteSymlinks walks all +// directory components of a real path and returns "no symlinks found" when none exist. +func TestResolveRemoteSymlinksNoSymlinks(t *testing.T) { + // "Global/Perl.gitignore" is a real path in github/gitignore with no symlinks + _, err := resolveRemoteSymlinks("github", "gitignore", "Global/Perl.gitignore", "main") + require.Error(t, err, "Expected error when no symlinks found") + skipOnAuthError(t, err) + + assert.Contains(t, err.Error(), "no symlinks found", "Should indicate no symlinks were found in path") +} + +// TestDownloadFileFromGitHubSymlinkRoute verifies that downloading a nonexistent file +// through a real directory triggers the symlink resolution fallback and ultimately +// returns the original fetch error (not a panic or hang). +func TestDownloadFileFromGitHubSymlinkRoute(t *testing.T) { + // Use a path through a real directory but with a nonexistent file. + // This triggers: 404 -> symlink resolution -> "no symlinks found" -> original error. + _, err := downloadFileFromGitHub("github", "gitignore", "Global/nonexistent-file-xyz123.gitignore", "main") + require.Error(t, err, "Expected error for nonexistent file") + skipOnAuthError(t, err) + + assert.Contains(t, err.Error(), "failed to fetch file content", "Should return the original fetch failure") +} + // TestDownloadIncludeFromWorkflowSpecWithCache tests caching behavior func TestDownloadIncludeFromWorkflowSpecWithCache(t *testing.T) { cache := NewImportCache(t.TempDir()) From 8ac7088e6e8f9e4041d2428e15a002d3ac415310 Mon Sep 17 00:00:00 2001 From: William Easton Date: Sun, 15 Feb 2026 17:06:23 -0600 Subject: [PATCH 5/5] Make the linter happy --- pkg/parser/import_remote_nested_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/parser/import_remote_nested_test.go b/pkg/parser/import_remote_nested_test.go index 8562c5c1152..36d2dc3369a 100644 --- a/pkg/parser/import_remote_nested_test.go +++ b/pkg/parser/import_remote_nested_test.go @@ -216,12 +216,12 @@ func TestRemoteOriginPropagation(t *testing.T) { } nestedPath := "shared/formatting.md" - expectedSpec := fmt.Sprintf("%s/%s/.github/workflows/%s@%s", + resolvedSpec := fmt.Sprintf("%s/%s/.github/workflows/%s@%s", origin.Owner, origin.Repo, nestedPath, origin.Ref) assert.Equal(t, "elastic/ai-github-actions/.github/workflows/shared/formatting.md@"+sha, - expectedSpec, + resolvedSpec, "SHA ref should be preserved for nested imports", ) })