Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/cli/compile_compiler_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
92 changes: 92 additions & 0 deletions pkg/cli/compile_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := `---
Comment on lines +831 to +834
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

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

This test is non-deterministic: it explicitly notes that actions-lock.json “may or may not exist”, and then only asserts the wrong path doesn’t exist. If the compile run doesn’t ever write the action cache, the test will pass even if the original bug regresses. Make the test force creation/update of actions-lock.json (e.g., ensure a code path that marks the ActionCache dirty and saves it) so it fails when the cache is written relative to CWD.

Copilot uses AI. Check for mistakes.
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")
Comment on lines +853 to +873
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

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

Using os.Chdir in a test changes the process-wide working directory and can create hard-to-debug coupling if other tests are added later (especially with t.Parallel). Prefer setting cmd.Dir = setup.workflowsDir for the subprocess to simulate running from a subdirectory, and keep the parent test process CWD unchanged.

Suggested change
// 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")
// Run the compile command as if from the .github/workflows subdirectory using a relative path
cmd := exec.Command(setup.binaryPath, "compile", "test-action.md")
cmd.Dir = setup.workflowsDir
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("CLI compile command failed: %v\nOutput: %s", err, string(output))
}
// 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")
// 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")

Copilot uses AI. Check for mistakes.

// 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")
}
24 changes: 20 additions & 4 deletions pkg/workflow/compiler_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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()

Comment on lines +143 to +146
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

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

NewCompiler() now spawns a git rev-parse --show-toplevel process for every compiler instantiation. This compiler is constructed in a large number of unit tests and likely in multiple code paths, so this eager detection can noticeably slow test/CLI execution. Consider making git-root detection lazy (e.g., perform it once inside getSharedActionResolver() when the action cache is first needed) and/or caching the computed root with sync.Once so repeated NewCompiler() calls don’t re-run git.

Copilot uses AI. Check for mistakes.
// Create compiler with defaults
c := &Compiler{
verbose: false,
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions pkg/workflow/git_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +42 to +55
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

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

This introduces a second git-root helper that duplicates the existing implementation in pkg/cli/git.go (findGitRoot / findGitRootForPath). Having parallel implementations across packages increases the chance they drift (flags, error handling, -C support). If possible, centralize git-root detection in one shared helper (or export one of them) and reuse it from here.

Copilot uses AI. Check for mistakes.
}

// GetCurrentGitTag returns the current git tag if available.
// Returns empty string if not on a tag.
func GetCurrentGitTag() string {
Expand Down
Loading