From b819d6d0654022be9384944c3fa5c2e4cc7203e9 Mon Sep 17 00:00:00 2001 From: Till Klampaeckel Date: Sun, 20 Apr 2025 17:46:00 +0200 Subject: [PATCH 1/5] Chore(cache): refactor/fix s3 - implement expiration (through proper meta data) - add a goroutine to run in the background --- cmd/bot/main.go | 11 ++-- internal/content/content.go | 4 +- internal/content/s3_cache.go | 62 ++++++++++++++----- internal/content/s3_cleanup.go | 110 +++++++++++++++++++++++++++++++++ internal/utils/utils.go | 18 ++++++ 5 files changed, 185 insertions(+), 20 deletions(-) create mode 100644 internal/content/s3_cleanup.go create mode 100644 internal/utils/utils.go diff --git a/cmd/bot/main.go b/cmd/bot/main.go index e9ea8fc..54eb97a 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -124,11 +124,12 @@ func main() { Client: client, } - cacheClient := &content.CacheClientS3{ - MC: mc, - Bucket: cacheBucket, - CTX: ctx, - } + cacheClient := content.NewCacheClientS3(ctx, mc, cacheBucket) + + // Initialize and start the cleanup handler + cleanup := content.NewS3Cleanup(ctx, mc, cacheBucket) + cleanup.Start() + defer cleanup.Stop() if err := content.Start(githubToken, cacheClient); err != nil { return fmt.Errorf("failed to start service: %v", err) diff --git a/internal/content/content.go b/internal/content/content.go index 6cc9b3e..503dc37 100644 --- a/internal/content/content.go +++ b/internal/content/content.go @@ -3,6 +3,7 @@ package content import ( "context" "errors" + "fmt" "log/slog" "strings" @@ -10,6 +11,7 @@ import ( "github.com/ezeoleaf/larry/config" "github.com/ezeoleaf/larry/provider/github" "github.com/till/golangoss-bluesky/internal/bluesky" + "github.com/till/golangoss-bluesky/internal/utils" ) var ( @@ -29,7 +31,7 @@ func Start(token string, cacheClient cache.Client) error { func Do(ctx context.Context, c bluesky.Client) error { p, err := provider.GetContentToPublish() if err != nil { - slog.Error("error fetching content", slog.Any("err", err)) + utils.LogError(fmt.Errorf("error fetching content: %w", err)) return ErrCouldNotContent } diff --git a/internal/content/s3_cache.go b/internal/content/s3_cache.go index b546de1..678daeb 100644 --- a/internal/content/s3_cache.go +++ b/internal/content/s3_cache.go @@ -13,21 +13,46 @@ import ( // CacheClientS3 is a small cache that is backed by an S3-compatible store type CacheClientS3 struct { - MC *minio.Client - Bucket string - CTX context.Context + mc *minio.Client + bucket string + ctx context.Context + defaultExpiration time.Duration } -func (c *CacheClientS3) Set(key string, value interface{}, exp time.Duration) error { - data, err := json.Marshal(value) - if err != nil { +// NewCacheClientS3 creates a new S3 cache client with default settings +func NewCacheClientS3(ctx context.Context, mc *minio.Client, bucket string) *CacheClientS3 { + return &CacheClientS3{ + mc: mc, + bucket: bucket, + ctx: ctx, + defaultExpiration: 24 * time.Hour, + } +} + +func (c *CacheClientS3) Set(key string, value any, exp time.Duration) error { + var data bytes.Buffer + if err := json.NewEncoder(&data).Encode(value); err != nil { return err } - r := bytes.NewReader(data) + r := bytes.NewReader(data.Bytes()) + + // Use the provided expiration time or fall back to default + expiration := exp + if expiration == 0 { + expiration = c.defaultExpiration + } + + // Calculate the expiration time + expiresAt := time.Now().Add(expiration) - _, err = c.MC.PutObject(c.CTX, c.Bucket, key, r, int64(r.Len()), minio.PutObjectOptions{ - Expires: time.Now().Add(exp), + // Set metadata to track expiration + metadata := map[string]string{ + "expires-at": expiresAt.Format(time.RFC3339), + } + + _, err := c.mc.PutObject(c.ctx, c.bucket, key, r, int64(r.Len()), minio.PutObjectOptions{ + UserMetadata: metadata, }) return err } @@ -36,17 +61,27 @@ func (c *CacheClientS3) Set(key string, value interface{}, exp time.Duration) er // does not exist, in other case we can use minio.ToErrorResponse(err) to extract more details about the // potential S3 related error func (c *CacheClientS3) Get(key string) (string, error) { - if _, err := c.MC.StatObject(c.CTX, c.Bucket, key, minio.StatObjectOptions{}); err != nil { + // First check if object exists and get its metadata + objInfo, err := c.mc.StatObject(c.ctx, c.bucket, key, minio.StatObjectOptions{}) + if err != nil { return "", redis.Nil } - object, err := c.MC.GetObject(c.CTX, c.Bucket, key, minio.GetObjectOptions{}) + if expiresAt, ok := objInfo.UserMetadata["expires-at"]; ok { + expTime, err := time.Parse(time.RFC3339, expiresAt) + if err == nil && time.Now().After(expTime) { + // Object has expired, delete it and return not found + _ = c.Del(key) // Ignore delete error + return "", redis.Nil + } + } + + object, err := c.mc.GetObject(c.ctx, c.bucket, key, minio.GetObjectOptions{}) if err != nil { return "", err } var val any - if err := json.NewDecoder(object).Decode(&val); err != nil { return "", err } @@ -63,12 +98,11 @@ func (c *CacheClientS3) Get(key string) (string, error) { } func (c *CacheClientS3) Del(key string) error { - return c.MC.RemoveObject(c.CTX, c.Bucket, key, minio.RemoveObjectOptions{ + return c.mc.RemoveObject(c.ctx, c.bucket, key, minio.RemoveObjectOptions{ ForceDelete: true, }) } func (c *CacheClientS3) Scan(key string, action func(context.Context, string) error) error { - return nil } diff --git a/internal/content/s3_cleanup.go b/internal/content/s3_cleanup.go new file mode 100644 index 0000000..a4bb034 --- /dev/null +++ b/internal/content/s3_cleanup.go @@ -0,0 +1,110 @@ +package content + +import ( + "context" + "fmt" + "time" + + "github.com/minio/minio-go/v7" + "github.com/till/golangoss-bluesky/internal/utils" +) + +// S3Cleanup handles background cleanup of expired objects in S3 +type S3Cleanup struct { + mc *minio.Client + bucket string + // cleanupInterval is how often to run the cleanup routine + cleanupInterval time.Duration + // stopCleanup is used to signal the cleanup routine to stop + stopCleanup chan struct{} +} + +// NewS3Cleanup creates a new S3 cleanup handler +func NewS3Cleanup(mc *minio.Client, bucket string) *S3Cleanup { + return &S3Cleanup{ + mc: mc, + bucket: bucket, + cleanupInterval: 24 * time.Hour, + stopCleanup: make(chan struct{}), + } +} + +// Start begins the background cleanup routine +func (c *S3Cleanup) Start(ctx context.Context) { + go c.cleanupRoutine(ctx) +} + +// Stop stops the background cleanup routine +func (c *S3Cleanup) Stop() { + close(c.stopCleanup) +} + +// cleanupRoutine periodically checks for and deletes expired objects +func (c *S3Cleanup) cleanupRoutine(ctx context.Context) { + ticker := time.NewTicker(c.cleanupInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if err := c.cleanupExpired(ctx); err != nil { + utils.LogErrorWithContext(ctx, fmt.Errorf("failed to cleanup expired objects: %w", err)) + } + case <-c.stopCleanup: + return + } + } +} + +// cleanupExpired scans the bucket for expired objects and deletes them +func (c *S3Cleanup) cleanupExpired(ctx context.Context) error { + filter := minio.ListObjectsOptions{ + Recursive: true, + WithMetadata: true, + } + + objectsCh := c.mc.ListObjects(ctx, c.bucket, filter) + for obj := range objectsCh { + if obj.Err != nil { + utils.LogErrorWithContext(ctx, fmt.Errorf("failed to list objects: %w", obj.Err)) + continue + } + + // Check if object has expiration metadata + expiresAt, ok := obj.UserMetadata["expires-at"] + if !ok { + utils.LogErrorWithContext( + ctx, + fmt.Errorf("no expiration metadata found for object: %s", obj.Key), + ) + if err := c.delete(ctx, obj); err != nil { + return err + } + continue + } + + expTime, err := time.Parse(time.RFC3339, expiresAt) + if err != nil { + utils.LogError(fmt.Errorf("failed to parse expiration time (%s): %w", obj.Key, err)) + continue + } + if !time.Now().After(expTime) { + continue + } + + // Object has expired, delete it + if err := c.delete(ctx, obj); err != nil { + return err + } + } + return nil +} + +func (c *S3Cleanup) delete(ctx context.Context, obj minio.ObjectInfo) error { + if err := c.mc.RemoveObject(ctx, c.bucket, obj.Key, minio.RemoveObjectOptions{ + ForceDelete: true, + }); err != nil { + return fmt.Errorf("failed to delete expired object (%s): %w", obj.Key, err) + } + return nil +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..e698f1f --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,18 @@ +package utils + +import ( + "context" + "log/slog" +) + +func LogError(err error) { + slog.Error(err.Error(), LogAttr(err)) +} + +func LogErrorWithContext(ctx context.Context, err error) { + slog.ErrorContext(ctx, err.Error(), LogAttr(err)) +} + +func LogAttr(err error) slog.Attr { + return slog.Any("error", err) +} From b7484abc899742094fc6aaf6b730ee61d99d8566 Mon Sep 17 00:00:00 2001 From: Till Klampaeckel Date: Mon, 21 Apr 2025 16:21:09 +0200 Subject: [PATCH 2/5] Fix(bluesky): run in a loop to catch connection errors --- cmd/bot/main.go | 128 ++++++++++++++++++++++++++++-------------------- 1 file changed, 76 insertions(+), 52 deletions(-) diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 54eb97a..49033fc 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -14,6 +14,7 @@ import ( bk "github.com/tailscale/go-bluesky" "github.com/till/golangoss-bluesky/internal/bluesky" "github.com/till/golangoss-bluesky/internal/content" + "github.com/till/golangoss-bluesky/internal/utils" "github.com/urfave/cli/v2" ) @@ -34,6 +35,8 @@ var ( githubToken string = "" checkInterval time.Duration = 15 * time.Minute + // How long to wait before retrying after a connection failure + reconnectDelay time.Duration = 2 * time.Minute ) func init() { @@ -44,6 +47,75 @@ func init() { ctx = context.Background() } +// connectBluesky establishes a connection to Bluesky and logs in +func connectBluesky(ctx context.Context) (*bk.Client, error) { + client, err := bk.Dial(ctx, bk.ServerBskySocial) + if err != nil { + return nil, fmt.Errorf("failed to open connection: %v", err) + } + + if err := client.Login(ctx, blueskyHandle, blueskyAppKey); err != nil { + client.Close() + switch { + case errors.Is(err, bk.ErrMasterCredentials): + return nil, fmt.Errorf("you're not allowed to use your full-access credentials, please create an appkey") + case errors.Is(err, bk.ErrLoginUnauthorized): + return nil, fmt.Errorf("username of application password seems incorrect, please double check") + default: + return nil, fmt.Errorf("login failed: %v", err) + } + } + + return client, nil +} + +// runWithReconnect attempts to run the bot with automatic reconnection on failure +func runWithReconnect(ctx context.Context, mc *minio.Client) error { + for { + client, err := connectBluesky(ctx) + if err != nil { + slog.Error("failed to connect to Bluesky", "error", err) + slog.Info("retrying connection", "delay", reconnectDelay) + time.Sleep(reconnectDelay) + continue + } + + c := bluesky.Client{ + Client: client, + } + + cacheClient := content.NewCacheClientS3(ctx, mc, cacheBucket) + + // Initialize and start the cleanup handler + cleanup := content.NewS3Cleanup(mc, cacheBucket) + cleanup.Start(ctx) + defer cleanup.Stop() + + if err := content.Start(githubToken, cacheClient); err != nil { + slog.Error("failed to start service", "error", err) + client.Close() + time.Sleep(reconnectDelay) + continue + } + + // Run the main loop + for { + slog.DebugContext(ctx, "checking...") + if err := content.Do(ctx, c); err != nil { + if !errors.Is(err, content.ErrCouldNotContent) { + slog.Error("error during content check", "error", err) + client.Close() + time.Sleep(reconnectDelay) + break + } + slog.DebugContext(ctx, "backing off...") + } + + time.Sleep(checkInterval) + } + } +} + func main() { bot := cli.App{ Name: "golangoss-bluesky", @@ -88,25 +160,7 @@ func main() { }, Action: func(cCtx *cli.Context) error { - // FIXME: run this in a control loop; or we crash the app - client, err := bk.Dial(ctx, bk.ServerBskySocial) - if err != nil { - return fmt.Errorf("failed to open connection: %v", err) - } - defer client.Close() - - if err := client.Login(ctx, blueskyHandle, blueskyAppKey); err != nil { - switch { - case errors.Is(err, bk.ErrMasterCredentials): - return fmt.Errorf("you're not allowed to use your full-access credentials, please create an appkey") - case errors.Is(err, bk.ErrLoginUnauthorized): - return fmt.Errorf("username of application password seems incorrect, please double check") - default: - return fmt.Errorf("something else went wrong, please look at the returned error") - } - } - - // init s3 client + // Initialize S3 client mc, err := minio.New(awsEndpoint, &minio.Options{ Creds: credentials.NewStaticV4(awsAccessKeyId, awsSecretKey, ""), Secure: true, @@ -115,47 +169,17 @@ func main() { return fmt.Errorf("failed to initialize minio client: %v", err) } - // ensure the bucket exists + // Ensure the bucket exists if err := mc.MakeBucket(ctx, cacheBucket, minio.MakeBucketOptions{}); err != nil { return fmt.Errorf("failed to create bucket: %v", err) } - c := bluesky.Client{ - Client: client, - } - - cacheClient := content.NewCacheClientS3(ctx, mc, cacheBucket) - - // Initialize and start the cleanup handler - cleanup := content.NewS3Cleanup(ctx, mc, cacheBucket) - cleanup.Start() - defer cleanup.Stop() - - if err := content.Start(githubToken, cacheClient); err != nil { - return fmt.Errorf("failed to start service: %v", err) - } - - var runErr error - - for { - slog.DebugContext(ctx, "checking...") - if err := content.Do(ctx, c); err != nil { - if !errors.Is(err, content.ErrCouldNotContent) { - runErr = err - break - } - slog.DebugContext(ctx, "backing off...") - } - - time.Sleep(checkInterval) - } - return runErr + return runWithReconnect(ctx, mc) }, } if err := bot.Run(os.Args); err != nil { - slog.ErrorContext(ctx, err.Error()) + utils.LogErrorWithContext(ctx, err) os.Exit(1) } - } From 0095351d3eb6303624d2ec504a493163aba32ed8 Mon Sep 17 00:00:00 2001 From: Till Klampaeckel Date: Mon, 21 Apr 2025 19:22:25 +0200 Subject: [PATCH 3/5] Chore(deps): upgrade go --- go.mod | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0741d31..66f8787 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/till/golangoss-bluesky -go 1.23.0 - -toolchain go1.24.1 +go 1.24.1 require ( github.com/go-redis/redis/v8 v8.11.5 From ba51df0c6c44885bb6e313df96d10a47bed77818 Mon Sep 17 00:00:00 2001 From: Till Klampaeckel Date: Mon, 21 Apr 2025 20:58:25 +0200 Subject: [PATCH 4/5] Chore(cs): comments, delete dead code, etc. --- cmd/bot/main.go | 30 ++++++++++++-------------- internal/bluesky/bluesky.go | 3 ++- internal/bluesky/doc.go | 2 ++ internal/content/cache.go | 39 ---------------------------------- internal/content/content.go | 6 +++++- internal/content/doc.go | 2 ++ internal/content/s3_cache.go | 5 ++++- internal/content/s3_cleanup.go | 1 + internal/utils/utils.go | 4 ++++ 9 files changed, 33 insertions(+), 59 deletions(-) create mode 100644 internal/bluesky/doc.go delete mode 100644 internal/content/cache.go create mode 100644 internal/content/doc.go diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 49033fc..0fec083 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -1,3 +1,4 @@ +// package main is the entry point for this application package main import ( @@ -19,20 +20,17 @@ import ( ) var ( - blueskyHandle string = "till+bluesky-golang@lagged.biz" - blueskyAppKey string = "" - - cacheBucket string = "golangoss-cache-bucket" - - ctx context.Context + blueskyHandle = "till+bluesky-golang@lagged.biz" + blueskyAppKey = "" // for cache - awsEndpoint string = "" - awsAccessKeyId string = "" - awsSecretKey string = "" + awsEndpoint = "" + awsAccessKeyID = "" + awsSecretKey = "" + cacheBucket = "golangoss-cache-bucket" // for github crawling - githubToken string = "" + githubToken = "" checkInterval time.Duration = 15 * time.Minute // How long to wait before retrying after a connection failure @@ -43,8 +41,6 @@ func init() { slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelDebug, }))) - - ctx = context.Background() } // connectBluesky establishes a connection to Bluesky and logs in @@ -143,7 +139,7 @@ func main() { Name: "aws-access-key-id", EnvVars: []string{"AWS_ACCESS_KEY_ID"}, Required: true, - Destination: &awsAccessKeyId, + Destination: &awsAccessKeyID, }, &cli.StringFlag{ Name: "aws-secret-key", @@ -162,7 +158,7 @@ func main() { Action: func(cCtx *cli.Context) error { // Initialize S3 client mc, err := minio.New(awsEndpoint, &minio.Options{ - Creds: credentials.NewStaticV4(awsAccessKeyId, awsSecretKey, ""), + Creds: credentials.NewStaticV4(awsAccessKeyID, awsSecretKey, ""), Secure: true, }) if err != nil { @@ -170,16 +166,16 @@ func main() { } // Ensure the bucket exists - if err := mc.MakeBucket(ctx, cacheBucket, minio.MakeBucketOptions{}); err != nil { + if err := mc.MakeBucket(cCtx.Context, cacheBucket, minio.MakeBucketOptions{}); err != nil { return fmt.Errorf("failed to create bucket: %v", err) } - return runWithReconnect(ctx, mc) + return runWithReconnect(cCtx.Context, mc) }, } if err := bot.Run(os.Args); err != nil { - utils.LogErrorWithContext(ctx, err) + utils.LogError(err) os.Exit(1) } } diff --git a/internal/bluesky/bluesky.go b/internal/bluesky/bluesky.go index 54b716f..9938e80 100644 --- a/internal/bluesky/bluesky.go +++ b/internal/bluesky/bluesky.go @@ -16,6 +16,7 @@ import ( bk "github.com/tailscale/go-bluesky" ) +// Client wraps the official bluesky sdk type Client struct { Client *bk.Client } @@ -51,7 +52,7 @@ func PostRecord(title, description, url, author, stargazers, hashtags string) *b text += "\n\n" + hashtags } - var startRepoURL int64 = 0 + var startRepoURL int64 facets := []*bsky.RichtextFacet{} facets = append(facets, addFacet( diff --git a/internal/bluesky/doc.go b/internal/bluesky/doc.go new file mode 100644 index 0000000..fc30c52 --- /dev/null +++ b/internal/bluesky/doc.go @@ -0,0 +1,2 @@ +// Package bluesky wraps the official bluesky sdk +package bluesky diff --git a/internal/content/cache.go b/internal/content/cache.go deleted file mode 100644 index 7185ee8..0000000 --- a/internal/content/cache.go +++ /dev/null @@ -1,39 +0,0 @@ -package content - -import ( - "context" - "log/slog" - "sync" - "time" - - "github.com/go-redis/redis/v8" -) - -// CacheClientProcess is an in process cache that adheres to larry's interface -type CacheClientProcess struct { - store sync.Map -} - -func (c *CacheClientProcess) Set(key string, value interface{}, exp time.Duration) error { - slog.Debug("set", slog.String("key", key), slog.Any("value", value)) - c.store.Store(key, value) - return nil -} - -func (c *CacheClientProcess) Get(key string) (string, error) { - slog.Debug("get", slog.String("key", key)) - val, status := c.store.Load(key) - if !status { - return "", redis.Nil - } - return val.(string), nil -} - -func (c *CacheClientProcess) Del(key string) error { - c.store.Delete(key) - return nil -} - -func (c *CacheClientProcess) Scan(key string, action func(context.Context, string) error) error { - return nil -} diff --git a/internal/content/content.go b/internal/content/content.go index 503dc37..469b2aa 100644 --- a/internal/content/content.go +++ b/internal/content/content.go @@ -15,10 +15,13 @@ import ( ) var ( - provider github.Provider + provider github.Provider + + // ErrCouldNotContent is returned when content cannot be fetched ErrCouldNotContent = errors.New("could not get content") ) +// Start bootstraps the content provider func Start(token string, cacheClient cache.Client) error { cfg := config.Config{ Language: "go", @@ -28,6 +31,7 @@ func Start(token string, cacheClient cache.Client) error { return nil } +// Do gets content from the provider and posts it to bluesky func Do(ctx context.Context, c bluesky.Client) error { p, err := provider.GetContentToPublish() if err != nil { diff --git a/internal/content/doc.go b/internal/content/doc.go new file mode 100644 index 0000000..724949a --- /dev/null +++ b/internal/content/doc.go @@ -0,0 +1,2 @@ +// Package content provides a custom cache implementation +package content diff --git a/internal/content/s3_cache.go b/internal/content/s3_cache.go index 678daeb..4996150 100644 --- a/internal/content/s3_cache.go +++ b/internal/content/s3_cache.go @@ -29,6 +29,7 @@ func NewCacheClientS3(ctx context.Context, mc *minio.Client, bucket string) *Cac } } +// Set sets a value in the cache func (c *CacheClientS3) Set(key string, value any, exp time.Duration) error { var data bytes.Buffer if err := json.NewEncoder(&data).Encode(value); err != nil { @@ -97,12 +98,14 @@ func (c *CacheClientS3) Get(key string) (string, error) { } } +// Del deletes a value from the cache func (c *CacheClientS3) Del(key string) error { return c.mc.RemoveObject(c.ctx, c.bucket, key, minio.RemoveObjectOptions{ ForceDelete: true, }) } -func (c *CacheClientS3) Scan(key string, action func(context.Context, string) error) error { +// Scan satisfies the interface for the cache client +func (c *CacheClientS3) Scan(_ string, _ func(context.Context, string) error) error { return nil } diff --git a/internal/content/s3_cleanup.go b/internal/content/s3_cleanup.go index a4bb034..cdafe38 100644 --- a/internal/content/s3_cleanup.go +++ b/internal/content/s3_cleanup.go @@ -100,6 +100,7 @@ func (c *S3Cleanup) cleanupExpired(ctx context.Context) error { return nil } +// delete an object from s3 func (c *S3Cleanup) delete(ctx context.Context, obj minio.ObjectInfo) error { if err := c.mc.RemoveObject(ctx, c.bucket, obj.Key, minio.RemoveObjectOptions{ ForceDelete: true, diff --git a/internal/utils/utils.go b/internal/utils/utils.go index e698f1f..3f75938 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -1,3 +1,4 @@ +// Package utils provides utility/convenience functions package utils import ( @@ -5,14 +6,17 @@ import ( "log/slog" ) +// LogError logs an error func LogError(err error) { slog.Error(err.Error(), LogAttr(err)) } +// LogErrorWithContext logs an error with a context func LogErrorWithContext(ctx context.Context, err error) { slog.ErrorContext(ctx, err.Error(), LogAttr(err)) } +// LogAttr returns a slog.Attr for an error func LogAttr(err error) slog.Attr { return slog.Any("error", err) } From 4724b69e39301ba39bac8aa328a02842edcb98b4 Mon Sep 17 00:00:00 2001 From: Till Klampaeckel Date: Mon, 21 Apr 2025 20:58:48 +0200 Subject: [PATCH 5/5] Chore(ci): workflow --- .github/workflows/pr.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/pr.yml diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..d6a88b4 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,27 @@ +name: pr + +on: + pull_request: + +concurrency: + group: pr-${{ github.event.number }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - uses: docker://morphy/revive-action:v2 + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: go test -v ./...