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
21 changes: 13 additions & 8 deletions cmd/gh-actions-lock/atomic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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 {
Expand Down
62 changes: 62 additions & 0 deletions cmd/gh-actions-lock/reporoot_test.go
Original file line number Diff line number Diff line change
@@ -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'")
})
}
}
7 changes: 7 additions & 0 deletions cmd/gh-actions-lock/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions cmd/gh-actions-lock/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
5 changes: 4 additions & 1 deletion internal/workflowfile/rewrite.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
59 changes: 49 additions & 10 deletions internal/workflowfile/workflowfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down
91 changes: 91 additions & 0 deletions internal/workflowfile/workflowfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading