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
43 changes: 18 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Installation instructions are not yet available.

## Commands

`ghactl` is organised into top-level commands, each covering a distinct area of GitHub Actions functionality.
to pa`ghactl` is organized into top-level commands, each covering a distinct area of GitHub Actions functionality.

> [!NOTE]
> More commands will be added over time.
Expand All @@ -44,25 +44,6 @@ Installation instructions are not yet available.

---

## `tool`

Manage GitHub runner tools: download, extract, cache, and check versions.

| Subcommand | Description |
| ---------------- | ---------------------------------------------------- |
| `cache get` | Get the tool cache directory path. |
| `cache find` | Find one or more cached tool versions. |
| `cache add dir` | Add a directory to the tool cache. |
| `cache add file` | Add a file to the tool cache. |
| `download` | Download a tool to a temporary directory. |
| `extract tar` | Extract a tar archive to a temporary directory. |
| `extract tgz` | Extract a tar.gz archive to a temporary directory. |
| `extract zip` | Extract a zip archive to a temporary directory. |
| `install` | Install a tool from a source. |
| `version check` | Check if a version matches a constraint. |

---

## `path`

Manage GitHub Actions PATH entries.
Expand All @@ -87,12 +68,24 @@ This writes to the GitHub Actions `GITHUB_PATH` file to be used in future steps.
ghactl path add --path "$HOME/.local/bin"
```

GitHub Actions step example:
---

```yaml
- name: Add local bin directory
run: ghactl path add --path "${HOME}/.local/bin"
```
## `tool`

Manage GitHub runner tools: download, extract, cache, and check versions.

| Subcommand | Description |
| ---------------- | ---------------------------------------------------- |
| `cache get` | Get the tool cache directory path. |
| `cache find` | Find one or more cached tool versions. |
| `cache add dir` | Add a directory to the tool cache. |
| `cache add file` | Add a file to the tool cache. |
| `download` | Download a tool to a temporary directory. |
| `extract tar` | Extract a tar archive to a temporary directory. |
| `extract tgz` | Extract a tar.gz archive to a temporary directory. |
| `extract zip` | Extract a zip archive to a temporary directory. |
| `install` | Install a tool from a source. |
| `version check` | Check if a version matches a constraint. |

---

Expand Down
21 changes: 18 additions & 3 deletions internal/cmd/tool/tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,12 @@ func (c *Cmd) Install(ctx context.Context, options InstallOptions) (string, erro
return "", err
}

cachedPath, err = c.CacheDir(extractedPath, name, resolution.Version, arch)
resolvedPath, err := toolcache.ResolveToolDirectory(extractedPath)
if err != nil {
return "", err
}

cachedPath, err = c.CacheDir(resolvedPath, name, resolution.Version, arch)
if err != nil {
return "", err
}
Expand All @@ -192,7 +197,12 @@ func (c *Cmd) Install(ctx context.Context, options InstallOptions) (string, erro
return "", err
}

cachedPath, err = c.CacheDir(extractedPath, name, resolution.Version, arch)
resolvedPath, err := toolcache.ResolveToolDirectory(extractedPath)
if err != nil {
return "", err
}

cachedPath, err = c.CacheDir(resolvedPath, name, resolution.Version, arch)
if err != nil {
return "", err
}
Expand All @@ -202,7 +212,12 @@ func (c *Cmd) Install(ctx context.Context, options InstallOptions) (string, erro
return "", err
}

cachedPath, err = c.CacheDir(extractedPath, name, resolution.Version, arch)
resolvedPath, err := toolcache.ResolveToolDirectory(extractedPath)
if err != nil {
return "", err
}

cachedPath, err = c.CacheDir(resolvedPath, name, resolution.Version, arch)
if err != nil {
return "", err
}
Expand Down
53 changes: 53 additions & 0 deletions internal/toolkit/toolcache/extract.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package toolcache

import (
"os"
"path/filepath"

"github.com/action-stars/ghactl/internal/archive"
"github.com/action-stars/ghactl/internal/fileio"
"github.com/action-stars/ghactl/internal/toolkit/core"
)

Expand Down Expand Up @@ -31,3 +35,52 @@ func ExtractZip(zipFile string) (string, error) {
}
return dest, nil
}

// ResolveToolDirectory navigates nested directories to find the actual tool location.
// It handles:
// - Single root directory (steps into it)
// - 'bin' subdirectory (steps into it if it exists after entering a single nested directory)
//
// For example, if an archive extracts to:
//
// extracted/
// ├── jsonschema-v1.0.0/
// │ ├── bin/
// │ │ └── jsonschema
// │ └── lib/
//
// This will resolve to: extracted/jsonschema-v1.0.0/bin.
func ResolveToolDirectory(extractedPath string) (string, error) {
finalPath := extractedPath

// Check if directory exists
exists, err := fileio.DirExists(finalPath)
if err != nil {
return "", err
}
if !exists {
return "", nil
}

// Check for single root directory
entries, err := os.ReadDir(finalPath)
if err != nil {
return "", err
}

steppedIntoNested := false
if len(entries) == 1 && entries[0].IsDir() {
finalPath = filepath.Join(finalPath, entries[0].Name())
steppedIntoNested = true
}

// Check for bin subdirectory only after stepping into a nested directory
if steppedIntoNested {
binPath := filepath.Join(finalPath, "bin")
if exists, err := fileio.DirExists(binPath); err == nil && exists {
finalPath = binPath
}
}

return finalPath, nil
}
90 changes: 90 additions & 0 deletions internal/toolkit/toolcache/extract_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package toolcache

import (
"path/filepath"
"testing"

"github.com/matryer/is"
Expand Down Expand Up @@ -121,3 +122,92 @@ func TestExtractZip(t *testing.T) {
})
}
}

func TestResolveToolDirectory(t *testing.T) {
t.Run("returns_path_for_empty_directory", func(t *testing.T) {
is := is.New(t)
root := t.TempDir()

result, err := ResolveToolDirectory(root)

is.NoErr(err)
is.Equal(result, root)
})

t.Run("returns_path_with_multiple_items", func(t *testing.T) {
is := is.New(t)
root := t.TempDir()
mustCreateTestFile(t, filepath.Join(root, "file1"), "content")
mustCreateTestFile(t, filepath.Join(root, "file2"), "content")
mustCreateTestDir(t, filepath.Join(root, "dir1"))

result, err := ResolveToolDirectory(root)

is.NoErr(err)
is.Equal(result, root)
})

t.Run("steps_into_single_nested_directory", func(t *testing.T) {
is := is.New(t)
root := t.TempDir()
nested := filepath.Join(root, "tool-v1.0.0")
mustCreateTestDir(t, nested)
mustCreateTestFile(t, filepath.Join(nested, "tool.exe"), "")

result, err := ResolveToolDirectory(root)

is.NoErr(err)
is.Equal(result, nested)
})

t.Run("steps_into_nested_directory_with_bin_subdirectory", func(t *testing.T) {
is := is.New(t)
root := t.TempDir()
nested := filepath.Join(root, "jsonschema-v1.0.0")
mustCreateTestDir(t, nested)
bin := filepath.Join(nested, "bin")
mustCreateTestDir(t, bin)
mustCreateTestFile(t, filepath.Join(bin, "jsonschema"), "")
mustCreateTestDir(t, filepath.Join(nested, "lib"))

result, err := ResolveToolDirectory(root)

is.NoErr(err)
is.Equal(result, bin)
})

t.Run("skips_bin_if_multiple_items_at_root", func(t *testing.T) {
is := is.New(t)
root := t.TempDir()
mustCreateTestDir(t, filepath.Join(root, "bin"))
mustCreateTestDir(t, filepath.Join(root, "lib"))
mustCreateTestFile(t, filepath.Join(root, "file.txt"), "")

result, err := ResolveToolDirectory(root)

is.NoErr(err)
is.Equal(result, root)
})

t.Run("handles_non_existent_path", func(t *testing.T) {
is := is.New(t)
root := t.TempDir()
nonExistentPath := filepath.Join(root, "does-not-exist")

result, err := ResolveToolDirectory(nonExistentPath)

is.NoErr(err)
is.Equal(result, "")
})

t.Run("handles_file_path_error", func(t *testing.T) {
is := is.New(t)
root := t.TempDir()
filePath := filepath.Join(root, "file.txt")
mustCreateTestFile(t, filePath, "content")

_, err := ResolveToolDirectory(filePath)

is.True(err != nil)
})
}
20 changes: 20 additions & 0 deletions internal/toolkit/toolcache/helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package toolcache

import (
"os"
"testing"
)

func mustCreateTestDir(t *testing.T, path string) {
t.Helper()
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatal(err)
}
}

func mustCreateTestFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
Loading