diff --git a/cmd/gh-actions-lock/atomic_test.go b/cmd/gh-actions-lock/atomic_test.go index 10beba57..19fcc540 100644 --- a/cmd/gh-actions-lock/atomic_test.go +++ b/cmd/gh-actions-lock/atomic_test.go @@ -27,14 +27,7 @@ jobs: // local action the run would migrate to `$/…` when withLocal is set. func writeCleanWorkflow(t *testing.T, reg *httpmock.Registry, withLocal bool) string { t.Helper() - reg.Register(httpmock.GraphQLForRepo("actions", "setup-go"), httpmock.JSONResponse(map[string]any{ - "data": map[string]any{"a0": testRepoResponse("actions/setup-go", parityTestMoved, nodeActionYAML)}, - })) - reg.Register(httpmock.REST("GET", `^/repos/actions/setup-go/tags`), - httpmock.JSONResponse(httpmock.TagListResponse("v5.0.0", parityTestMoved))) - reg.Register(httpmock.REST("GET", `repos/actions/setup-go$`), httpmock.JSONResponse(map[string]any{ - "default_branch": "main", "id": 2, "owner": map[string]any{"id": 1}, - })) + registerSetupGo(reg) require.NoError(t, os.MkdirAll(".git", 0o755)) require.NoError(t, os.MkdirAll(filepath.Join(".github", "actions", "local"), 0o755)) require.NoError(t, os.WriteFile(filepath.Join(".github", "actions", "local", "action.yml"), @@ -48,6 +41,18 @@ func writeCleanWorkflow(t *testing.T, reg *httpmock.Registry, withLocal bool) st return filepath.ToSlash(path) } +// registerSetupGo stubs resolution of actions/setup-go@v5.0.0. +func registerSetupGo(reg *httpmock.Registry) { + reg.Register(httpmock.GraphQLForRepo("actions", "setup-go"), httpmock.JSONResponse(map[string]any{ + "data": map[string]any{"a0": testRepoResponse("actions/setup-go", parityTestMoved, nodeActionYAML)}, + })) + reg.Register(httpmock.REST("GET", `^/repos/actions/setup-go/tags`), + httpmock.JSONResponse(httpmock.TagListResponse("v5.0.0", parityTestMoved))) + reg.Register(httpmock.REST("GET", `repos/actions/setup-go$`), httpmock.JSONResponse(map[string]any{ + "default_branch": "main", "id": 2, "owner": map[string]any{"id": 1}, + })) +} + // snapshotWorkflows reads every file under .github so a test can assert the // run left the tree byte-identical. func snapshotWorkflows(t *testing.T) map[string]string { diff --git a/cmd/gh-actions-lock/reporoot_test.go b/cmd/gh-actions-lock/reporoot_test.go new file mode 100644 index 00000000..88f2bd6d --- /dev/null +++ b/cmd/gh-actions-lock/reporoot_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/github/gh-actions-lock/internal/ghapi/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A partial checkout without .git, with a `$/` composite outside .github, +// relocks only when GH_ACTIONS_LOCK_REPO_ROOT names the root. +func TestRun_RepoRootEnvWithoutGit(t *testing.T) { + tests := []struct { + name string + root string // "abs" uses the temp dir path; anything else is used verbatim + wantErr string + }{ + {name: "absolute root locks composite deps", root: "abs"}, + {name: "relative root equal to cwd locks composite deps", root: "."}, + {name: "env unset fails without .git", wantErr: "not in a git repository"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + write := func(rel, content string) { + full := filepath.Join(dir, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) + require.NoError(t, os.WriteFile(full, []byte(content), 0o600)) + } + write("tools/act/action.yml", + "runs:\n using: composite\n steps:\n - uses: actions/setup-go@v5.0.0\n") + write(".github/workflows/ci.yml", + "name: ci\non: push\njobs:\n b:\n runs-on: ubuntu-latest\n steps:\n - uses: $/tools/act\n") + t.Chdir(dir) + t.Setenv("GH_ACTIONS_LOCK_REPO_ROOT", "") + switch tt.root { + case "abs": + // On macOS t.TempDir is under /var while the cwd resolves to + // /private/var, so this also covers symlink-equivalent paths. + t.Setenv("GH_ACTIONS_LOCK_REPO_ROOT", dir) + case "": + default: + t.Setenv("GH_ACTIONS_LOCK_REPO_ROOT", tt.root) + } + reg := &httpmock.Registry{} + registerSetupGo(reg) + + _, stderr, err := runCommandWithHTTP(t, reg) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error()+stderr, tt.wantErr) + return + } + require.NoError(t, err, stderr) + assert.Contains(t, readTempLockfilePins(t), "'actions/setup-go@v5.0.0'") + }) + } +} diff --git a/cmd/gh-actions-lock/root.go b/cmd/gh-actions-lock/root.go index 57b3a8f8..3f0b8cd2 100644 --- a/cmd/gh-actions-lock/root.go +++ b/cmd/gh-actions-lock/root.go @@ -126,6 +126,13 @@ structured results go to stdout and progress to stderr: gh actions-lock --no-fix --json 2>/dev/null | jq .valid +ENVIRONMENT + +GH_ACTIONS_LOCK_REPO_ROOT names the repository root instead of +searching parent directories for .git. Set it when relocking a +partial checkout without .git. $/ actions resolve against it, and +the directory must exist and contain the workflows. + RUN LOGS Every run except --verify-local saves its full --json output, plus the diff --git a/cmd/gh-actions-lock/run.go b/cmd/gh-actions-lock/run.go index 6914a931..ebd6d51f 100644 --- a/cmd/gh-actions-lock/run.go +++ b/cmd/gh-actions-lock/run.go @@ -639,7 +639,11 @@ func migrateLocalActions(paths []string) (int, func(), error) { // a workflow path (they live under .github/workflows/); onboarding only // runs when workflows exist, so an empty paths list means nothing to do. if len(paths) > 0 { - if root := workflowfile.FindRepoRoot(paths[0]); root != "" { + root, err := workflowfile.FindRepoRoot(paths[0]) + if err != nil { + return 0, undo, err + } + if root != "" { actionFiles, err := workflowfile.DiscoverCompositeActionFiles(root) if err != nil { return 0, undo, err @@ -660,7 +664,11 @@ func migrateLocalActions(paths []string) (int, func(), error) { } seen[absPath] = true - if root := workflowfile.FindRepoRoot(path); root != "" { + root, err := workflowfile.FindRepoRoot(path) + if err != nil { + return 0, undo, fmt.Errorf("refusing to migrate %s: %w", path, err) + } + if root != "" { if err := workflowfile.ValidatePathWithinRoot(root, path); err != nil { return 0, undo, fmt.Errorf("refusing to migrate %s: %w", path, err) } diff --git a/internal/workflowfile/rewrite.go b/internal/workflowfile/rewrite.go index bee831ef..1d24a21e 100644 --- a/internal/workflowfile/rewrite.go +++ b/internal/workflowfile/rewrite.go @@ -150,7 +150,10 @@ func (f *File) MigrateLocalActionsToSelfRepository() ([]byte, int, error) { return append([]byte(nil), f.Content...), 0, nil } - repoRoot := findRepoRoot(f.Path) + repoRoot, err := findRepoRoot(f.Path) + if err != nil { + return append([]byte(nil), f.Content...), 0, err + } replacements := make(map[string]string, len(scan.LocalPaths)) for _, localPath := range scan.LocalPaths { if !localActionExists(repoRoot, localPath) { diff --git a/internal/workflowfile/workflowfile.go b/internal/workflowfile/workflowfile.go index 65ffd396..32aa8a64 100644 --- a/internal/workflowfile/workflowfile.go +++ b/internal/workflowfile/workflowfile.go @@ -194,9 +194,13 @@ func ScanSelfRepositoryActions(workflowPath string, actionRefs []string) SelfRep return scan } - repoRoot := findRepoRoot(workflowPath) + repoRoot, err := findRepoRoot(workflowPath) + if err != nil { + scan.Errors = append(scan.Errors, fmt.Sprintf("can't inspect self repository actions: %v", err)) + return scan + } if repoRoot == "" { - scan.Errors = append(scan.Errors, "can't inspect self repository actions: not in a git repository") + scan.Errors = append(scan.Errors, "can't inspect self repository actions: not in a git repository (set "+RepoRootEnv+" to override)") return scan } @@ -317,9 +321,10 @@ func DiscoverWorkflowsIn(dir string) ([]string, error) { return paths, nil } -// FindRepoRoot returns the git repository root containing startPath, or "" when -// startPath is not inside a git repository. -func FindRepoRoot(startPath string) string { +// FindRepoRoot returns the repository root containing startPath, or "" when +// startPath is not inside a git repository. RepoRootEnv overrides discovery; +// an unusable override is an error. +func FindRepoRoot(startPath string) (string, error) { return findRepoRoot(startPath) } @@ -373,7 +378,13 @@ func ExtractLocalCompositeRefs(workflowPath string, localPaths []string) ([]pars var warnings []string seen := make(map[string]bool) - repoRoot := findRepoRoot(workflowPath) + repoRoot, err := findRepoRoot(workflowPath) + if err != nil { + if len(localPaths) > 0 { + warnings = append(warnings, fmt.Sprintf("can't resolve local action paths: %v", err)) + } + return nil, warnings + } if repoRoot == "" { if len(localPaths) > 0 { warnings = append(warnings, "can't resolve local action paths: not in a git repository") @@ -527,23 +538,51 @@ func walkYAMLNodesDepth(node *yaml.Node, fn func(keyNode, valueNode *yaml.Node), } } -func findRepoRoot(startPath string) string { +// RepoRootEnv overrides `.git` discovery for callers relocking a partial +// checkout without `.git`. The workflow's directory must lie inside it. +const RepoRootEnv = "GH_ACTIONS_LOCK_REPO_ROOT" + +// findRepoRoot returns the repository root containing startPath. It honours +// RepoRootEnv when set; otherwise it walks up for `.git` and returns "" with a +// nil error when none is found. A non-nil error means the override is unusable. +func findRepoRoot(startPath string) (string, error) { absPath, err := filepath.Abs(filepath.Dir(startPath)) if err != nil { - return "" + return "", nil + } + if root := os.Getenv(RepoRootEnv); root != "" { + return overrideRepoRoot(root, absPath) } for { if _, err := os.Stat(filepath.Join(absPath, ".git")); err == nil { - return absPath + return absPath, nil } parent := filepath.Dir(absPath) if parent == absPath { - return "" + return "", nil } absPath = parent } } +func overrideRepoRoot(root, startDir string) (string, error) { + absRoot, err := filepath.Abs(root) + if err != nil { + return "", fmt.Errorf("%s=%s: %w", RepoRootEnv, root, err) + } + info, err := os.Stat(absRoot) + if err != nil { + return "", fmt.Errorf("%s=%s: %w", RepoRootEnv, root, err) + } + if !info.IsDir() { + return "", fmt.Errorf("%s=%s: not a directory", RepoRootEnv, root) + } + if err := ValidatePathWithinRoot(absRoot, startDir); err != nil { + return "", fmt.Errorf("%s=%s does not contain %s", RepoRootEnv, root, startDir) + } + return absRoot, nil +} + func isWithinRoot(root, candidate string) bool { absRoot, err := filepath.Abs(root) if err != nil { diff --git a/internal/workflowfile/workflowfile_test.go b/internal/workflowfile/workflowfile_test.go index dfd74566..fe6251ef 100644 --- a/internal/workflowfile/workflowfile_test.go +++ b/internal/workflowfile/workflowfile_test.go @@ -479,3 +479,94 @@ runs: assert.Contains(t, scan.Errors[0], "`uses:` can't contain an expression") assert.Empty(t, scan.Refs) } + +func TestFindRepoRoot_EnvOverride(t *testing.T) { + gitRepo := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(gitRepo, ".git"), 0o755)) + partial := t.TempDir() + outside := t.TempDir() + + tests := []struct { + name string + env string + start string + wantRoot string + wantErr string + }{ + {name: "unset walks up to .git", start: filepath.Join(gitRepo, ".github", "workflows", "ci.yml"), wantRoot: gitRepo}, + {name: "unset without .git returns empty", start: filepath.Join(partial, ".github", "workflows", "ci.yml")}, + {name: "set returns override without .git", env: partial, start: filepath.Join(partial, ".github", "workflows", "ci.yml"), wantRoot: partial}, + {name: "set wins over .git", env: filepath.Join(gitRepo, ".github"), start: filepath.Join(gitRepo, ".github", "workflows", "ci.yml"), wantRoot: filepath.Join(gitRepo, ".github")}, + {name: "nonexistent dir fails", env: filepath.Join(partial, "missing"), start: filepath.Join(partial, ".github", "workflows", "ci.yml"), wantErr: RepoRootEnv}, + {name: "start outside root fails", env: partial, start: filepath.Join(outside, ".github", "workflows", "ci.yml"), wantErr: "does not contain"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.NoError(t, os.MkdirAll(filepath.Dir(tt.start), 0o755)) + t.Setenv(RepoRootEnv, tt.env) + + root, err := FindRepoRoot(tt.start) + + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + assert.Empty(t, root) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantRoot, root) + }) + } +} + +// Callers run from the partial checkout with the override pointing at it, +// possibly as "." or through a symlinked path such as macOS /tmp. +func TestFindRepoRoot_EnvOverrideMatchesCwd(t *testing.T) { + real := t.TempDir() + link := filepath.Join(t.TempDir(), "link") + require.NoError(t, os.Symlink(real, link)) + require.NoError(t, os.MkdirAll(filepath.Join(real, ".github", "workflows"), 0o755)) + + tests := []struct { + name string + cwd string + env string + start string + }{ + {name: "relative dot root", cwd: real, env: ".", start: filepath.Join(".github", "workflows", "ci.yml")}, + {name: "symlinked root, real start", cwd: real, env: link, start: filepath.Join(".github", "workflows", "ci.yml")}, + {name: "real root, symlinked start", cwd: link, env: real, start: filepath.Join(".github", "workflows", "ci.yml")}, + {name: "symlinked root, absolute real start", cwd: real, env: link, start: filepath.Join(real, ".github", "workflows", "ci.yml")}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Chdir(tt.cwd) + t.Setenv(RepoRootEnv, tt.env) + + root, err := FindRepoRoot(tt.start) + + require.NoError(t, err) + want, err := filepath.EvalSymlinks(real) + require.NoError(t, err) + got, err := filepath.EvalSymlinks(root) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + } +} + +func TestScanSelfRepositoryActions_EnvRootBoundsEscape(t *testing.T) { + parent := t.TempDir() + repoRoot := filepath.Join(parent, "repo") + workflowPath := filepath.Join(repoRoot, ".github", "workflows", "ci.yml") + require.NoError(t, os.MkdirAll(filepath.Dir(workflowPath), 0o755)) + sibling := filepath.Join(parent, "sibling", "action.yml") + require.NoError(t, os.MkdirAll(filepath.Dir(sibling), 0o755)) + require.NoError(t, os.WriteFile(sibling, []byte("runs:\n using: composite\n steps:\n - uses: evil/x@v1\n"), 0o644)) + t.Setenv(RepoRootEnv, repoRoot) + + scan := ScanSelfRepositoryActions(workflowPath, []string{"$/../sibling"}) + + require.Len(t, scan.Errors, 1) + assert.Contains(t, scan.Errors[0], "outside repo root") + assert.Empty(t, scan.Refs) +}