mirror of
https://github.com/goreleaser/goreleaser.git
synced 2024-12-31 01:53:50 +02:00
feat: add generic webhook announcer (#2750)
This commit is contained in:
parent
bdfb09cfb7
commit
339bcabbb5
@ -17,6 +17,7 @@ import (
|
||||
"github.com/goreleaser/goreleaser/internal/pipe/teams"
|
||||
"github.com/goreleaser/goreleaser/internal/pipe/telegram"
|
||||
"github.com/goreleaser/goreleaser/internal/pipe/twitter"
|
||||
"github.com/goreleaser/goreleaser/internal/pipe/webhook"
|
||||
"github.com/goreleaser/goreleaser/internal/tmpl"
|
||||
"github.com/goreleaser/goreleaser/pkg/context"
|
||||
)
|
||||
@ -39,6 +40,7 @@ var announcers = []Announcer{
|
||||
teams.Pipe{},
|
||||
telegram.Pipe{},
|
||||
twitter.Pipe{},
|
||||
webhook.Pipe{},
|
||||
}
|
||||
|
||||
// Pipe that announces releases.
|
||||
|
119
internal/pipe/webhook/webhook.go
Normal file
119
internal/pipe/webhook/webhook.go
Normal file
@ -0,0 +1,119 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/apex/log"
|
||||
"github.com/caarlos0/env/v6"
|
||||
"github.com/goreleaser/goreleaser/internal/tmpl"
|
||||
"github.com/goreleaser/goreleaser/pkg/context"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMessageTemplate = `{ "message": "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"}`
|
||||
ContentTypeHeaderKey = "Content-Type"
|
||||
UserAgentHeaderKey = "User-Agent"
|
||||
UserAgentHeaderValue = "gorleaser"
|
||||
AuthorizationHeaderKey = "Authorization"
|
||||
DefaultContentType = "application/json; charset=utf-8"
|
||||
)
|
||||
|
||||
type Pipe struct{}
|
||||
|
||||
func (Pipe) String() string { return "webhook" }
|
||||
func (Pipe) Skip(ctx *context.Context) bool { return !ctx.Config.Announce.Webhook.Enabled }
|
||||
|
||||
type Config struct {
|
||||
BasicAuthHeader string `env:"BASIC_AUTH_HEADER_VALUE"`
|
||||
BearerTokenHeader string `env:"BEARER_TOKEN_HEADER_VALUE"`
|
||||
}
|
||||
|
||||
func (p Pipe) Default(ctx *context.Context) error {
|
||||
if ctx.Config.Announce.Webhook.MessageTemplate == "" {
|
||||
ctx.Config.Announce.Webhook.MessageTemplate = defaultMessageTemplate
|
||||
}
|
||||
if ctx.Config.Announce.Webhook.ContentType == "" {
|
||||
ctx.Config.Announce.Webhook.ContentType = DefaultContentType
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p Pipe) Announce(ctx *context.Context) error {
|
||||
var cfg Config
|
||||
if err := env.Parse(&cfg); err != nil {
|
||||
return fmt.Errorf("announce: failed to announce to webhook: %w", err)
|
||||
}
|
||||
|
||||
endpointURLConfig, err := tmpl.New(ctx).Apply(ctx.Config.Announce.Webhook.EndpointURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("announce: failed to announce to webhook: %w", err)
|
||||
}
|
||||
if len(endpointURLConfig) == 0 {
|
||||
return errors.New("announce: failed to announce to webhook: no endpoint url")
|
||||
}
|
||||
|
||||
if _, err := url.ParseRequestURI(endpointURLConfig); err != nil {
|
||||
return fmt.Errorf("announce: failed to announce to webhook: %w", err)
|
||||
}
|
||||
endpointURL, err := url.Parse(endpointURLConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("announce: failed to announce to webhook: %w", err)
|
||||
}
|
||||
|
||||
msg, err := tmpl.New(ctx).Apply(ctx.Config.Announce.Webhook.MessageTemplate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("announce: failed to announce to webhook: %s", err)
|
||||
}
|
||||
|
||||
log.Infof("posting: '%s'", msg)
|
||||
customTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
|
||||
customTransport.TLSClientConfig = &tls.Config{
|
||||
InsecureSkipVerify: ctx.Config.Announce.Webhook.SkipTLSVerify,
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: customTransport,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, endpointURL.String(), strings.NewReader(msg))
|
||||
if err != nil {
|
||||
return fmt.Errorf("announce: failed to announce to webhook: %w", err)
|
||||
}
|
||||
req.Header.Add(ContentTypeHeaderKey, ctx.Config.Announce.Webhook.ContentType)
|
||||
req.Header.Add(UserAgentHeaderKey, UserAgentHeaderValue)
|
||||
|
||||
if cfg.BasicAuthHeader != "" {
|
||||
log.Debugf("set basic auth header")
|
||||
req.Header.Add(AuthorizationHeaderKey, cfg.BasicAuthHeader)
|
||||
} else if cfg.BearerTokenHeader != "" {
|
||||
log.Debugf("set bearer token header")
|
||||
req.Header.Add(AuthorizationHeaderKey, cfg.BearerTokenHeader)
|
||||
}
|
||||
|
||||
for key, value := range ctx.Config.Announce.Webhook.Headers {
|
||||
log.Debugf("Header Key %s / Value %s", key, value)
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("announce: failed to announce to webhook: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK, http.StatusCreated, http.StatusAccepted, http.StatusNoContent:
|
||||
log.Infof("Post OK: '%v'", resp.StatusCode)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
log.Infof("Response : %v\n", string(body))
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("request failed with status %v", resp.Status)
|
||||
}
|
||||
}
|
241
internal/pipe/webhook/webhook_test.go
Normal file
241
internal/pipe/webhook/webhook_test.go
Normal file
@ -0,0 +1,241 @@
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/goreleaser/goreleaser/pkg/config"
|
||||
"github.com/goreleaser/goreleaser/pkg/context"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStringer(t *testing.T) {
|
||||
require.Equal(t, Pipe{}.String(), "webhook")
|
||||
}
|
||||
|
||||
func TestNoEndpoint(t *testing.T) {
|
||||
ctx := context.New(config.Project{
|
||||
Announce: config.Announce{
|
||||
Webhook: config.Webhook{},
|
||||
},
|
||||
})
|
||||
require.EqualError(t, Pipe{}.Announce(ctx), `announce: failed to announce to webhook: no endpoint url`)
|
||||
}
|
||||
|
||||
func TestMalformedEndpoint(t *testing.T) {
|
||||
ctx := context.New(config.Project{
|
||||
Announce: config.Announce{
|
||||
Webhook: config.Webhook{
|
||||
EndpointURL: "httxxx://example.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.EqualError(t, Pipe{}.Announce(ctx), `announce: failed to announce to webhook: Post "httxxx://example.com": unsupported protocol scheme "httxxx"`)
|
||||
}
|
||||
|
||||
func TestAnnounceInvalidMessageTemplate(t *testing.T) {
|
||||
ctx := context.New(config.Project{
|
||||
Announce: config.Announce{
|
||||
Webhook: config.Webhook{
|
||||
EndpointURL: "https://example.com/webhook",
|
||||
MessageTemplate: "{{ .Foo }",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.EqualError(t, Pipe{}.Announce(ctx), `announce: failed to announce to webhook: template: tmpl:1: unexpected "}" in operand`)
|
||||
}
|
||||
|
||||
type WebHookServerMockMessage struct {
|
||||
Response string `json:"response"`
|
||||
UUID uuid.UUID `json:"uuid"`
|
||||
}
|
||||
|
||||
func TestAnnounceWebhook(t *testing.T) {
|
||||
responseServer := WebHookServerMockMessage{
|
||||
Response: "Thanks for the announcement!",
|
||||
UUID: uuid.New(),
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "webhook-test", string(body))
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
err = json.NewEncoder(w).Encode(responseServer)
|
||||
require.NoError(t, err)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx := context.New(config.Project{
|
||||
ProjectName: "webhook-test",
|
||||
Announce: config.Announce{
|
||||
Webhook: config.Webhook{
|
||||
EndpointURL: srv.URL,
|
||||
MessageTemplate: "{{ .ProjectName }}",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, Pipe{}.Announce(ctx))
|
||||
}
|
||||
|
||||
func TestAnnounceTLSWebhook(t *testing.T) {
|
||||
responseServer := WebHookServerMockMessage{
|
||||
Response: "Thanks for the announcement!",
|
||||
UUID: uuid.New(),
|
||||
}
|
||||
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "webhook-test", string(body))
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
err = json.NewEncoder(w).Encode(responseServer)
|
||||
require.NoError(t, err)
|
||||
}))
|
||||
defer srv.Close()
|
||||
fmt.Println(srv.URL)
|
||||
ctx := context.New(config.Project{
|
||||
ProjectName: "webhook-test",
|
||||
Announce: config.Announce{
|
||||
Webhook: config.Webhook{
|
||||
EndpointURL: srv.URL,
|
||||
MessageTemplate: "{{ .ProjectName }}",
|
||||
SkipTLSVerify: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, Pipe{}.Announce(ctx))
|
||||
}
|
||||
|
||||
func TestAnnounceTLSCheckCertWebhook(t *testing.T) {
|
||||
responseServer := WebHookServerMockMessage{
|
||||
Response: "Thanks for the announcement!",
|
||||
UUID: uuid.New(),
|
||||
}
|
||||
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
err := json.NewEncoder(w).Encode(responseServer)
|
||||
require.NoError(t, err)
|
||||
}))
|
||||
defer srv.Close()
|
||||
fmt.Println(srv.URL)
|
||||
ctx := context.New(config.Project{
|
||||
ProjectName: "webhook-test",
|
||||
Announce: config.Announce{
|
||||
Webhook: config.Webhook{
|
||||
EndpointURL: srv.URL,
|
||||
SkipTLSVerify: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.Error(t, Pipe{}.Announce(ctx))
|
||||
}
|
||||
|
||||
func TestAnnounceBasicAuthWebhook(t *testing.T) {
|
||||
responseServer := WebHookServerMockMessage{
|
||||
Response: "Thanks for the announcement!",
|
||||
UUID: uuid.New(),
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "webhook-test", string(body))
|
||||
|
||||
auth := r.Header.Get("Authorization")
|
||||
require.Equal(t, fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte("user:pass"))), auth)
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
err = json.NewEncoder(w).Encode(responseServer)
|
||||
require.NoError(t, err)
|
||||
}))
|
||||
|
||||
defer srv.Close()
|
||||
|
||||
ctx := context.New(config.Project{
|
||||
ProjectName: "webhook-test",
|
||||
Announce: config.Announce{
|
||||
Webhook: config.Webhook{
|
||||
EndpointURL: srv.URL,
|
||||
MessageTemplate: "{{ .ProjectName }}",
|
||||
},
|
||||
},
|
||||
})
|
||||
os.Setenv("BASIC_AUTH_HEADER_VALUE", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte("user:pass"))))
|
||||
defer os.Unsetenv("BASIC_AUTH_HEADER_VALUE")
|
||||
require.NoError(t, Pipe{}.Announce(ctx))
|
||||
}
|
||||
|
||||
func TestAnnounceAdditionalHeadersWebhook(t *testing.T) {
|
||||
responseServer := WebHookServerMockMessage{
|
||||
Response: "Thanks for the announcement!",
|
||||
UUID: uuid.New(),
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "webhook-test", string(body))
|
||||
|
||||
customHeader := r.Header.Get("X-Custom-Header")
|
||||
require.Equal(t, "custom-value", customHeader)
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
err = json.NewEncoder(w).Encode(responseServer)
|
||||
require.NoError(t, err)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx := context.New(config.Project{
|
||||
ProjectName: "webhook-test",
|
||||
Announce: config.Announce{
|
||||
Webhook: config.Webhook{
|
||||
EndpointURL: srv.URL,
|
||||
MessageTemplate: "{{ .ProjectName }}",
|
||||
Headers: map[string]string{
|
||||
"X-Custom-Header": "custom-value",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, Pipe{}.Announce(ctx))
|
||||
}
|
||||
|
||||
func TestSkip(t *testing.T) {
|
||||
t.Run("skip", func(t *testing.T) {
|
||||
require.True(t, Pipe{}.Skip(context.New(config.Project{})))
|
||||
})
|
||||
|
||||
t.Run("dont skip", func(t *testing.T) {
|
||||
ctx := context.New(config.Project{
|
||||
Announce: config.Announce{
|
||||
Webhook: config.Webhook{
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.False(t, Pipe{}.Skip(ctx))
|
||||
})
|
||||
}
|
@ -884,6 +884,16 @@ type Announce struct {
|
||||
Mattermost Mattermost `yaml:"mattermost,omitempty"`
|
||||
LinkedIn LinkedIn `yaml:"linkedin,omitempty"`
|
||||
Telegram Telegram `yaml:"telegram,omitempty"`
|
||||
Webhook Webhook `yaml:"webhook,omitempty"`
|
||||
}
|
||||
|
||||
type Webhook struct {
|
||||
Enabled bool `yaml:"enabled,omitempty"`
|
||||
SkipTLSVerify bool `yaml:"skip_tls_verify,omitempty"`
|
||||
MessageTemplate string `yaml:"message_template,omitempty"`
|
||||
EndpointURL string `yaml:"endpoint_url,omitempty"`
|
||||
Headers map[string]string `yaml:"headers,omitempty"`
|
||||
ContentType string `yaml:"content_type,omitempty"`
|
||||
}
|
||||
|
||||
type Twitter struct {
|
||||
|
@ -35,6 +35,7 @@ import (
|
||||
"github.com/goreleaser/goreleaser/internal/pipe/telegram"
|
||||
"github.com/goreleaser/goreleaser/internal/pipe/twitter"
|
||||
"github.com/goreleaser/goreleaser/internal/pipe/universalbinary"
|
||||
"github.com/goreleaser/goreleaser/internal/pipe/webhook"
|
||||
"github.com/goreleaser/goreleaser/pkg/context"
|
||||
)
|
||||
|
||||
@ -82,4 +83,5 @@ var Defaulters = []Defaulter{
|
||||
milestone.Pipe{},
|
||||
linkedin.Pipe{},
|
||||
telegram.Pipe{},
|
||||
webhook.Pipe{},
|
||||
}
|
||||
|
44
www/docs/customization/announce/webhook.md
Normal file
44
www/docs/customization/announce/webhook.md
Normal file
@ -0,0 +1,44 @@
|
||||
# Webhook
|
||||
|
||||
Webhooks are a way to receive notifications. With this `Goreleaser` functionality, you can send events to any server
|
||||
exposing a webhook.
|
||||
|
||||
If your endpoints are not secure, you can use following environment variables to configure them:
|
||||
|
||||
- BASIC_AUTH_HEADER_VALUE like `Basic <base64(username:password)>`
|
||||
- BEARER_TOKEN_HEADER_VALUE like `Bearer <token>`
|
||||
|
||||
Add following to your `.goreleaser.yaml` config to enable the webhook functionality:
|
||||
|
||||
```yaml
|
||||
# .goreleaser.yaml
|
||||
announce:
|
||||
webhook:
|
||||
# Whether its enabled or not.
|
||||
# Defaults to false.
|
||||
enabled: true
|
||||
|
||||
# Check the certificate of the webhook. Defaults to false.
|
||||
skip_tls_verify: true
|
||||
|
||||
# Message template to use while publishing.
|
||||
# Defaults to `{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}`
|
||||
message_template: '{ "title": "Awesome project {{.Tag}} is out!"}'
|
||||
|
||||
# Content type to use.
|
||||
# Defaults to `"application/json; charset=utf-8"`
|
||||
content_type: "application/json"
|
||||
|
||||
# Endpoint to send the webhook to.
|
||||
endpoint_url: "https://example.com/webhook"
|
||||
# Headers to send with the webhook.
|
||||
# For example:
|
||||
# headers:
|
||||
# Authorization: "Bearer <token>"
|
||||
headers:
|
||||
User-Agent: "goreleaser"
|
||||
|
||||
```
|
||||
|
||||
!!! tip
|
||||
Learn more about the [name template engine](/customization/templates/).
|
@ -122,6 +122,7 @@ nav:
|
||||
- customization/announce/teams.md
|
||||
- customization/announce/telegram.md
|
||||
- customization/announce/twitter.md
|
||||
- customization/announce/webhook.md
|
||||
- Command Line Usage:
|
||||
- goreleaser: cmd/goreleaser.md
|
||||
- goreleaser init: cmd/goreleaser_init.md
|
||||
|
Loading…
Reference in New Issue
Block a user