diff --git a/action.go b/action.go
new file mode 100644
index 0000000..1710a78
--- /dev/null
+++ b/action.go
@@ -0,0 +1,261 @@
+package sdk
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "strings"
+)
+
+const (
+ addMaskCmd = "add-mask"
+
+ envCmd = "env"
+ outputCmd = "output"
+ pathCmd = "path"
+ stateCmd = "state"
+
+ // https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#multiline-strings
+ multiLineFileDelim = "234baa68-d26f-4bf9-996d-45ec3520cb95"
+ multilineFileCmd = "%s<<" + multiLineFileDelim + "\n%s\n" + multiLineFileDelim // ${name}<<${delimiter}${os.EOL}${convertedVal}${os.EOL}${delimiter}
+
+ addMatcherCmd = "add-matcher"
+ removeMatcherCmd = "remove-matcher"
+
+ groupCmd = "group"
+ endGroupCmd = "endgroup"
+
+ stepSummaryCmd = "step-summary"
+
+ debugCmd = "debug"
+
+ noticeCmd = "notice"
+ warningCmd = "warning"
+ errorCmd = "error"
+
+ errFileCmdFmt = "unable to write command to the environment file: %s"
+)
+
+type Action struct {
+ w io.Writer
+ env func(string) string
+ fields CommandProperties
+}
+
+func New() *Action {
+ return &Action{w: os.Stdout, env: os.Getenv}
+}
+
+// WithFieldsSlice includes the provided fields in log output. "f" must be a
+// slice of k=v pairs. The given slice will be sorted. It panics if any of the
+// string in the given slice does not construct a valid 'key=value' pair.
+func (c *Action) WithFieldsSlice(f ...string) *Action {
+ m := make(CommandProperties)
+ for _, s := range f {
+ pair := strings.SplitN(s, "=", 2)
+ if len(pair) < 2 {
+ panic(fmt.Sprintf("%q is not a proper k=v pair!", s))
+ }
+
+ m[pair[0]] = pair[1]
+ }
+
+ return c.WithFieldsMap(m)
+}
+
+// WithFieldsMap includes the provided fields in log output. The fields in "m"
+// are automatically converted to k=v pairs and sorted.
+func (c *Action) WithFieldsMap(m map[string]string) *Action {
+ return &Action{
+ w: c.w,
+ fields: m,
+ }
+}
+
+// GetInput gets the input by the given name. It returns the empty string if the
+// input is not defined.
+func (c *Action) GetInput(i string) string {
+ e := strings.ReplaceAll(i, " ", "_")
+ e = strings.ToUpper(e)
+ e = "INPUT_" + e
+ return strings.TrimSpace(c.env(e))
+}
+
+// IssueCommand issues a new GitHub actions Command.
+// It panics if it cannot write to the output stream.
+func (c *Action) IssueCommand(cmd *Command) {
+ if _, err := fmt.Fprintln(c.w, cmd.String()); err != nil {
+ panic(fmt.Errorf("failed to issue command: %w", err))
+ }
+}
+
+// IssueFileCommand issues a new GitHub actions Command using environment files.
+// It panics if writing to the file fails.
+func (c *Action) IssueFileCommand(cmd *Command) {
+ e := strings.ReplaceAll(cmd.Name, "-", "_")
+ e = strings.ToUpper(e)
+ e = "GITHUB_" + e
+
+ filepath := c.env(e)
+ msg := []byte(cmd.Message)
+ f, err := os.OpenFile(filepath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+ if err != nil {
+ panic(fmt.Errorf(errFileCmdFmt, err))
+ }
+
+ defer func() {
+ if err := f.Close(); err != nil {
+ panic(err)
+ }
+ }()
+
+ if _, err := f.Write(msg); err != nil {
+ panic(fmt.Errorf(errFileCmdFmt, err))
+ }
+}
+
+// AddMask adds a new field mask for the given string "p". After called, future
+// attempts to log "p" will be replaced with "***" in log output. It panics if
+// it cannot write to the output stream.
+func (c *Action) AddMask(p string) {
+ // ::add-mask::
+ c.IssueCommand(&Command{
+ Name: addMaskCmd,
+ Message: p,
+ })
+}
+
+// AddMatcher adds a new matcher with the given file path. It panics if it
+// cannot write to the output stream.
+func (c *Action) AddMatcher(p string) {
+ // ::add-matcher::
+ c.IssueCommand(&Command{
+ Name: addMatcherCmd,
+ Message: p,
+ })
+}
+
+// RemoveMatcher removes a matcher with the given owner name. It panics if it
+// cannot write to the output stream.
+func (c *Action) RemoveMatcher(o string) {
+ // ::remove-matcher owner=::
+ c.IssueCommand(&Command{
+ Name: removeMatcherCmd,
+ Properties: CommandProperties{
+ "owner": o,
+ },
+ })
+}
+
+// Group starts a new collapsable region up to the next ungroup invocation. It
+// panics if it cannot write to the output stream.
+func (c *Action) Group(t string) {
+ // ::group::
+ c.IssueCommand(&Command{
+ Name: groupCmd,
+ Message: t,
+ })
+}
+
+// EndGroup ends the current group. It panics if it cannot write to the output
+// stream.
+func (c *Action) EndGroup() {
+ // ::endgroup::
+ c.IssueCommand(&Command{
+ Name: endGroupCmd,
+ })
+}
+
+// Debugf prints a debug-level message. It follows the standard fmt.Printf
+// arguments, appending an OS-specific line break to the end of the message.
+// It panics if it cannot write to the output stream.
+func (c *Action) Debugf(msg string, args ...any) {
+ // ::debug ::
+ c.IssueCommand(&Command{
+ Name: debugCmd,
+ Message: fmt.Sprintf(msg, args...),
+ Properties: c.fields,
+ })
+}
+
+// Noticef prints a notice-level message. It follows the standard fmt.Printf
+// arguments, appending an OS-specific line break to the end of the message.
+// It panics if it cannot write to the output stream.
+func (c *Action) Noticef(msg string, args ...any) {
+ // ::notice ::
+ c.IssueCommand(&Command{
+ Name: noticeCmd,
+ Message: fmt.Sprintf(msg, args...),
+ Properties: c.fields,
+ })
+}
+
+// Warningf prints a warning-level message. It follows the standard fmt.Printf
+// arguments, appending an OS-specific line break to the end of the message.
+// It panics if it cannot write to the output stream.
+func (c *Action) Warningf(msg string, args ...any) {
+ // ::warning ::
+ c.IssueCommand(&Command{
+ Name: warningCmd,
+ Message: fmt.Sprintf(msg, args...),
+ Properties: c.fields,
+ })
+}
+
+// Errorf prints a error-level message. It follows the standard fmt.Printf
+// arguments, appending an OS-specific line break to the end of the message.
+// It panics if it cannot write to the output stream.
+func (c *Action) Errorf(msg string, args ...any) {
+ // ::error ::
+ c.IssueCommand(&Command{
+ Name: errorCmd,
+ Message: fmt.Sprintf(msg, args...),
+ Properties: c.fields,
+ })
+}
+
+// AddPath adds the string "p" to the path for the invocation.
+// It panics if it cannot write to the output file.
+func (c *Action) AddPath(p string) {
+ c.IssueFileCommand(&Command{
+ Name: pathCmd,
+ Message: p,
+ })
+}
+
+// SaveState saves state to be used in the "finally" post job entry point.
+// It panics if it cannot write to the output stream.
+func (c *Action) SaveState(k, v string) {
+ c.IssueFileCommand(&Command{
+ Name: stateCmd,
+ Message: fmt.Sprintf(multilineFileCmd, k, v),
+ })
+}
+
+// AddStepSummary writes the given markdown to the job summary. If a job summary
+// already exists, this value is appended.
+// It panics if it cannot write to the output file.
+func (c *Action) AddStepSummary(markdown string) {
+ c.IssueFileCommand(&Command{
+ Name: stepSummaryCmd,
+ Message: markdown,
+ })
+}
+
+// SetEnv sets an environment variable.
+// It panics if it cannot write to the output file.
+func (c *Action) SetEnv(k, v string) {
+ c.IssueFileCommand(&Command{
+ Name: envCmd,
+ Message: fmt.Sprintf(multilineFileCmd, k, v),
+ })
+}
+
+// SetOutput sets an output parameter.
+// It panics if it cannot write to the output file.
+func (c *Action) SetOutput(k, v string) {
+ c.IssueFileCommand(&Command{
+ Name: outputCmd,
+ Message: fmt.Sprintf(multilineFileCmd, k, v),
+ })
+}
diff --git a/action_test.go b/action_test.go
new file mode 100644
index 0000000..df835ef
--- /dev/null
+++ b/action_test.go
@@ -0,0 +1,443 @@
+package sdk
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "os"
+ "testing"
+)
+
+func newFakeGetenvFunc(t *testing.T, wantKey, v string) func(string) string {
+ return func(gotKey string) string {
+ if gotKey != wantKey {
+ t.Errorf("expected call GetenvFunc(%q) to be GetenvFunc(%q)", gotKey, wantKey)
+ }
+
+ return v
+ }
+}
+
+func TestAction_WithFieldsSlice(t *testing.T) {
+ t.Parallel()
+
+ var b bytes.Buffer
+ a := New()
+ a.w = &b
+
+ a = a.WithFieldsSlice("line=100", "file=app.js")
+ a.Debugf("fail: %s", "thing")
+
+ if got, want := b.String(), fmt.Sprintln("::debug file=app.js,line=100::fail: thing"); got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+}
+
+func TestAction_WithFieldsSlice_Panic(t *testing.T) {
+ t.Parallel()
+
+ defer func() {
+ want := `"no-equals" is not a proper k=v pair!`
+ if got := recover(); got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+ }()
+
+ var b bytes.Buffer
+ a := New()
+ a.w = &b
+
+ a = a.WithFieldsSlice("no-equals")
+ a.Debugf("fail: %s", "thing")
+}
+
+func TestAction_WithFieldsMap(t *testing.T) {
+ t.Parallel()
+
+ var b bytes.Buffer
+ a := New()
+ a.w = &b
+
+ a = a.WithFieldsMap(map[string]string{"line": "100", "file": "app.js"})
+ a.Debugf("fail: %s", "thing")
+
+ if got, want := b.String(), fmt.Sprintln("::debug file=app.js,line=100::fail: thing"); got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+}
+
+func TestAction_GetInput(t *testing.T) {
+ t.Parallel()
+
+ var b bytes.Buffer
+ a := New()
+ a.w = &b
+ a.env = newFakeGetenvFunc(t, "INPUT_FOO", "bar")
+
+ if got, want := a.GetInput("foo"), "bar"; got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+}
+
+func TestAction_IssueCommand(t *testing.T) {
+ t.Parallel()
+
+ var b bytes.Buffer
+ a := New()
+ a.w = &b
+
+ a.IssueCommand(&Command{
+ Name: "foo",
+ Message: "bar",
+ })
+
+ if got, want := b.String(), fmt.Sprintln("::foo::bar"); got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+}
+
+func TestAction_IssueFileCommand(t *testing.T) {
+ t.Parallel()
+
+ file, err := os.CreateTemp("", "")
+ if err != nil {
+ t.Fatalf("unable to create a temp env file: %s", err)
+ }
+ defer os.Remove(file.Name())
+
+ var b bytes.Buffer
+ a := New()
+ a.w = &b
+ a.env = newFakeGetenvFunc(t, "GITHUB_FOO", file.Name())
+
+ a.IssueFileCommand(&Command{
+ Name: "foo",
+ Message: "bar",
+ })
+
+ // expect an empty stdout buffer
+ if got, want := b.String(), ""; got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+
+ // expect the message to be written to the env file
+ data, err := io.ReadAll(file)
+ if err != nil {
+ t.Errorf("unable to read temp env file: %s", err)
+ }
+
+ if got, want := string(data), "bar"; got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+}
+
+func TestAction_AddMask(t *testing.T) {
+ t.Parallel()
+
+ var b bytes.Buffer
+ a := New()
+ a.w = &b
+
+ a.AddMask("foobar")
+
+ if got, want := b.String(), fmt.Sprintln("::add-mask::foobar"); got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+}
+
+func TestAction_AddMatcher(t *testing.T) {
+ t.Parallel()
+
+ var b bytes.Buffer
+ a := New()
+ a.w = &b
+
+ a.AddMatcher("foobar.json")
+
+ if got, want := b.String(), fmt.Sprintln("::add-matcher::foobar.json"); got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+}
+
+func TestAction_RemoveMatcher(t *testing.T) {
+ t.Parallel()
+
+ var b bytes.Buffer
+ a := New()
+ a.w = &b
+
+ a.RemoveMatcher("foobar")
+
+ if got, want := b.String(), fmt.Sprintln("::remove-matcher owner=foobar::"); got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+}
+
+func TestAction_Group(t *testing.T) {
+ t.Parallel()
+
+ var b bytes.Buffer
+ a := New()
+ a.w = &b
+
+ a.Group("mygroup")
+
+ if got, want := b.String(), fmt.Sprintln("::group::mygroup"); got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+}
+
+func TestAction_EndGroup(t *testing.T) {
+ t.Parallel()
+
+ var b bytes.Buffer
+ a := New()
+ a.w = &b
+
+ a.EndGroup()
+
+ if got, want := b.String(), fmt.Sprintln("::endgroup::"); got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+}
+
+func TestAction_Debugf(t *testing.T) {
+ t.Parallel()
+
+ var b bytes.Buffer
+ a := New()
+ a.w = &b
+
+ a.Debugf("fail: %s", "thing")
+
+ if got, want := b.String(), fmt.Sprintln("::debug::fail: thing"); got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+}
+
+func TestAction_Noticef(t *testing.T) {
+ t.Parallel()
+
+ var b bytes.Buffer
+ a := New()
+ a.w = &b
+
+ a.Noticef("fail: %s", "thing")
+
+ if got, want := b.String(), fmt.Sprintln("::notice::fail: thing"); got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+}
+
+func TestAction_Warningf(t *testing.T) {
+ t.Parallel()
+
+ var b bytes.Buffer
+ a := New()
+ a.w = &b
+
+ a.Warningf("fail: %s", "thing")
+
+ if got, want := b.String(), fmt.Sprintln("::warning::fail: thing"); got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+}
+
+func TestAction_Errorf(t *testing.T) {
+ t.Parallel()
+
+ var b bytes.Buffer
+ a := New()
+ a.w = &b
+
+ a.Errorf("fail: %s", "thing")
+
+ if got, want := b.String(), fmt.Sprintln("::error::fail: thing"); got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+}
+
+func TestAction_AddPath(t *testing.T) {
+ t.Parallel()
+
+ // expect a file command to be issued when env file is set.
+ file, err := os.CreateTemp("", "")
+ if err != nil {
+ t.Fatalf("unable to create a temp env file: %s", err)
+ }
+ defer os.Remove(file.Name())
+
+ var b bytes.Buffer
+ a := New()
+ a.w = &b
+ a.env = newFakeGetenvFunc(t, "GITHUB_PATH", file.Name())
+
+ a.AddPath("/custom/bin")
+
+ if got, want := b.String(), ""; got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+
+ // expect an empty stdout buffer
+ if got, want := b.String(), ""; got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+
+ // expect the message to be written to the file.
+ data, err := io.ReadAll(file)
+ if err != nil {
+ t.Errorf("unable to read temp env file: %s", err)
+ }
+
+ if got, want := string(data), "/custom/bin"; got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+}
+
+func TestAction_SaveState(t *testing.T) {
+ t.Parallel()
+
+ var b bytes.Buffer
+ file, err := os.CreateTemp("", "")
+ if err != nil {
+ t.Fatalf("unable to create a temp env file: %s", err)
+ }
+ defer os.Remove(file.Name())
+
+ a := New()
+ a.w = &b
+ a.env = newFakeGetenvFunc(t, "GITHUB_STATE", file.Name())
+
+ a.SaveState("key", "value")
+ a.SaveState("key2", "value2")
+
+ // expect an empty stdout buffer
+ if got, want := b.String(), ""; got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+
+ // expect the command to be written to the file.
+ data, err := io.ReadAll(file)
+ if err != nil {
+ t.Errorf("unable to read temp env file: %s", err)
+ }
+
+ want := fmt.Sprintf("key<<%s\nvalue\n%s", multiLineFileDelim, multiLineFileDelim)
+ want += fmt.Sprintf("key2<<%s\nvalue2\n%s", multiLineFileDelim, multiLineFileDelim)
+ if got := string(data); got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+}
+
+func TestAction_AddStepSummary(t *testing.T) {
+ t.Parallel()
+
+ // expectations for env file env commands
+ var b bytes.Buffer
+ file, err := os.CreateTemp("", "")
+ if err != nil {
+ t.Fatalf("unable to create a temp env file: %s", err)
+ }
+ defer os.Remove(file.Name())
+
+ a := New()
+ a.w = &b
+ a.env = newFakeGetenvFunc(t, "GITHUB_STEP_SUMMARY", file.Name())
+
+ a.AddStepSummary(`
+## This is
+
+some markdown
+`)
+ a.AddStepSummary(`
+- content
+`)
+
+ // expect an empty stdout buffer
+ if got, want := b.String(), ""; got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+
+ // expect the command to be written to the file.
+ data, err := io.ReadAll(file)
+ if err != nil {
+ t.Errorf("unable to read temp summary file: %s", err)
+ }
+
+ want := "\n## This is\n\nsome markdown\n\n- content\n"
+ if got := string(data); got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+}
+
+func TestAction_SetEnv(t *testing.T) {
+ t.Parallel()
+
+ // expectations for env file env commands
+ var b bytes.Buffer
+ file, err := os.CreateTemp("", "")
+ if err != nil {
+ t.Fatalf("unable to create a temp env file: %s", err)
+ }
+ defer os.Remove(file.Name())
+
+ a := New()
+ a.w = &b
+ a.env = newFakeGetenvFunc(t, "GITHUB_ENV", file.Name())
+
+ a.SetEnv("key", "value")
+ a.SetEnv("key2", "value2")
+
+ // expect an empty stdout buffer
+ if got, want := b.String(), ""; got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+
+ // expect the command to be written to the file.
+ data, err := io.ReadAll(file)
+ if err != nil {
+ t.Errorf("unable to read temp env file: %s", err)
+ }
+
+ want := fmt.Sprintf("key<<%s\nvalue\n%s", multiLineFileDelim, multiLineFileDelim)
+ want += fmt.Sprintf("key2<<%s\nvalue2\n%s", multiLineFileDelim, multiLineFileDelim)
+ if got := string(data); got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+}
+
+func TestAction_SetOutput(t *testing.T) {
+ t.Parallel()
+
+ // expectations for env file env commands
+ var b bytes.Buffer
+ file, err := os.CreateTemp("", "")
+ if err != nil {
+ t.Fatalf("unable to create a temp env file: %s", err)
+ }
+ defer os.Remove(file.Name())
+
+ a := New()
+ a.w = &b
+ a.env = newFakeGetenvFunc(t, "GITHUB_OUTPUT", file.Name())
+
+ a.SetOutput("key", "value")
+ a.SetOutput("key2", "value2")
+
+ // expect an empty stdout buffer
+ if got, want := b.String(), ""; got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+
+ // expect the command to be written to the file.
+ data, err := io.ReadAll(file)
+ if err != nil {
+ t.Errorf("unable to read temp env file: %s", err)
+ }
+
+ want := fmt.Sprintf("key<<%s\nvalue\n%s", multiLineFileDelim, multiLineFileDelim)
+ want += fmt.Sprintf("key2<<%s\nvalue2\n%s", multiLineFileDelim, multiLineFileDelim)
+ if got := string(data); got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+}
diff --git a/client.go b/client.go
new file mode 100644
index 0000000..9898adb
--- /dev/null
+++ b/client.go
@@ -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)
+}
diff --git a/command.go b/command.go
new file mode 100644
index 0000000..2c0f38f
--- /dev/null
+++ b/command.go
@@ -0,0 +1,83 @@
+package sdk
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+)
+
+const (
+ cmdSeparator = "::"
+ cmdPropertiesPrefix = " "
+)
+
+// CommandProperties is a named "map[string]string" type to hold key-value pairs
+// passed to an actions command.
+type CommandProperties map[string]string
+
+// String encodes the CommandProperties to a string as comma separated
+// 'key=value' pairs. The pairs are joined in a chronological order.
+func (props *CommandProperties) String() string {
+ l := make([]string, 0, len(*props))
+ for k, v := range *props {
+ l = append(l, fmt.Sprintf("%s=%s", k, escapeProperty(v)))
+ }
+
+ sort.Strings(l)
+ return strings.Join(l, ",")
+}
+
+// Command can be issued by a GitHub action by writing to `stdout` with
+// following format.
+//
+// ::name key=value,key=value::message
+//
+// Examples:
+// ::warning::This is the message
+// ::set-env name=MY_VAR::some value
+type Command struct {
+ Name string
+ Message string
+ Properties CommandProperties
+}
+
+// String encodes the Command to a string in the following format:
+//
+// ::name key=value,key=value::message
+func (cmd *Command) String() string {
+ if cmd.Name == "" {
+ cmd.Name = "missing.command"
+ }
+
+ var builder strings.Builder
+ builder.WriteString(cmdSeparator)
+ builder.WriteString(cmd.Name)
+ if len(cmd.Properties) > 0 {
+ builder.WriteString(cmdPropertiesPrefix)
+ builder.WriteString(cmd.Properties.String())
+ }
+
+ builder.WriteString(cmdSeparator)
+ builder.WriteString(escapeData(cmd.Message))
+ return builder.String()
+}
+
+// escapeData escapes string values for presentation in the output of a command.
+// This is a not-so-well-documented requirement of commands that define a message.
+func escapeData(v string) string {
+ v = strings.ReplaceAll(v, "%", "%25")
+ v = strings.ReplaceAll(v, "\r", "%0D")
+ v = strings.ReplaceAll(v, "\n", "%0A")
+ return v
+}
+
+// escapeData escapes command property values for presentation in the output of
+// a command.
+func escapeProperty(v string) string {
+ v = strings.ReplaceAll(v, "%", "%25")
+ v = strings.ReplaceAll(v, "\r", "%0D")
+ v = strings.ReplaceAll(v, "\n", "%0A")
+ v = strings.ReplaceAll(v, ":", "%3A")
+ v = strings.ReplaceAll(v, ",", "%2C")
+ return v
+}
diff --git a/command_test.go b/command_test.go
new file mode 100644
index 0000000..4df7217
--- /dev/null
+++ b/command_test.go
@@ -0,0 +1,45 @@
+package sdk
+
+import "testing"
+
+func TestCommandProperties_String(t *testing.T) {
+ t.Parallel()
+
+ props := CommandProperties{"hello": "world"}
+ if got, want := props.String(), "hello=world"; got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+
+ props["foo"] = "bar"
+ if got, want := props.String(), "foo=bar,hello=world"; got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+}
+
+func TestCommand_String(t *testing.T) {
+ t.Parallel()
+
+ cmd := Command{Name: "foo"}
+ if got, want := cmd.String(), "::foo::"; got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+
+ cmd = Command{Name: "foo", Message: "bar"}
+ if got, want := cmd.String(), "::foo::bar"; got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+
+ cmd = Command{
+ Name: "foo",
+ Message: "bar",
+ Properties: CommandProperties{"bar": "foo"},
+ }
+ if got, want := cmd.String(), "::foo bar=foo::bar"; got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+
+ cmd = Command{Message: "quux"}
+ if got, want := cmd.String(), "::missing.command::quux"; got != want {
+ t.Errorf("expected %q to be %q", got, want)
+ }
+}
diff --git a/context.go b/context.go
new file mode 100644
index 0000000..5f676c9
--- /dev/null
+++ b/context.go
@@ -0,0 +1,167 @@
+package sdk
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+)
+
+// GitHubContext of current workflow.
+// See: https://docs.github.com/en/actions/learn-github-actions/environment-variables
+type GitHubContext struct {
+ Action string `env:"GITHUB_ACTION"`
+ ActionPath string `env:"GITHUB_ACTION_PATH"`
+ ActionRepository string `env:"GITHUB_ACTION_REPOSITORY"`
+ 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"`
+ Env string `env:"GITHUB_ENV"`
+ EventName string `env:"GITHUB_EVENT_NAME"`
+ EventPath string `env:"GITHUB_EVENT_PATH"`
+ GraphqlURL string `env:"GITHUB_GRAPHQL_URL,default=https://api.github.com/graphql"`
+ HeadRef string `env:"GITHUB_HEAD_REF"`
+ Job string `env:"GITHUB_JOB"`
+ Path string `env:"GITHUB_PATH"`
+ Ref string `env:"GITHUB_REF"`
+ RefName string `env:"GITHUB_REF_NAME"`
+ RefProtected string `env:"GITHUB_REF_PROTECTED"`
+ RefType string `env:"GITHUB_REF_TYPE"`
+
+ Repository string `env:"GITHUB_REPOSITORY"`
+ RepositoryOwner string `env:"GITHUB_REPOSITORY_OWNER"`
+
+ 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
+}
+
+// Context returns the context of current action with the payload object
+// that triggered the workflow
+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 != "" {
+ context.Action = v
+ }
+ if v := c.env("GITHUB_ACTION_PATH"); v != "" {
+ context.ActionPath = v
+ }
+ if v := c.env("GITHUB_ACTION_REPOSITORY"); v != "" {
+ context.ActionRepository = v
+ }
+ if v := c.env("GITHUB_ACTIONS"); v != "" {
+ context.Actions = v
+ }
+ if v := c.env("GITHUB_ACTOR"); v != "" {
+ context.Actor = v
+ }
+ if v := c.env("GITHUB_API_URL"); v != "" {
+ context.APIURL = v
+ }
+ if v := c.env("GITHUB_BASE_REF"); v != "" {
+ context.BaseRef = v
+ }
+ if v := c.env("GITHUB_ENV"); v != "" {
+ context.Env = v
+ }
+ if v := c.env("GITHUB_EVENT_NAME"); v != "" {
+ context.EventName = v
+ }
+ if v := c.env("GITHUB_EVENT_PATH"); v != "" {
+ context.EventPath = v
+ }
+ if v := c.env("GITHUB_GRAPHQL_URL"); v != "" {
+ context.GraphqlURL = v
+ }
+ if v := c.env("GITHUB_HEAD_REF"); v != "" {
+ context.HeadRef = v
+ }
+ if v := c.env("GITHUB_JOB"); v != "" {
+ context.Job = v
+ }
+ if v := c.env("GITHUB_PATH"); v != "" {
+ context.Path = v
+ }
+ if v := c.env("GITHUB_REF"); v != "" {
+ context.Ref = v
+ }
+ if v := c.env("GITHUB_REF_NAME"); v != "" {
+ context.RefName = v
+ }
+ if v := c.env("GITHUB_REF_PROTECTED"); v != "" {
+ context.RefProtected = v
+ }
+ 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 := c.env("GITHUB_RETENTION_DAYS"); v != "" {
+ context.RetentionDays = v
+ }
+ if v := c.env("GITHUB_RUN_ATTEMPT"); v != "" {
+ context.RunAttempt = v
+ }
+ if v := c.env("GITHUB_RUN_ID"); v != "" {
+ context.RunID = v
+ }
+ if v := c.env("GITHUB_RUN_NUMBER"); v != "" {
+ context.RunNumber = v
+ }
+ if v := c.env("GITHUB_SERVER_URL"); v != "" {
+ context.ServerURL = v
+ }
+ if v := c.env("GITHUB_SHA"); v != "" {
+ context.SHA = v
+ }
+ if v := c.env("GITHUB_STEP_SUMMARY"); v != "" {
+ context.StepSummary = v
+ }
+ if v := c.env("GITHUB_WORKFLOW"); v != "" {
+ context.Workflow = v
+ }
+ if v := c.env("GITHUB_WORKSPACE"); v != "" {
+ context.Workspace = v
+ }
+ if v := c.env("GITHUB_TOKEN"); v != "" {
+ context.Token = v
+ }
+
+ 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, &c.event); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal event payload: %w", err)
+ }
+ }
+ }
+ return c.event, nil
+}
diff --git a/context_test.go b/context_test.go
new file mode 100644
index 0000000..89f3ab2
--- /dev/null
+++ b/context_test.go
@@ -0,0 +1,149 @@
+package sdk
+
+import (
+ "os"
+ "reflect"
+ "testing"
+)
+
+func TestAction_Context(t *testing.T) {
+ t.Parallel()
+
+ f, err := os.CreateTemp("", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.Remove(f.Name()) })
+
+ if _, err := f.Write([]byte(`{"foo": "bar"}`)); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ eventPayloadPath := f.Name()
+
+ cases := []struct {
+ name string
+ env map[string]string
+ exp *GitHubContext
+ }{
+ {
+ name: "empty",
+ env: nil,
+ exp: &GitHubContext{
+ // Defaults
+ APIURL: "https://api.github.com",
+ ServerURL: "https://github.com",
+ GraphqlURL: "https://api.github.com/graphql",
+ event: map[string]any{},
+ },
+ },
+ {
+ name: "no_payload",
+ env: map[string]string{
+ "GITHUB_ACTION": "__repo-owner_name-of-action-repo",
+ "GITHUB_ACTION_PATH": "/path/to/action",
+ "GITHUB_ACTION_REPOSITORY": "repo-owner/name-of-action-repo",
+ "GITHUB_ACTIONS": "true",
+ "GITHUB_ACTOR": "sethvargo",
+ "GITHUB_API_URL": "https://foo.com",
+ "GITHUB_BASE_REF": "main",
+ "GITHUB_ENV": "/path/to/env",
+ "GITHUB_EVENT_NAME": "event_name",
+ "GITHUB_HEAD_REF": "headbranch",
+ "GITHUB_GRAPHQL_URL": "https://baz.com",
+ "GITHUB_JOB": "12",
+ "GITHUB_PATH": "/path/to/path",
+ "GITHUB_REF": "refs/tags/v1.0",
+ "GITHUB_REF_NAME": "v1.0",
+ "GITHUB_REF_PROTECTED": "true",
+ "GITHUB_REF_TYPE": "tag",
+ "GITHUB_REPOSITORY": "sethvargo/baz",
+ "GITHUB_REPOSITORY_OWNER": "sethvargo",
+ "GITHUB_RETENTION_DAYS": "90",
+ "GITHUB_RUN_ATTEMPT": "6",
+ "GITHUB_RUN_ID": "56",
+ "GITHUB_RUN_NUMBER": "34",
+ "GITHUB_SERVER_URL": "https://bar.com",
+ "GITHUB_SHA": "abcd1234",
+ "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",
+ Actor: "sethvargo",
+ APIURL: "https://foo.com",
+ BaseRef: "main",
+ Env: "/path/to/env",
+ EventName: "event_name",
+ // NOTE: No EventPath
+ GraphqlURL: "https://baz.com",
+ Job: "12",
+ HeadRef: "headbranch",
+ Path: "/path/to/path",
+ Ref: "refs/tags/v1.0",
+ RefName: "v1.0",
+ RefProtected: "true",
+ RefType: "tag",
+ Repository: "sethvargo/baz",
+ RepositoryOwner: "sethvargo",
+ 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{},
+ },
+ },
+ {
+ name: "payload",
+ env: map[string]string{
+ "GITHUB_EVENT_PATH": eventPayloadPath,
+ },
+ exp: &GitHubContext{
+ EventPath: eventPayloadPath,
+
+ // Defaults
+ APIURL: "https://api.github.com",
+ ServerURL: "https://github.com",
+ GraphqlURL: "https://api.github.com/graphql",
+
+ event: map[string]any{
+ "foo": "bar",
+ },
+ },
+ },
+ }
+
+ for _, tc := range cases {
+ tc := tc
+
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ a := New()
+ a.env = func(s string) string { return tc.env[s] }
+ got := a.Context()
+ _, err := got.Event()
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if !reflect.DeepEqual(got, tc.exp) {
+ t.Errorf("expected\n\n%#v\n\nto be\n\n%#v\n", got, tc.exp)
+ }
+ })
+ }
+}
diff --git a/go.mod b/go.mod
index 5f045b8..f788a0e 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,3 @@
-module git.geekeey.de/actions/sdk
+module code.geekeey.de/actions/sdk
go 1.22.5
diff --git a/iox/globfs.go b/iox/globfs.go
new file mode 100644
index 0000000..91240cb
--- /dev/null
+++ b/iox/globfs.go
@@ -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
+}
diff --git a/iox/globfs_test.go b/iox/globfs_test.go
new file mode 100644
index 0000000..e548a2a
--- /dev/null
+++ b/iox/globfs_test.go
@@ -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)
+ }
+}