1
0
mirror of https://github.com/goreleaser/goreleaser.git synced 2025-01-30 04:50:45 +02:00

feat(announce): added Slack notification options (#2988)

* feat(announce): added Slack notification options

This feature adds support for specifying a richer content in Slack
announcements. We may now specify "blocks" and "attachments" to produce
better-looking announcement messages.

* fixes #2986

The goreleaser configuration only exposes the top-level structures and does not
check the validity of the Slack API internal structures. This way, we do
not inject hard dependencies on changes in the Slack API.

Notice: untyped config parsing introduces a little hack to have yaml and
JSON marshaling work together properly. This hack won't be necessary
with yaml.v3.

How this has been tested?
-------------------------

* Added unit tests for the config parsing
* Added a (skipped) e2e test.
  For now, this requires a valid Slack webhook, so I've been able to test this manually.

Signed-off-by: Frederic BIDON <fredbi@yahoo.com>

* added more unit tests

Signed-off-by: Frederic BIDON <fredbi@yahoo.com>

* removed yaml.v2 hack

Signed-off-by: Frederic BIDON <fredbi@yahoo.com>
This commit is contained in:
fredbi 2022-03-30 14:42:59 +02:00 committed by GitHub
parent 8d6ef40020
commit 905a1640f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 660 additions and 6 deletions

View File

@ -1,6 +1,7 @@
package slack
import (
"encoding/json"
"fmt"
"github.com/apex/log"
@ -47,12 +48,22 @@ func (Pipe) Announce(ctx *context.Context) error {
log.Infof("posting: '%s'", msg)
// optional processing of advanced formatting options
blocks, attachments, err := parseAdvancedFormatting(ctx)
if err != nil {
return err
}
wm := &slack.WebhookMessage{
Username: ctx.Config.Announce.Slack.Username,
IconEmoji: ctx.Config.Announce.Slack.IconEmoji,
IconURL: ctx.Config.Announce.Slack.IconURL,
Channel: ctx.Config.Announce.Slack.Channel,
Text: msg,
// optional enrichments
Blocks: blocks,
Attachments: attachments,
}
err = slack.PostWebhook(cfg.Webhook, wm)
@ -62,3 +73,43 @@ func (Pipe) Announce(ctx *context.Context) error {
return nil
}
func parseAdvancedFormatting(ctx *context.Context) (*slack.Blocks, []slack.Attachment, error) {
var blocks *slack.Blocks
if in := ctx.Config.Announce.Slack.Blocks; len(in) > 0 {
blocks = &slack.Blocks{BlockSet: make([]slack.Block, 0, len(in))}
if err := unmarshal(ctx, in, blocks); err != nil {
return nil, nil, fmt.Errorf("announce: slack blocks: %w", err)
}
}
var attachments []slack.Attachment
if in := ctx.Config.Announce.Slack.Attachments; len(in) > 0 {
attachments = make([]slack.Attachment, 0, len(in))
if err := unmarshal(ctx, in, &attachments); err != nil {
return nil, nil, fmt.Errorf("announce: slack attachments: %w", err)
}
}
return blocks, attachments, nil
}
func unmarshal(ctx *context.Context, in interface{}, target interface{}) error {
jazon, err := json.Marshal(in)
if err != nil {
return fmt.Errorf("announce: failed to marshal input as JSON: %w", err)
}
tplApplied, err := tmpl.New(ctx).Apply(string(jazon))
if err != nil {
return fmt.Errorf("announce: failed to evaluate template: %w", err)
}
if err = json.Unmarshal([]byte(tplApplied), target); err != nil {
return fmt.Errorf("announce: failed to unmarshal into target: %w", err)
}
return nil
}

View File

@ -1,10 +1,14 @@
package slack
import (
"bytes"
"os"
"testing"
"github.com/goreleaser/goreleaser/internal/yaml"
"github.com/goreleaser/goreleaser/pkg/config"
"github.com/goreleaser/goreleaser/pkg/context"
"github.com/slack-go/slack"
"github.com/stretchr/testify/require"
)
@ -55,3 +59,292 @@ func TestSkip(t *testing.T) {
require.False(t, Pipe{}.Skip(ctx))
})
}
const testVersion = "v1.2.3"
func TestParseRichText(t *testing.T) {
t.Parallel()
t.Run("parse only - full slack config with blocks and attachments", func(t *testing.T) {
t.Parallel()
var project config.Project
require.NoError(t, yaml.Unmarshal(goodRichSlackConf(), &project))
ctx := context.New(project)
ctx.Version = testVersion
blocks, attachments, err := parseAdvancedFormatting(ctx)
require.NoError(t, err)
require.Len(t, blocks.BlockSet, 4)
require.Len(t, attachments, 2)
})
t.Run("parse only - slack config with bad blocks", func(t *testing.T) {
t.Parallel()
var project config.Project
require.NoError(t, yaml.Unmarshal(badBlocksSlackConf(), &project))
ctx := context.New(project)
ctx.Version = testVersion
_, _, err := parseAdvancedFormatting(ctx)
require.Error(t, err)
require.ErrorContains(t, err, "json")
})
t.Run("parse only - slack config with bad attachments", func(t *testing.T) {
t.Parallel()
var project config.Project
require.NoError(t, yaml.Unmarshal(badAttachmentsSlackConf(), &project))
ctx := context.New(project)
ctx.Version = testVersion
_, _, err := parseAdvancedFormatting(ctx)
require.Error(t, err)
require.ErrorContains(t, err, "json")
})
}
func TestRichText(t *testing.T) {
t.Parallel()
os.Setenv("SLACK_WEBHOOK", slackTestHook())
t.Run("e2e - full slack config with blocks and attachments", func(t *testing.T) {
t.SkipNow() // requires a valid webhook for integration testing
t.Parallel()
var project config.Project
require.NoError(t, yaml.Unmarshal(goodRichSlackConf(), &project))
ctx := context.New(project)
ctx.Version = testVersion
require.NoError(t, Pipe{}.Announce(ctx))
})
t.Run("slack config with bad blocks", func(t *testing.T) {
t.Parallel()
var project config.Project
require.NoError(t, yaml.Unmarshal(badBlocksSlackConf(), &project))
ctx := context.New(project)
ctx.Version = testVersion
err := Pipe{}.Announce(ctx)
require.Error(t, err)
require.ErrorContains(t, err, "json")
})
}
func TestUnmarshall(t *testing.T) {
t.Parallel()
t.Run("happy unmarshal", func(t *testing.T) {
t.Parallel()
ctx := context.New(config.Project{})
ctx.Version = testVersion
var blocks slack.Blocks
require.NoError(t, unmarshal(ctx, []interface{}{map[string]interface{}{"type": "divider"}}, &blocks))
})
t.Run("unmarshal fails on MarshalJSON", func(t *testing.T) {
t.Parallel()
ctx := context.New(config.Project{})
ctx.Version = testVersion
var blocks slack.Blocks
require.Error(t, unmarshal(ctx, []interface{}{map[string]interface{}{"type": func() {}}}, &blocks))
})
t.Run("unmarshal happy to resolve template", func(t *testing.T) {
t.Parallel()
var project config.Project
require.NoError(t, yaml.Unmarshal(goodTemplateSlackConf(), &project))
ctx := context.New(project)
ctx.Version = testVersion
var blocks slack.Blocks
require.NoError(t, unmarshal(ctx, ctx.Config.Announce.Slack.Blocks, &blocks))
require.Len(t, blocks.BlockSet, 1)
header, ok := blocks.BlockSet[0].(*slack.HeaderBlock)
require.True(t, ok)
require.Contains(t, header.Text.Text, testVersion)
})
t.Run("unmarshal fails on resolve template", func(t *testing.T) {
t.Parallel()
var project config.Project
require.NoError(t, yaml.Unmarshal(badTemplateSlackConf(), &project))
ctx := context.New(project)
ctx.Version = testVersion
var blocks slack.Blocks
require.Error(t, unmarshal(ctx, ctx.Config.Announce.Slack.Blocks, &blocks))
})
}
func slackTestHook() string {
// redacted: replace this by a real Slack Web Incoming Hook to test the featue end to end.
const hook = "https://hooks.slack.com/services/*********/***********/************************"
return hook
}
func goodRichSlackConf() []byte {
const conf = `
project_name: test
announce:
slack:
enabled: true
message_template: fallback
channel: my_channel
blocks:
- type: header
text:
type: plain_text
text: '{{ .Version }}'
- type: section
text:
type: mrkdwn
text: |
Heading
=======
# Other Heading
*Bold*
_italic_
~Strikethrough~
## Heading 2
### Heading 3
* List item 1
* List item 2
- List item 3
- List item 4
[link](https://example.com)
<https://example.com|link>
:)
:star:
- type: divider
- type: section
text:
type: mrkdwn
text: |
my release
attachments:
-
title: Release artifacts
color: '#2eb886'
text: |
*Helm chart packages*
- fallback: full changelog
color: '#2eb886'
title: Full Change Log
text: |
* this link
* that link
`
buf := bytes.NewBufferString(conf)
return bytes.ReplaceAll(buf.Bytes(), []byte("\t"), []byte(" "))
}
func badBlocksSlackConf() []byte {
const conf = `
project_name: test
announce:
slack:
enabled: true
message_template: fallback
channel: my_channel
blocks:
- type: header
text: invalid # <- wrong type for Slack API
`
buf := bytes.NewBufferString(conf)
return bytes.ReplaceAll(buf.Bytes(), []byte("\t"), []byte(" "))
}
func badAttachmentsSlackConf() []byte {
const conf = `
project_name: test
announce:
slack:
enabled: true
message_template: fallback
channel: my_channel
attachments:
-
title:
- Release artifacts
- wrong # <- title is not an array
color: '#2eb886'
text: |
*Helm chart packages*
`
buf := bytes.NewBufferString(conf)
return bytes.ReplaceAll(buf.Bytes(), []byte("\t"), []byte(" "))
}
func goodTemplateSlackConf() []byte {
const conf = `
project_name: test
announce:
slack:
enabled: true
message_template: '{{ .Version }}'
channel: my_channel
blocks:
- type: header
text:
type: plain_text
text: '{{ .Version }}'
`
buf := bytes.NewBufferString(conf)
return bytes.ReplaceAll(buf.Bytes(), []byte("\t"), []byte(" "))
}
func badTemplateSlackConf() []byte {
const conf = `
project_name: test
announce:
slack:
enabled: true
message_template: fallback
channel: my_channel
blocks:
- type: header
text:
type: plain_text
text: '{{ .Wrong }}'
`
buf := bytes.NewBufferString(conf)
return bytes.ReplaceAll(buf.Bytes(), []byte("\t"), []byte(" "))
}

View File

@ -3,6 +3,7 @@
package config
import (
"encoding/json"
"io"
"os"
"strings"
@ -979,12 +980,14 @@ type Reddit struct {
}
type Slack struct {
Enabled bool `yaml:"enabled,omitempty"`
MessageTemplate string `yaml:"message_template,omitempty"`
Channel string `yaml:"channel,omitempty"`
Username string `yaml:"username,omitempty"`
IconEmoji string `yaml:"icon_emoji,omitempty"`
IconURL string `yaml:"icon_url,omitempty"`
Enabled bool `yaml:"enabled,omitempty"`
MessageTemplate string `yaml:"message_template,omitempty"`
Channel string `yaml:"channel,omitempty"`
Username string `yaml:"username,omitempty"`
IconEmoji string `yaml:"icon_emoji,omitempty"`
IconURL string `yaml:"icon_url,omitempty"`
Blocks []SlackBlock `yaml:"blocks,omitempty"`
Attachments []SlackAttachment `yaml:"attachments,omitempty"`
}
type Discord struct {
@ -1058,3 +1061,47 @@ func LoadReader(fd io.Reader) (config Project, err error) {
log.WithField("config", config).Debug("loaded config file")
return config, err
}
// SlackBlock represents the untyped structure of a rich slack message layout.
type SlackBlock struct {
Internal interface{}
}
// UnmarshalYAML is a custom unmarshaler that unmarshals a YAML slack block as untyped interface{}.
func (a *SlackBlock) UnmarshalYAML(unmarshal func(interface{}) error) error {
var yamlv2 interface{}
if err := unmarshal(&yamlv2); err != nil {
return err
}
a.Internal = yamlv2
return nil
}
// MarshalJSON marshals a slack block as JSON.
func (a SlackBlock) MarshalJSON() ([]byte, error) {
return json.Marshal(a.Internal)
}
// SlackAttachment represents the untyped structure of a slack message attachment.
type SlackAttachment struct {
Internal interface{}
}
// UnmarshalYAML is a custom unmarshaler that unmarshals a YAML slack attachment as untyped interface{}.
func (a *SlackAttachment) UnmarshalYAML(unmarshal func(interface{}) error) error {
var yamlv2 interface{}
if err := unmarshal(&yamlv2); err != nil {
return err
}
a.Internal = yamlv2
return nil
}
// MarshalJSON marshals a slack attachment as JSON.
func (a SlackAttachment) MarshalJSON() ([]byte, error) {
return json.Marshal(a.Internal)
}

View File

@ -0,0 +1,246 @@
package config
import (
"bytes"
"encoding/json"
"errors"
"io"
"testing"
"github.com/stretchr/testify/require"
)
func TestUnmarshalSlackBlocks(t *testing.T) {
t.Parallel()
t.Run("valid blocks", func(t *testing.T) {
t.Parallel()
prop, err := LoadReader(goodBlocksSlackConf())
require.NoError(t, err)
expectedBlocks := []SlackBlock{
{
Internal: map[string]interface{}{
"type": "header",
"text": map[string]interface{}{
"type": "plain_text",
"text": "{{ .Version }}",
},
},
},
{
Internal: map[string]interface{}{
"text": map[string]interface{}{
"type": "mrkdwn",
"text": "Heading\n=======\n\n**Bold**\n",
},
"type": "section",
},
},
}
// assert Unmarshal from YAML
require.Equal(t, expectedBlocks, prop.Announce.Slack.Blocks)
jazon, err := json.Marshal(prop.Announce.Slack.Blocks)
require.NoError(t, err)
var untyped []SlackBlock
require.NoError(t, json.Unmarshal(jazon, &untyped))
// assert that JSON Marshal didn't alter the struct
require.Equal(t, expectedBlocks, prop.Announce.Slack.Blocks)
})
t.Run("invalid blocks", func(t *testing.T) {
t.Parallel()
_, err := LoadReader(badBlocksSlackConf())
require.Error(t, err)
})
}
func TestUnmarshalSlackAttachments(t *testing.T) {
t.Parallel()
t.Run("valid attachments", func(t *testing.T) {
t.Parallel()
prop, err := LoadReader(goodAttachmentsSlackConf())
require.NoError(t, err)
expectedAttachments := []SlackAttachment{
{
Internal: map[string]interface{}{
"color": "#46a64f",
"fields": []interface{}{
map[string]interface{}{
"short": false,
"title": "field 1",
"value": "value 1",
},
},
"footer": "a footer",
"mrkdwn_in": []interface{}{
"text",
},
"pretext": "optional",
"text": "another",
"title": "my_title",
},
},
}
// assert Unmarshal from YAML
require.Equal(t, expectedAttachments, prop.Announce.Slack.Attachments)
jazon, err := json.Marshal(prop.Announce.Slack.Attachments)
require.NoError(t, err)
var untyped []SlackAttachment
require.NoError(t, json.Unmarshal(jazon, &untyped))
// assert that JSON Marshal didn't alter the struct
require.Equal(t, expectedAttachments, prop.Announce.Slack.Attachments)
})
t.Run("invalid attachments", func(t *testing.T) {
t.Parallel()
_, err := LoadReader(badAttachmentsSlackConf())
require.Error(t, err)
})
}
func TestUnmarshalYAMLSlackBlocks(t *testing.T) {
// func (a *SlackAttachment) UnmarshalYAML(unmarshal func(interface{}) error) error {
t.Parallel()
const testError = "testError"
erf := func(_ interface{}) error {
return errors.New(testError)
}
t.Run("SlackBlock.UnmarshalYAML error case", func(t *testing.T) {
t.Parallel()
var block SlackBlock
err := block.UnmarshalYAML(erf)
require.Error(t, err)
require.ErrorContains(t, err, testError)
})
t.Run("SlackAttachment.UnmarshalYAML error case", func(t *testing.T) {
t.Parallel()
var attachment SlackAttachment
err := attachment.UnmarshalYAML(erf)
require.Error(t, err)
require.ErrorContains(t, err, testError)
})
}
func goodBlocksSlackConf() io.Reader {
const conf = `
announce:
slack:
enabled: true
username: my_user
message_template: fallback
channel: my_channel
blocks:
- type: header
text:
type: plain_text
text: '{{ .Version }}'
- type: section
text:
type: mrkdwn
text: |
Heading
=======
**Bold**
`
buf := bytes.NewBufferString(conf)
return bytes.NewReader(bytes.ReplaceAll(buf.Bytes(), []byte("\t"), []byte(" ")))
}
func badBlocksSlackConf() io.Reader {
const conf = `
announce:
slack:
enabled: true
username: my_user
message_template: fallback
channel: my_channel
blocks:
type: header
text:
type: plain_text
text: '{{ .Version }}'
type: section
text:
type: mrkdwn
text: |
**Bold**
`
buf := bytes.NewBufferString(conf)
return bytes.NewReader(bytes.ReplaceAll(buf.Bytes(), []byte("\t"), []byte(" ")))
}
func goodAttachmentsSlackConf() io.Reader {
const conf = `
announce:
slack:
enabled: true
username: my_user
message_template: fallback
channel: my_channel
attachments:
- mrkdwn_in: ["text"]
color: '#46a64f'
pretext: optional
title: my_title
text: another
fields:
- title: field 1
value: value 1
short: false
footer: a footer
`
buf := bytes.NewBufferString(conf)
return bytes.NewReader(bytes.ReplaceAll(buf.Bytes(), []byte("\t"), []byte(" ")))
}
func badAttachmentsSlackConf() io.Reader {
const conf = `
announce:
slack:
enabled: true
username: my_user
message_template: fallback
channel: my_channel
attachments:
key:
mrkdwn_in: ["text"]
color: #46a64f
pretext: optional
title: my_title
text: another
fields:
- title: field 1
value: value 1
short: false
footer: a footer
`
buf := bytes.NewBufferString(conf)
return bytes.NewReader(bytes.ReplaceAll(buf.Bytes(), []byte("\t"), []byte(" ")))
}

View File

@ -30,6 +30,23 @@ announce:
# URL to an image to use as the icon for this message.
icon_url: ''
# Blocks for advanced formatting, see: https://api.slack.com/messaging/webhooks#advanced_message_formatting
# and https://api.slack.com/messaging/composing/layouts#adding-blocks.
#
# Templating is possible inside this structure.
#
# Attention: goreleaser doesn't check the full structure of the Slack API: please make sure that
# your configuration for advanced message formatting abides by this API.
blocks: []
# Attachments, see: https://api.slack.com/reference/messaging/attachments
#
# Templating is possible inside this structure.
#
# Attention: goreleaser doesn't check the full structure of the Slack API: please make sure that
# your configuration for advanced message formatting abides by this API.
attachments: []
```
!!! tip