Compare commits

...

3 commits

Author SHA1 Message Date
09ef963ca3
wip: needs tests 2024-10-20 14:55:16 +02:00
3d43ff4758
feat: add cache helpers 2024-07-22 21:13:54 +02:00
db684e73f8
feat: add initial helper methods 2024-07-21 12:15:55 +02:00
15 changed files with 1838 additions and 0 deletions

261
action.go Normal file
View file

@ -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::<p>
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::<p>
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=<o>::
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::<t>
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.fields>::<msg, args>
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.fields>::<msg, args>
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.fields>::<msg, args>
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.fields>::<msg, args>
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),
})
}

443
action_test.go Normal file
View file

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

9
cache.go Normal file
View file

@ -0,0 +1,9 @@
package sdk
import "git.geekeey.de/actions/sdk/cache"
func (c *Action) Cache() *cache.Client {
token := c.env("ACTIONS_RUNTIME_TOKEN")
url := c.env("ACTIONS_CACHE_URL")
return cache.New(token, url)
}

56
cache/blob.go vendored Normal file
View file

@ -0,0 +1,56 @@
package cache
import (
"bytes"
"io"
"os"
)
type Blob interface {
io.ReaderAt
io.Closer
Size() int64
}
type byteBlob struct {
buf *bytes.Reader
}
func NewByteBlob(b []byte) Blob {
return &byteBlob{buf: bytes.NewReader(b)}
}
func (blob *byteBlob) ReadAt(p []byte, off int64) (n int, err error) {
return blob.buf.ReadAt(p, off)
}
func (blob *byteBlob) Size() int64 {
return blob.buf.Size()
}
func (blob *byteBlob) Close() error {
return nil
}
type fileBlob struct {
buf *os.File
}
func NewFileBlob(f *os.File) Blob {
return &fileBlob{buf: f}
}
func (blob *fileBlob) ReadAt(p []byte, off int64) (n int, err error) {
return blob.buf.ReadAt(p, off)
}
func (blob *fileBlob) Size() int64 {
if i, err := blob.buf.Stat(); err != nil {
return i.Size()
}
return 0
}
func (blob *fileBlob) Close() error {
return nil
}

329
cache/cache.go vendored Normal file
View file

@ -0,0 +1,329 @@
package cache
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"strings"
"sync"
"golang.org/x/sync/errgroup"
)
var UploadConcurrency = 4
var UploadChunkSize = 32 * 1024 * 1024
type Client struct {
base string
http *http.Client
}
type auth struct {
transport http.RoundTripper
token string
}
func (t *auth) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", t.token))
return t.transport.RoundTrip(req)
}
func New(token, url string) *Client {
t := &auth{transport: &retry{transport: &http.Transport{}}, token: token}
return &Client{
base: url,
http: &http.Client{Transport: t},
}
}
func (c *Client) url(p string) string {
return path.Join(c.base, "_apis/artifactcache", p)
}
func (c *Client) version(k string) string {
h := sha256.New()
h.Write([]byte("|go-actionscache-1.0"))
return hex.EncodeToString(h.Sum(nil))
}
type ApiError struct {
Message string `json:"message"`
TypeName string `json:"typeName"`
TypeKey string `json:"typeKey"`
ErrorCode int `json:"errorCode"`
}
func (e ApiError) Error() string {
return e.Message
}
func (e ApiError) Is(err error) bool {
if err == os.ErrExist {
if strings.Contains(e.TypeKey, "AlreadyExists") {
return true
}
}
return false
}
func checkApiError(res *http.Response) error {
if res.StatusCode >= 200 && res.StatusCode < 300 {
return nil
}
dec := json.NewDecoder(io.LimitReader(res.Body, 32*1024))
var details ApiError
if err := dec.Decode(&details); err != nil {
return err
}
if details.Message != "" {
return details
} else {
return fmt.Errorf("unknown error %s", res.Status)
}
}
func (c *Client) Load(ctx context.Context, keys ...string) (*Entry, error) {
u, err := url.Parse(c.url("cache"))
if err != nil {
return nil, err
}
q := u.Query()
q.Set("keys", strings.Join(keys, ","))
q.Set("version", c.version(keys[0]))
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Add("Accept", "application/json;api-version=6.0-preview.1")
res, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
err = checkApiError(res)
if err != nil {
return nil, err
}
dec := json.NewDecoder(io.LimitReader(res.Body, 32*1024))
var ce Entry
if err = dec.Decode(&ce); err != nil {
return nil, err
}
ce.http = c.http
return &ce, nil
}
func (c *Client) Save(ctx context.Context, key string, b Blob) error {
id, err := c.reserve(ctx, key)
if err != nil {
return err
}
err = c.upload(ctx, id, b)
if err != nil {
return err
}
return c.commit(ctx, id, b.Size())
}
type ReserveCacheReq struct {
Key string `json:"key"`
Version string `json:"version"`
}
type ReserveCacheRes struct {
CacheID int `json:"cacheID"`
}
func (c *Client) reserve(ctx context.Context, key string) (int, error) {
payload := ReserveCacheReq{Key: key, Version: c.version(key)}
buf := new(bytes.Buffer)
if err := json.NewEncoder(buf).Encode(payload); err != nil {
return 0, err
}
url := c.url("caches")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, buf)
if err != nil {
return 0, err
}
req.Header.Add("Content-Type", "application/json")
res, err := c.http.Do(req)
if err != nil {
return 0, err
}
defer res.Body.Close()
err = checkApiError(res)
if err != nil {
return 0, err
}
dec := json.NewDecoder(io.LimitReader(res.Body, 32*1024))
var cr ReserveCacheRes
if err = dec.Decode(&cr); err != nil {
return 0, err
}
if cr.CacheID == 0 {
return 0, fmt.Errorf("invalid response (cache id is 0)")
}
return cr.CacheID, nil
}
type CommitCacheReq struct {
Size int64 `json:"size"`
}
func (c *Client) commit(ctx context.Context, id int, size int64) error {
payload := CommitCacheReq{Size: size}
buf := new(bytes.Buffer)
if err := json.NewEncoder(buf).Encode(payload); err != nil {
return err
}
url := c.url(fmt.Sprintf("caches/%d", id))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, buf)
if err != nil {
return err
}
req.Header.Add("Content-Type", "application/json")
res, err := c.http.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
err = checkApiError(res)
if err != nil {
return err
}
return nil
}
func (c *Client) upload(ctx context.Context, id int, b Blob) error {
var mu sync.Mutex
grp, ctx := errgroup.WithContext(ctx)
offset := int64(0)
for i := 0; i < UploadConcurrency; i++ {
grp.Go(func() error {
for {
mu.Lock()
start := offset
if start >= b.Size() {
mu.Unlock()
return nil
}
end := start + int64(UploadChunkSize)
if end > b.Size() {
end = b.Size()
}
offset = end
mu.Unlock()
if err := c.create(ctx, id, b, start, end-start); err != nil {
return err
}
}
})
}
return grp.Wait()
}
func (c *Client) create(ctx context.Context, id int, ra io.ReaderAt, off, n int64) error {
url := c.url(fmt.Sprintf("caches/%d", id))
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, io.NewSectionReader(ra, off, n))
if err != nil {
return err
}
req.Header.Add("Content-Type", "application/octet-stream")
req.Header.Add("Content-Range", fmt.Sprintf("bytes %d-%d/*", off, off+n-1))
res, err := c.http.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
err = checkApiError(res)
if err != nil {
return err
}
return nil
}
type Entry struct {
Key string `json:"cacheKey"`
Scope string `json:"scope"`
URL string `json:"archiveLocation"`
http *http.Client
}
// Download returns a ReaderAtCloser for pulling the data. Concurrent reads are not allowed
func (ce *Entry) Download(ctx context.Context) ReaderAtCloser {
return NewReaderAtCloser(func(offset int64) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, "GET", ce.URL, nil)
if err != nil {
return nil, err
}
if offset != 0 {
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", offset))
}
client := ce.http
if client == nil {
client = http.DefaultClient
}
res, err := client.Do(req)
if err != nil {
return nil, err
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
if res.StatusCode == http.StatusRequestedRangeNotSatisfiable {
return nil, fmt.Errorf("invalid status response %v for %s, range: %v", res.Status, ce.URL, req.Header.Get("Range"))
}
return nil, fmt.Errorf("invalid status response %v for %s", res.Status, ce.URL)
}
if offset != 0 {
cr := res.Header.Get("content-range")
if !strings.HasPrefix(cr, fmt.Sprintf("bytes %d-", offset)) {
res.Body.Close()
return nil, fmt.Errorf("unhandled content range in response: %v", cr)
}
}
return res.Body, nil
})
}
func (ce *Entry) WriteTo(ctx context.Context, w io.Writer) error {
rac := ce.Download(ctx)
if _, err := io.Copy(w, &rc{ReaderAt: rac}); err != nil {
return err
}
return rac.Close()
}

89
cache/reader.go vendored Normal file
View file

@ -0,0 +1,89 @@
package cache
import (
"io"
)
type ReaderAtCloser interface {
io.ReaderAt
io.Closer
}
type readerAtCloser struct {
offset int64
rc io.ReadCloser
ra io.ReaderAt
open func(offset int64) (io.ReadCloser, error)
closed bool
}
func NewReaderAtCloser(open func(offset int64) (io.ReadCloser, error)) ReaderAtCloser {
return &readerAtCloser{
open: open,
}
}
func (hrs *readerAtCloser) ReadAt(p []byte, off int64) (n int, err error) {
if hrs.closed {
return 0, io.EOF
}
if hrs.ra != nil {
return hrs.ra.ReadAt(p, off)
}
if hrs.rc == nil || off != hrs.offset {
if hrs.rc != nil {
hrs.rc.Close()
hrs.rc = nil
}
rc, err := hrs.open(off)
if err != nil {
return 0, err
}
hrs.rc = rc
}
if ra, ok := hrs.rc.(io.ReaderAt); ok {
hrs.ra = ra
n, err = ra.ReadAt(p, off)
} else {
for {
var nn int
nn, err = hrs.rc.Read(p)
n += nn
p = p[nn:]
if nn == len(p) || err != nil {
break
}
}
}
hrs.offset += int64(n)
return
}
func (hrs *readerAtCloser) Close() error {
if hrs.closed {
return nil
}
hrs.closed = true
if hrs.rc != nil {
return hrs.rc.Close()
}
return nil
}
type rc struct {
io.ReaderAt
offset int
}
func (r *rc) Read(b []byte) (int, error) {
n, err := r.ReadAt(b, int64(r.offset))
r.offset += n
if n > 0 && err == io.EOF {
err = nil
}
return n, err
}

42
cache/retry.go vendored Normal file
View file

@ -0,0 +1,42 @@
package cache
import (
"bytes"
"fmt"
"io"
"net/http"
)
type retry struct {
transport http.RoundTripper
retry int
}
func (t *retry) RoundTrip(req *http.Request) (*http.Response, error) {
var body []byte
if req.Body != nil {
body, _ = io.ReadAll(req.Body)
}
for count := 0; count < t.retry; count++ {
req.Body = io.NopCloser(bytes.NewBuffer(body))
res, err := t.transport.RoundTrip(req)
if err != nil {
return nil, err
}
if t.check(res) {
if res.Body != nil {
io.Copy(io.Discard, res.Body)
res.Body.Close()
}
continue
}
return res, err
}
return nil, fmt.Errorf("too many retries")
}
func (t *retry) check(res *http.Response) bool {
return res.StatusCode > 399
}

115
cache/tar.go vendored Normal file
View file

@ -0,0 +1,115 @@
package cache
import (
"archive/tar"
"compress/gzip"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// Tar takes a source and variable writers and walks 'source' writing each file
// found to the tar writer; the purpose for accepting multiple writers is to allow
// for multiple outputs (for example a file, or md5 hash)
func Tar(src string, writers ...io.Writer) error {
if _, err := os.Stat(src); err != nil {
return fmt.Errorf("unable to tar files - %v", err.Error())
}
mw := io.MultiWriter(writers...)
gzw := gzip.NewWriter(mw)
defer gzw.Close()
tw := tar.NewWriter(gzw)
defer tw.Close()
// walk path
return filepath.Walk(src, func(file string, fi os.FileInfo, err error) error {
if err != nil {
return err
}
if !fi.Mode().IsRegular() {
return nil
}
header, err := tar.FileInfoHeader(fi, fi.Name())
if err != nil {
return err
}
// update the name to correctly reflect the desired destination when untaring
header.Name = strings.TrimPrefix(strings.Replace(file, src, "", -1), string(filepath.Separator))
if err := tw.WriteHeader(header); err != nil {
return err
}
f, err := os.Open(file)
if err != nil {
return err
}
if _, err := io.Copy(tw, f); err != nil {
f.Close()
return err
}
f.Close()
return nil
})
}
// Untar takes a destination path and a reader; a tar reader loops over the tarfile
// creating the file structure at 'dst' along the way, and writing any files
func Untar(dst string, r io.Reader) error {
gzr, err := gzip.NewReader(r)
if err != nil {
return err
}
defer gzr.Close()
tr := tar.NewReader(gzr)
for {
header, err := tr.Next()
if errors.Is(err, io.EOF) || header == nil {
break
}
if err != nil {
return err
}
target := filepath.Join(dst, header.Name)
switch header.Typeflag {
// if its a dir and it doesn't exist create it
case tar.TypeDir:
if _, err := os.Stat(target); err != nil {
if err := os.MkdirAll(target, 0755); err != nil {
return err
}
}
// if it's a file create it
case tar.TypeReg:
f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode))
if err != nil {
return err
}
if _, err := io.Copy(f, tr); err != nil {
return err
}
f.Close()
}
}
return nil
}

29
cmd/main.go Normal file
View file

@ -0,0 +1,29 @@
package main
import (
"context"
"os"
"git.geekeey.de/actions/sdk"
"git.geekeey.de/actions/sdk/cache"
)
func main() {
a := sdk.New()
a.AddMask("hello")
a.WithFieldsSlice("foo=bar", "biz=baz").Debugf("hello world")
blob, err := a.Cache().Load(context.Background(), "example")
if err != nil {
panic(err)
}
cache.Tar("./foo")
f, err := os.Open("")
if err != nil {
panic(err)
}
a.Cache().Save(context.Background(), "", cache.NewFileBlob(f))
entry := blob.Download(context.Background())
if entry == nil {
return
}
}

83
command.go Normal file
View file

@ -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
}

45
command_test.go Normal file
View file

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

189
context.go Normal file
View file

@ -0,0 +1,189 @@
package sdk
import (
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
)
// 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 bool `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 bool `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"`
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"`
// 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, error) {
var merr error
context := &GitHubContext{
APIURL: "https://api.github.com",
GraphqlURL: "https://api.github.com/graphql",
ServerURL: "https://github.com",
}
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, err := parseBool(c.env("GITHUB_ACTIONS")); err == nil {
context.Actions = v
} else {
merr = errors.Join(merr, err)
}
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, err := parseBool(c.env("GITHUB_REF_PROTECTED")); err == nil {
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 {
context.RetentionDays = v
} else {
merr = errors.Join(merr, err)
}
if v, err := parseInt(c.env("GITHUB_RUN_ATTEMPT")); err == nil {
context.RunAttempt = v
} else {
merr = errors.Join(merr, err)
}
if v, err := parseInt(c.env("GITHUB_RUN_ID")); err == nil {
context.RunID = v
} else {
merr = errors.Join(merr, err)
}
if v, err := parseInt(c.env("GITHUB_RUN_NUMBER")); err == nil {
context.RunNumber = v
} else {
merr = errors.Join(merr, err)
}
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 context.EventPath != "" {
eventData, err := os.ReadFile(context.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 {
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)
}

144
context_test.go Normal file
View file

@ -0,0 +1,144 @@
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",
},
},
{
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",
},
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",
},
},
{
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, err := a.Context()
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)
}
})
}
}

2
go.mod
View file

@ -1,3 +1,5 @@
module git.geekeey.de/actions/sdk
go 1.22.5
require golang.org/x/sync v0.7.0 // indirect

2
go.sum Normal file
View file

@ -0,0 +1,2 @@
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=