Compare commits

...

4 commits

Author SHA1 Message Date
3daeb3fc05
feta: add globfs
Add a fs.FS impl to allow to filter an existing fs.FS by path patterns.
2025-10-05 20:37:14 +02:00
285a7e8be5
chore: update domain to code.geekeey.de 2025-09-30 21:23:51 +02:00
6a3a41374e
feat: change github context parsing 2025-08-11 21:27:54 +02:00
7d728dcbc2
feat: add http.Client provider with automatic token 2024-10-23 22:20:59 +02:00
6 changed files with 408 additions and 59 deletions

33
client.go Normal file
View file

@ -0,0 +1,33 @@
package sdk
import (
"fmt"
"net/http"
"net/url"
)
func (a *Action) Client() *Client {
c := &Client{Client: &http.Client{}}
context := a.Context()
c.base = context.APIURL
c.token = fmt.Sprintf("Bearer %s", context.Token)
return c
}
type Client struct {
*http.Client
base string
token string
}
func (c *Client) Do(req *http.Request) (*http.Response, error) {
req.Header.Set("Authorization", c.token)
if !req.URL.IsAbs() {
u, err := url.Parse(fmt.Sprintf("%s%s", c.base, req.URL))
if err != nil {
return nil, err
}
req.URL = u
}
return c.Client.Do(req)
}

View file

@ -2,10 +2,8 @@ package sdk
import (
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
)
// GitHubContext of current workflow.
@ -14,7 +12,7 @@ type GitHubContext struct {
Action string `env:"GITHUB_ACTION"`
ActionPath string `env:"GITHUB_ACTION_PATH"`
ActionRepository string `env:"GITHUB_ACTION_REPOSITORY"`
Actions bool `env:"GITHUB_ACTIONS"`
Actions string `env:"GITHUB_ACTIONS"`
Actor string `env:"GITHUB_ACTOR"`
APIURL string `env:"GITHUB_API_URL,default=https://api.github.com"`
BaseRef string `env:"GITHUB_BASE_REF"`
@ -27,34 +25,36 @@ type GitHubContext struct {
Path string `env:"GITHUB_PATH"`
Ref string `env:"GITHUB_REF"`
RefName string `env:"GITHUB_REF_NAME"`
RefProtected bool `env:"GITHUB_REF_PROTECTED"`
RefProtected string `env:"GITHUB_REF_PROTECTED"`
RefType string `env:"GITHUB_REF_TYPE"`
Repository string `env:"GITHUB_REPOSITORY"`
RepositoryOwner string `env:"GITHUB_REPOSITORY_OWNER"`
RetentionDays int64 `env:"GITHUB_RETENTION_DAYS"`
RunAttempt int64 `env:"GITHUB_RUN_ATTEMPT"`
RunID int64 `env:"GITHUB_RUN_ID"`
RunNumber int64 `env:"GITHUB_RUN_NUMBER"`
RetentionDays string `env:"GITHUB_RETENTION_DAYS"`
RunAttempt string `env:"GITHUB_RUN_ATTEMPT"`
RunID string `env:"GITHUB_RUN_ID"`
RunNumber string `env:"GITHUB_RUN_NUMBER"`
ServerURL string `env:"GITHUB_SERVER_URL,default=https://github.com"`
SHA string `env:"GITHUB_SHA"`
StepSummary string `env:"GITHUB_STEP_SUMMARY"`
Workflow string `env:"GITHUB_WORKFLOW"`
Workspace string `env:"GITHUB_WORKSPACE"`
Token string `env:"GITHUB_TOKEN"`
// Event is populated by parsing the file at EventPath, if it exists.
Event map[string]any
event map[string]any
}
// Context returns the context of current action with the payload object
// that triggered the workflow
func (c *Action) Context() (*GitHubContext, error) {
var merr error
func (c *Action) Context() *GitHubContext {
context := &GitHubContext{
APIURL: "https://api.github.com",
GraphqlURL: "https://api.github.com/graphql",
ServerURL: "https://github.com",
event: map[string]any{},
}
if v := c.env("GITHUB_ACTION"); v != "" {
@ -66,10 +66,8 @@ func (c *Action) Context() (*GitHubContext, error) {
if v := c.env("GITHUB_ACTION_REPOSITORY"); v != "" {
context.ActionRepository = v
}
if v, err := parseBool(c.env("GITHUB_ACTIONS")); err == nil {
if v := c.env("GITHUB_ACTIONS"); v != "" {
context.Actions = v
} else {
merr = errors.Join(merr, err)
}
if v := c.env("GITHUB_ACTOR"); v != "" {
context.Actor = v
@ -107,41 +105,29 @@ func (c *Action) Context() (*GitHubContext, error) {
if v := c.env("GITHUB_REF_NAME"); v != "" {
context.RefName = v
}
if v, err := parseBool(c.env("GITHUB_REF_PROTECTED")); err == nil {
if v := c.env("GITHUB_REF_PROTECTED"); v != "" {
context.RefProtected = v
} else {
merr = errors.Join(merr, err)
}
if v := c.env("GITHUB_REF_TYPE"); v != "" {
context.RefType = v
}
if v := c.env("GITHUB_REPOSITORY"); v != "" {
context.Repository = v
}
if v := c.env("GITHUB_REPOSITORY_OWNER"); v != "" {
context.RepositoryOwner = v
}
if v, err := parseInt(c.env("GITHUB_RETENTION_DAYS")); err == nil {
if v := c.env("GITHUB_RETENTION_DAYS"); v != "" {
context.RetentionDays = v
} else {
merr = errors.Join(merr, err)
}
if v, err := parseInt(c.env("GITHUB_RUN_ATTEMPT")); err == nil {
if v := c.env("GITHUB_RUN_ATTEMPT"); v != "" {
context.RunAttempt = v
} else {
merr = errors.Join(merr, err)
}
if v, err := parseInt(c.env("GITHUB_RUN_ID")); err == nil {
if v := c.env("GITHUB_RUN_ID"); v != "" {
context.RunID = v
} else {
merr = errors.Join(merr, err)
}
if v, err := parseInt(c.env("GITHUB_RUN_NUMBER")); err == nil {
if v := c.env("GITHUB_RUN_NUMBER"); v != "" {
context.RunNumber = v
} else {
merr = errors.Join(merr, err)
}
if v := c.env("GITHUB_SERVER_URL"); v != "" {
context.ServerURL = v
@ -158,32 +144,24 @@ func (c *Action) Context() (*GitHubContext, error) {
if v := c.env("GITHUB_WORKSPACE"); v != "" {
context.Workspace = v
}
if v := c.env("GITHUB_TOKEN"); v != "" {
context.Token = v
}
if context.EventPath != "" {
eventData, err := os.ReadFile(context.EventPath)
return context
}
func (c *GitHubContext) Event() (map[string]any, error) {
if c.EventPath != "" {
eventData, err := os.ReadFile(c.EventPath)
if err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("could not read event file: %w", err)
}
if eventData != nil {
if err := json.Unmarshal(eventData, &context.Event); err != nil {
if err := json.Unmarshal(eventData, &c.event); err != nil {
return nil, fmt.Errorf("failed to unmarshal event payload: %w", err)
}
}
}
return context, merr
}
func parseBool(v string) (bool, error) {
if v == "" {
return false, nil
}
return strconv.ParseBool(v)
}
func parseInt(v string) (int64, error) {
if v == "" {
return 0, nil
}
return strconv.ParseInt(v, 10, 64)
return c.event, nil
}

View file

@ -37,6 +37,7 @@ func TestAction_Context(t *testing.T) {
APIURL: "https://api.github.com",
ServerURL: "https://github.com",
GraphqlURL: "https://api.github.com/graphql",
event: map[string]any{},
},
},
{
@ -70,12 +71,13 @@ func TestAction_Context(t *testing.T) {
"GITHUB_STEP_SUMMARY": "/path/to/summary",
"GITHUB_WORKFLOW": "test",
"GITHUB_WORKSPACE": "/path/to/workspace",
"GITHUB_TOKEN": "somerandomtoken",
},
exp: &GitHubContext{
Action: "__repo-owner_name-of-action-repo",
ActionPath: "/path/to/action",
ActionRepository: "repo-owner/name-of-action-repo",
Actions: true,
Actions: "true",
Actor: "sethvargo",
APIURL: "https://foo.com",
BaseRef: "main",
@ -88,19 +90,21 @@ func TestAction_Context(t *testing.T) {
Path: "/path/to/path",
Ref: "refs/tags/v1.0",
RefName: "v1.0",
RefProtected: true,
RefProtected: "true",
RefType: "tag",
Repository: "sethvargo/baz",
RepositoryOwner: "sethvargo",
RetentionDays: 90,
RunAttempt: 6,
RunID: 56,
RunNumber: 34,
RetentionDays: "90",
RunAttempt: "6",
RunID: "56",
RunNumber: "34",
ServerURL: "https://bar.com",
SHA: "abcd1234",
StepSummary: "/path/to/summary",
Workflow: "test",
Workspace: "/path/to/workspace",
Token: "somerandomtoken",
event: map[string]any{},
},
},
{
@ -116,7 +120,7 @@ func TestAction_Context(t *testing.T) {
ServerURL: "https://github.com",
GraphqlURL: "https://api.github.com/graphql",
Event: map[string]any{
event: map[string]any{
"foo": "bar",
},
},
@ -131,7 +135,8 @@ func TestAction_Context(t *testing.T) {
a := New()
a.env = func(s string) string { return tc.env[s] }
got, err := a.Context()
got := a.Context()
_, err := got.Event()
if err != nil {
t.Fatal(err)
}

2
go.mod
View file

@ -1,3 +1,3 @@
module git.geekeey.de/actions/sdk
module code.geekeey.de/actions/sdk
go 1.22.5

99
iox/globfs.go Normal file
View file

@ -0,0 +1,99 @@
package iox
import (
"io/fs"
"os"
"path"
)
type GlobFS struct {
base fs.FS
patterns []string
}
// NewGlobFS creates a new GlobFS that exposes only files matching any of the given glob patterns.
func NewGlobFS(base fs.FS, patterns ...string) *GlobFS {
return &GlobFS{base: base, patterns: patterns}
}
// match reports whether the given path matches any of the configured patterns.
func (g *GlobFS) match(name string) bool {
for _, pat := range g.patterns {
if matched, _ := path.Match(pat, name); matched {
return true
}
}
return false
}
func (g *GlobFS) contains(dir string) bool {
queue := []string{dir}
visited := make(map[string]struct{})
for len(queue) > 0 {
current := queue[0]
queue = queue[1:] // dequeue
// Prevent visiting same dir multiple times
if _, seen := visited[current]; seen {
continue
}
visited[current] = struct{}{}
entries, err := fs.ReadDir(g.base, current)
if err != nil {
continue
}
for _, entry := range entries {
rel := path.Join(current, entry.Name())
if g.match(rel) {
return true
}
if entry.IsDir() {
queue = append(queue, rel)
}
}
}
return false
}
func (g *GlobFS) Open(name string) (fs.File, error) {
if g.match(name) {
return g.base.Open(name)
}
fi, err := fs.Stat(g.base, name)
if err != nil || !fi.IsDir() {
return nil, fs.ErrNotExist
}
if g.contains(name) {
return g.base.Open(name)
}
return nil, fs.ErrNotExist
}
func (g *GlobFS) ReadDir(name string) ([]fs.DirEntry, error) {
if g.match(name) {
return fs.ReadDir(g.base, name)
}
entries, err := fs.ReadDir(g.base, name)
if err != nil {
return nil, err
}
var children []fs.DirEntry
for _, entry := range entries {
rel := path.Join(name, entry.Name())
if g.match(rel) {
children = append(children, entry)
}
if entry.IsDir() && g.contains(rel) {
children = append(children, entry)
}
}
if len(children) == 0 {
return nil, os.ErrNotExist
}
return children, nil
}

234
iox/globfs_test.go Normal file
View file

@ -0,0 +1,234 @@
package iox
import (
"io/fs"
"reflect"
"sort"
"testing"
"testing/fstest"
)
func setupFS() fs.ReadDirFS {
// Create an in-memory FS with a mix of files and directories
return fstest.MapFS{
"main.go": &fstest.MapFile{Data: []byte("package main")},
"main_test.go": &fstest.MapFile{Data: []byte("package main_test")},
"README.md": &fstest.MapFile{Data: []byte("# readme")},
"LICENSE": &fstest.MapFile{Data: []byte("MIT")},
"docs/guide.md": &fstest.MapFile{Data: []byte("Docs")},
"docs/other.txt": &fstest.MapFile{Data: []byte("Other")},
"docs/hidden/.keep": &fstest.MapFile{Data: []byte("")},
"assets/img.png": &fstest.MapFile{Data: []byte("PNG")},
"assets/style.css": &fstest.MapFile{Data: []byte("CSS")},
".gitignore": &fstest.MapFile{Data: []byte("*.log")},
".hiddenfile": &fstest.MapFile{Data: []byte("")},
"emptydir/": &fstest.MapFile{Mode: fs.ModeDir},
}
}
// helper to get base names for easier comparison
func basenames(entries []fs.DirEntry) []string {
names := []string{}
for _, e := range entries {
names = append(names, e.Name())
}
sort.Strings(names)
return names
}
func TestGlobFSMultiplePatterns(t *testing.T) {
memfs := setupFS()
gfs := NewGlobFS(memfs, "*.go", "*.md", "assets/*", "docs/guide.md", ".gitignore")
tests := []struct {
path string
want []string
wantErr bool
}{
{path: ".", want: []string{"README.md", "assets", "docs", "main.go", "main_test.go", ".gitignore"}},
{path: "assets", want: []string{"img.png", "style.css"}},
{path: "docs", want: []string{"guide.md"}},
{path: "docs/hidden", want: []string{}, wantErr: true},
{path: "emptydir", want: []string{}, wantErr: true},
}
for _, tc := range tests {
tc := tc // capture range variable
t.Run(tc.path, func(t *testing.T) {
entries, err := fs.ReadDir(gfs, tc.path)
if tc.wantErr && err == nil {
t.Errorf("expected error, got nil")
return
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
return
}
got := basenames(entries)
sort.Strings(tc.want)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("got %v; want %v", got, tc.want)
}
})
}
}
func TestGlobFSOpen(t *testing.T) {
memfs := setupFS()
gfs := NewGlobFS(memfs, "*.go", "*.md", "assets/*", "docs/guide.md", ".gitignore")
type test struct {
path string
wantErr bool
}
tests := []test{
{path: "main.go"},
{path: "README.md"},
{path: "LICENSE", wantErr: true},
{path: "assets/img.png"},
{path: "assets/style.css"},
{path: "assets/nonexistent.png", wantErr: true},
{path: "docs/guide.md"},
{path: "docs/other.txt", wantErr: true},
{path: ".gitignore"},
{path: ".hiddenfile", wantErr: true},
{path: "docs/hidden/.keep", wantErr: true},
{path: "emptydir", wantErr: true},
{path: "docs"}, // allowed because it contains matching file(s)
{path: "assets"}, // allowed because it contains matching file(s)
}
for _, tc := range tests {
tc := tc
t.Run(tc.path, func(t *testing.T) {
f, err := gfs.Open(tc.path)
if tc.wantErr && err == nil {
t.Errorf("expected error, got file")
if f != nil {
f.Close()
}
} else if !tc.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
} else if !tc.wantErr && err == nil {
info, _ := f.Stat()
if info.IsDir() {
_, derr := fs.ReadDir(gfs, tc.path)
if derr != nil && !tc.wantErr {
t.Errorf("unexpected error: %v", derr)
}
}
f.Close()
}
})
}
}
func TestGlobFSReadFile(t *testing.T) {
memfs := setupFS()
gfs := NewGlobFS(memfs, "*.go", "*.md", "assets/*", ".gitignore")
tests := []struct {
name string
want []byte
wantErr bool
}{
{name: "main.go", want: []byte("package main")},
{name: "main_test.go", want: []byte("package main_test")},
{name: "README.md", want: []byte("# readme")},
{name: "assets/img.png", want: []byte("PNG")},
{name: "assets/style.css", want: []byte("CSS")},
{name: ".gitignore", want: []byte("*.log")},
{name: "LICENSE", wantErr: true}, // not allowed by filter
{name: "docs/guide.md", wantErr: true}, // not allowed by filter
{name: "docs/hidden/.keep", wantErr: true}, // not allowed by filter
{name: "doesnotexist.txt", wantErr: true}, // does not exist
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
got, err := fs.ReadFile(gfs, tc.name)
if tc.wantErr {
if err == nil {
t.Errorf("expected error, got nil (got=%q)", got)
}
} else {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if string(got) != string(tc.want) {
t.Errorf("got %q; want %q", got, tc.want)
}
}
})
}
}
func TestGlobFSRelativePaths(t *testing.T) {
memfs := setupFS()
gfs := NewGlobFS(memfs, "docs/*.md")
entries, err := fs.ReadDir(gfs, "docs")
if err != nil {
t.Fatal(err)
}
got := basenames(entries)
want := []string{"guide.md"}
if !reflect.DeepEqual(got, want) {
t.Errorf("docs/*.md: got %v, want %v", got, want)
}
}
func TestGlobFSNoMatchesOpen(t *testing.T) {
gfs := NewGlobFS(setupFS(), "*.xyz")
_, err := gfs.Open("main.go")
if err == nil {
t.Fatal("expected error when opening file with no matches")
}
}
func TestGlobFSNoMatchesStat(t *testing.T) {
gfs := NewGlobFS(setupFS(), "*.xyz")
_, err := fs.Stat(gfs, "main.go")
if err == nil {
t.Fatal("expected error with no matches: stat")
}
}
func TestGlobFSNoMatchesReadDir(t *testing.T) {
gfs := NewGlobFS(setupFS(), "*.xyz")
_, err := fs.ReadDir(gfs, "main.go")
if err == nil {
t.Fatal("expected error with no matches: readdir")
}
}
func TestGlobFSNoMatchesReadFile(t *testing.T) {
gfs := NewGlobFS(setupFS(), "*.xyz")
_, err := fs.ReadFile(gfs, "main.go")
if err == nil {
t.Fatal("expected error with no matches: readfile")
}
}
func TestGlobFS_IntegrationWithStdlib(t *testing.T) {
memfs := setupFS()
gfs := NewGlobFS(memfs, "*.go", "docs/guide.md")
// Use fs.WalkDir with our filtered FS
var walked []string
err := fs.WalkDir(gfs, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
walked = append(walked, path)
return nil
})
if err != nil {
t.Fatal(err)
}
// Only files and dirs matching or containing matches should appear
for _, p := range walked {
if p == "." || p == "main.go" || p == "main_test.go" || p == "docs" || p == "docs/guide.md" {
continue
}
t.Errorf("WalkDir: unexpected path %q", p)
}
}