diff --git a/pkg/cli/compile_compiler_setup.go b/pkg/cli/compile_compiler_setup.go index b8132a9cdf4..a4afde117a8 100644 --- a/pkg/cli/compile_compiler_setup.go +++ b/pkg/cli/compile_compiler_setup.go @@ -90,6 +90,7 @@ func createAndConfigureCompiler(config CompileConfig) *workflow.Compiler { } // Create compiler with auto-detected version and action mode + // Git root is now auto-detected in NewCompiler() for all compiler instances compiler := workflow.NewCompiler( workflow.WithVerbose(config.Verbose), workflow.WithEngineOverride(config.EngineOverride), diff --git a/pkg/cli/compile_integration_test.go b/pkg/cli/compile_integration_test.go index 82d6791edb0..1797a376e75 100644 --- a/pkg/cli/compile_integration_test.go +++ b/pkg/cli/compile_integration_test.go @@ -801,3 +801,95 @@ This workflow tests that invalid schedule strings in array format fail compilati t.Logf("Integration test passed - invalid schedule in array format correctly failed compilation\nOutput: %s", outputStr) } + +// TestCompileFromSubdirectoryCreatesActionsLockAtRoot tests that actions-lock.json +// is created at the repository root when compiling from a subdirectory +func TestCompileFromSubdirectoryCreatesActionsLockAtRoot(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + // Initialize git repository (required for git root detection) + gitInitCmd := exec.Command("git", "init") + gitInitCmd.Dir = setup.tempDir + if output, err := gitInitCmd.CombinedOutput(); err != nil { + t.Fatalf("Failed to initialize git repository: %v\nOutput: %s", err, string(output)) + } + + // Configure git user for the repository + gitConfigEmail := exec.Command("git", "config", "user.email", "test@test.com") + gitConfigEmail.Dir = setup.tempDir + if output, err := gitConfigEmail.CombinedOutput(); err != nil { + t.Fatalf("Failed to configure git user email: %v\nOutput: %s", err, string(output)) + } + + gitConfigName := exec.Command("git", "config", "user.name", "Test User") + gitConfigName.Dir = setup.tempDir + if output, err := gitConfigName.CombinedOutput(); err != nil { + t.Fatalf("Failed to configure git user name: %v\nOutput: %s", err, string(output)) + } + + // Create a simple test workflow + // Note: actions-lock.json is only created when actions need to be pinned, + // so it may or may not exist. The test verifies it's NOT created in the wrong location. + testWorkflow := `--- +name: Test Workflow +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +--- + +# Test Workflow + +Test workflow to verify actions-lock.json path handling when compiling from subdirectory. +` + + testWorkflowPath := filepath.Join(setup.workflowsDir, "test-action.md") + if err := os.WriteFile(testWorkflowPath, []byte(testWorkflow), 0644); err != nil { + t.Fatalf("Failed to write test workflow file: %v", err) + } + + // Change to the .github/workflows subdirectory + if err := os.Chdir(setup.workflowsDir); err != nil { + t.Fatalf("Failed to change to workflows subdirectory: %v", err) + } + + // Run the compile command from the subdirectory using a relative path + cmd := exec.Command(setup.binaryPath, "compile", "test-action.md") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("CLI compile command failed: %v\nOutput: %s", err, string(output)) + } + + // Change back to the temp directory root + if err := os.Chdir(setup.tempDir); err != nil { + t.Fatalf("Failed to change back to temp directory: %v", err) + } + + // Verify actions-lock.json is created at the repository root (.github/aw/actions-lock.json) + // NOT at .github/workflows/.github/aw/actions-lock.json + expectedLockPath := filepath.Join(setup.tempDir, ".github", "aw", "actions-lock.json") + wrongLockPath := filepath.Join(setup.workflowsDir, ".github", "aw", "actions-lock.json") + + // Check if actions-lock.json exists (it may or may not, depending on whether actions were pinned) + // The important part is that if it exists, it's in the right place + if _, err := os.Stat(expectedLockPath); err == nil { + t.Logf("actions-lock.json correctly created at repo root: %s", expectedLockPath) + } else if !os.IsNotExist(err) { + t.Fatalf("Failed to check for actions-lock.json at expected path: %v", err) + } + + // Verify actions-lock.json was NOT created in the wrong location + if _, err := os.Stat(wrongLockPath); err == nil { + t.Errorf("actions-lock.json incorrectly created at nested path: %s (should be at repo root)", wrongLockPath) + } + + // Verify the workflow lock file was created + lockFilePath := filepath.Join(setup.workflowsDir, "test-action.lock.yml") + if _, err := os.Stat(lockFilePath); os.IsNotExist(err) { + t.Fatalf("Expected lock file %s was not created", lockFilePath) + } + + t.Logf("Integration test passed - actions-lock.json created at correct location") +} diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 07a8b3759c9..3bed652e2eb 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -72,6 +72,11 @@ func WithRepositorySlug(slug string) CompilerOption { return func(c *Compiler) { c.repositorySlug = slug } } +// WithGitRoot sets the git repository root directory for action cache path +func WithGitRoot(gitRoot string) CompilerOption { + return func(c *Compiler) { c.gitRoot = gitRoot } +} + // FileTracker interface for tracking files created during compilation type FileTracker interface { TrackCreated(filePath string) @@ -125,6 +130,7 @@ type Compiler struct { repositorySlug string // Repository slug (owner/repo) used as seed for scattering artifactManager *ArtifactManager // Tracks artifact uploads/downloads for validation scheduleFriendlyFormats map[int]string // Maps schedule item index to friendly format string for current workflow + gitRoot string // Git repository root directory (if set, used for action cache path) } // NewCompiler creates a new workflow compiler with functional options. @@ -134,6 +140,10 @@ func NewCompiler(opts ...CompilerOption) *Compiler { // Get default version version := defaultVersion + // Auto-detect git repository root for action cache path resolution + // This ensures actions-lock.json is created at repo root regardless of CWD + gitRoot := findGitRoot() + // Create compiler with defaults c := &Compiler{ verbose: false, @@ -146,6 +156,7 @@ func NewCompiler(opts ...CompilerOption) *Compiler { stepOrderTracker: NewStepOrderTracker(), artifactManager: NewArtifactManager(), actionPinWarnings: make(map[string]bool), // Initialize warning cache + gitRoot: gitRoot, // Auto-detected git root } // Apply functional options @@ -283,11 +294,16 @@ func (c *Compiler) GetScheduleWarnings() []string { func (c *Compiler) getSharedActionResolver() (*ActionCache, *ActionResolver) { if c.actionCache == nil { // Initialize cache and resolver on first use - cwd, err := os.Getwd() - if err != nil { - cwd = "." + // Use git root if provided, otherwise fall back to current working directory + baseDir := c.gitRoot + if baseDir == "" { + cwd, err := os.Getwd() + if err != nil { + cwd = "." + } + baseDir = cwd } - c.actionCache = NewActionCache(cwd) + c.actionCache = NewActionCache(baseDir) // Load existing cache unless force refresh is enabled if !c.forceRefreshActionPins { diff --git a/pkg/workflow/git_helpers.go b/pkg/workflow/git_helpers.go index 42c61626903..f7662ab20dd 100644 --- a/pkg/workflow/git_helpers.go +++ b/pkg/workflow/git_helpers.go @@ -39,6 +39,22 @@ import ( var gitHelpersLog = logger.New("workflow:git_helpers") +// findGitRoot attempts to find the git repository root directory. +// Returns empty string if not in a git repository or if git command fails. +// This function is safe to call from any context and won't cause errors if git is not available. +func findGitRoot() string { + gitHelpersLog.Print("Attempting to find git root directory") + cmd := exec.Command("git", "rev-parse", "--show-toplevel") + output, err := cmd.Output() + if err != nil { + gitHelpersLog.Printf("Could not find git root (not a git repo or git not available): %v", err) + return "" + } + gitRoot := strings.TrimSpace(string(output)) + gitHelpersLog.Printf("Found git root: %s", gitRoot) + return gitRoot +} + // GetCurrentGitTag returns the current git tag if available. // Returns empty string if not on a tag. func GetCurrentGitTag() string {