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) + } +}