feat(notify): release v1.8.0 — Slack/Teams/Google Chat/Telegram/webhook notifications
ci / vet, staticcheck, test, build (push) Successful in 4m10s
docs / Build and deploy docs (push) Failing after 10s
release / Build and publish release (push) Successful in 5m9s

- Add internal/notify package: best-effort release notifications to Slack, Microsoft Teams, Google Chat, Telegram, and a generic JSON webhook; every target is independently opt-in and a failed notification never fails the release
- Add notify config section (slack_webhook_url, teams_webhook_url, google_chat_webhook_url, telegram_bot_token/telegram_chat_id, webhook_url) with matching env var fallbacks; 100% coverage plus FuzzMessagePayloads
- Wire notification sending into run() — fires after tag+push (including --no-release), skipped on --no-push/--no-commit since nothing was published yet
- Document the notify section in README, Hugo docs, and .releaser.yml template; update CLAUDE.md architecture/fuzzing tables
- Also commit the Apache License 2.0 docs-site footer link (docs/hugo.toml geekdocContentLicense), left uncommitted from a prior change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 13:55:57 +02:00
parent 51343768d9
commit d790a9edfe
24 changed files with 1023 additions and 22 deletions
+20
View File
@@ -0,0 +1,20 @@
package notify
import (
"context"
"fmt"
)
// googleChatPayload matches the Google Chat Incoming Webhook contract: a
// single "text" field.
type googleChatPayload struct {
Text string `json:"text"`
}
func newGoogleChatPayload(msg Message) googleChatPayload {
return googleChatPayload{Text: fmt.Sprintf("*Released %s*\n\n%s", msg.Version, msg.Notes)}
}
func sendGoogleChat(ctx context.Context, webhookURL string, msg Message) error {
return postJSON(ctx, webhookURL, newGoogleChatPayload(msg))
}
+37
View File
@@ -0,0 +1,37 @@
package notify
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSendGoogleChat(t *testing.T) {
var got googleChatPayload
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&got) //nolint:errcheck
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
if err := sendGoogleChat(context.Background(), srv.URL, Message{Version: "v1.2.3", Notes: "- feat: y"}); err != nil {
t.Fatalf("sendGoogleChat: %v", err)
}
if !strings.Contains(got.Text, "v1.2.3") || !strings.Contains(got.Text, "feat: y") {
t.Errorf("payload text = %q", got.Text)
}
}
func TestSendGoogleChatError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}))
defer srv.Close()
if err := sendGoogleChat(context.Background(), srv.URL, Message{Version: "v1.0.0"}); err == nil {
t.Fatal("expected error for 400 response")
}
}
+88
View File
@@ -0,0 +1,88 @@
// Package notify sends best-effort release notifications to chat and webhook targets.
package notify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// Message is the content of a release notification, shared across all targets.
type Message struct {
Version string // tag name, e.g. "v1.2.3"
Notes string // release notes body
}
// Config holds the destination for every supported notification target.
// Every field is opt-in — a target is skipped when its required fields are empty.
type Config struct {
SlackWebhookURL string
TeamsWebhookURL string
GoogleChatWebhookURL string
TelegramBotToken string
TelegramChatID string
WebhookURL string
}
var httpClient = &http.Client{Timeout: 15 * time.Second}
// SendAll sends msg to every target configured in cfg. Each target is attempted
// independently — a failure on one does not prevent the others from being tried.
// Returns one error per failed target, or nil if every configured target
// succeeded (or none were configured).
func SendAll(ctx context.Context, cfg Config, msg Message) []error {
var errs []error
if cfg.SlackWebhookURL != "" {
if err := sendSlack(ctx, cfg.SlackWebhookURL, msg); err != nil {
errs = append(errs, fmt.Errorf("slack: %w", err))
}
}
if cfg.TeamsWebhookURL != "" {
if err := sendTeams(ctx, cfg.TeamsWebhookURL, msg); err != nil {
errs = append(errs, fmt.Errorf("teams: %w", err))
}
}
if cfg.GoogleChatWebhookURL != "" {
if err := sendGoogleChat(ctx, cfg.GoogleChatWebhookURL, msg); err != nil {
errs = append(errs, fmt.Errorf("google chat: %w", err))
}
}
if cfg.TelegramBotToken != "" && cfg.TelegramChatID != "" {
if err := sendTelegram(ctx, cfg.TelegramBotToken, cfg.TelegramChatID, msg); err != nil {
errs = append(errs, fmt.Errorf("telegram: %w", err))
}
}
if cfg.WebhookURL != "" {
if err := sendWebhook(ctx, cfg.WebhookURL, msg); err != nil {
errs = append(errs, fmt.Errorf("webhook: %w", err))
}
}
return errs
}
// postJSON POSTs payload as JSON to url and treats any non-2xx response as an error.
func postJSON(ctx context.Context, url string, payload any) error {
body, _ := json.Marshal(payload) // payload fields are always plain strings — Marshal cannot fail here
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("returned status %d", resp.StatusCode)
}
return nil
}
+28
View File
@@ -0,0 +1,28 @@
package notify
import (
"encoding/json"
"strings"
"testing"
)
// FuzzMessagePayloads verifies that building a notification payload never
// panics on arbitrary version/notes strings, no matter the target — release
// notes are generated from free-form commit messages and end up embedded in
// every payload below.
func FuzzMessagePayloads(f *testing.F) {
f.Add("v1.2.3", "release notes")
f.Add("", "")
f.Add("v1.0.0", "* markdown _weird_ [chars] `code` <html> & \"quotes\"")
f.Add("tag\nwith\nnewline", "notes\x00with\xffbinary")
f.Add("v1.0.0", strings.Repeat("x", 10000))
f.Fuzz(func(t *testing.T, version, notes string) {
msg := Message{Version: version, Notes: notes}
_, _ = json.Marshal(newSlackPayload(msg))
_, _ = json.Marshal(newGoogleChatPayload(msg))
_, _ = json.Marshal(newTeamsPayload(msg))
_, _ = json.Marshal(newTelegramPayload("chat-id", msg))
_, _ = json.Marshal(newWebhookPayload(msg))
})
}
+137
View File
@@ -0,0 +1,137 @@
package notify
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
// alwaysFailTransport returns an error for every request.
type alwaysFailTransport struct{}
func (alwaysFailTransport) RoundTrip(*http.Request) (*http.Response, error) {
return nil, &testTransportError{"connection refused"}
}
type testTransportError struct{ msg string }
func (e *testTransportError) Error() string { return e.msg }
func TestPostJSONSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
if err := postJSON(context.Background(), srv.URL, map[string]string{"a": "b"}); err != nil {
t.Fatalf("postJSON: %v", err)
}
}
func TestPostJSONBadURL(t *testing.T) {
err := postJSON(context.Background(), "http://\x00bad", map[string]string{"a": "b"})
if err == nil {
t.Fatal("expected error for invalid URL")
}
}
func TestPostJSONRequestFails(t *testing.T) {
orig := httpClient
httpClient = &http.Client{Transport: alwaysFailTransport{}}
defer func() { httpClient = orig }()
err := postJSON(context.Background(), "http://127.0.0.1:1", map[string]string{"a": "b"})
if err == nil {
t.Fatal("expected error when HTTP request fails")
}
}
func TestPostJSONNon2xx(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
err := postJSON(context.Background(), srv.URL, map[string]string{"a": "b"})
if err == nil {
t.Fatal("expected error for 500 response")
}
}
func TestSendAllNoneConfigured(t *testing.T) {
errs := SendAll(context.Background(), Config{}, Message{Version: "v1.0.0"})
if errs != nil {
t.Fatalf("expected no errors, got %v", errs)
}
}
func TestSendAllAllSucceed(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
origBase := telegramAPIBase
telegramAPIBase = srv.URL
defer func() { telegramAPIBase = origBase }()
cfg := Config{
SlackWebhookURL: srv.URL,
TeamsWebhookURL: srv.URL,
GoogleChatWebhookURL: srv.URL,
TelegramBotToken: "tok",
TelegramChatID: "123",
WebhookURL: srv.URL,
}
errs := SendAll(context.Background(), cfg, Message{Version: "v1.0.0", Notes: "notes"})
if errs != nil {
t.Fatalf("expected no errors, got %v", errs)
}
}
func TestSendAllAllFail(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
origBase := telegramAPIBase
telegramAPIBase = srv.URL
defer func() { telegramAPIBase = origBase }()
cfg := Config{
SlackWebhookURL: srv.URL,
TeamsWebhookURL: srv.URL,
GoogleChatWebhookURL: srv.URL,
TelegramBotToken: "tok",
TelegramChatID: "123",
WebhookURL: srv.URL,
}
errs := SendAll(context.Background(), cfg, Message{Version: "v1.0.0"})
if len(errs) != 5 {
t.Fatalf("expected 5 errors, got %d: %v", len(errs), errs)
}
}
func TestSendAllTelegramRequiresBothFields(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
origBase := telegramAPIBase
telegramAPIBase = srv.URL
defer func() { telegramAPIBase = origBase }()
// Only bot token set — chat ID missing — Telegram target must be skipped.
errs := SendAll(context.Background(), Config{TelegramBotToken: "tok"}, Message{Version: "v1.0.0"})
if errs != nil {
t.Fatalf("expected no errors, got %v", errs)
}
if calls != 0 {
t.Fatalf("expected telegram to be skipped, got %d calls", calls)
}
}
+20
View File
@@ -0,0 +1,20 @@
package notify
import (
"context"
"fmt"
)
// slackPayload matches the Slack Incoming Webhook contract: a single "text"
// field, interpreted as Slack mrkdwn.
type slackPayload struct {
Text string `json:"text"`
}
func newSlackPayload(msg Message) slackPayload {
return slackPayload{Text: fmt.Sprintf("*Released %s*\n\n%s", msg.Version, msg.Notes)}
}
func sendSlack(ctx context.Context, webhookURL string, msg Message) error {
return postJSON(ctx, webhookURL, newSlackPayload(msg))
}
+40
View File
@@ -0,0 +1,40 @@
package notify
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSendSlack(t *testing.T) {
var got slackPayload
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q", ct)
}
json.NewDecoder(r.Body).Decode(&got) //nolint:errcheck
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
if err := sendSlack(context.Background(), srv.URL, Message{Version: "v1.2.3", Notes: "- fix: x"}); err != nil {
t.Fatalf("sendSlack: %v", err)
}
if !strings.Contains(got.Text, "v1.2.3") || !strings.Contains(got.Text, "fix: x") {
t.Errorf("payload text = %q", got.Text)
}
}
func TestSendSlackError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
if err := sendSlack(context.Background(), srv.URL, Message{Version: "v1.0.0"}); err == nil {
t.Fatal("expected error for 403 response")
}
}
+31
View File
@@ -0,0 +1,31 @@
package notify
import (
"context"
"fmt"
)
// teamsPayload is a Microsoft Teams "Incoming Webhook" O365 connector card
// (MessageCard schema) — the format Teams webhook URLs still accept.
type teamsPayload struct {
Type string `json:"@type"`
Context string `json:"@context"`
Summary string `json:"summary"`
Title string `json:"title"`
Text string `json:"text"`
}
func newTeamsPayload(msg Message) teamsPayload {
title := fmt.Sprintf("Released %s", msg.Version)
return teamsPayload{
Type: "MessageCard",
Context: "http://schema.org/extensions",
Summary: title,
Title: title,
Text: msg.Notes,
}
}
func sendTeams(ctx context.Context, webhookURL string, msg Message) error {
return postJSON(ctx, webhookURL, newTeamsPayload(msg))
}
+43
View File
@@ -0,0 +1,43 @@
package notify
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSendTeams(t *testing.T) {
var got teamsPayload
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&got) //nolint:errcheck
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
if err := sendTeams(context.Background(), srv.URL, Message{Version: "v1.2.3", Notes: "- fix: z"}); err != nil {
t.Fatalf("sendTeams: %v", err)
}
if got.Type != "MessageCard" {
t.Errorf("Type = %q, want MessageCard", got.Type)
}
if !strings.Contains(got.Title, "v1.2.3") || !strings.Contains(got.Summary, "v1.2.3") {
t.Errorf("Title/Summary = %q/%q, want to contain v1.2.3", got.Title, got.Summary)
}
if !strings.Contains(got.Text, "fix: z") {
t.Errorf("Text = %q", got.Text)
}
}
func TestSendTeamsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()
if err := sendTeams(context.Background(), srv.URL, Message{Version: "v1.0.0"}); err == nil {
t.Fatal("expected error for 503 response")
}
}
+30
View File
@@ -0,0 +1,30 @@
package notify
import (
"context"
"fmt"
)
// telegramAPIBase is the Telegram Bot API host. Overridden in tests.
var telegramAPIBase = "https://api.telegram.org"
// telegramPayload matches the Telegram Bot API sendMessage contract. Notes
// are sent as plain text (no parse_mode) — release notes come from arbitrary
// commit messages and are not guaranteed to be valid Telegram Markdown/HTML,
// and a malformed entity makes the whole request fail with a 400.
type telegramPayload struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
}
func newTelegramPayload(chatID string, msg Message) telegramPayload {
return telegramPayload{
ChatID: chatID,
Text: fmt.Sprintf("Released %s\n\n%s", msg.Version, msg.Notes),
}
}
func sendTelegram(ctx context.Context, botToken, chatID string, msg Message) error {
url := fmt.Sprintf("%s/bot%s/sendMessage", telegramAPIBase, botToken)
return postJSON(ctx, url, newTelegramPayload(chatID, msg))
}
+53
View File
@@ -0,0 +1,53 @@
package notify
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSendTelegram(t *testing.T) {
var got telegramPayload
var path string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
json.NewDecoder(r.Body).Decode(&got) //nolint:errcheck
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
orig := telegramAPIBase
telegramAPIBase = srv.URL
defer func() { telegramAPIBase = orig }()
if err := sendTelegram(context.Background(), "bot-token", "chat-1", Message{Version: "v1.2.3", Notes: "notes body"}); err != nil {
t.Fatalf("sendTelegram: %v", err)
}
if !strings.Contains(path, "bot-token") {
t.Errorf("path = %q, want to contain bot token", path)
}
if got.ChatID != "chat-1" {
t.Errorf("ChatID = %q, want chat-1", got.ChatID)
}
if !strings.Contains(got.Text, "v1.2.3") || !strings.Contains(got.Text, "notes body") {
t.Errorf("Text = %q", got.Text)
}
}
func TestSendTelegramError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}))
defer srv.Close()
orig := telegramAPIBase
telegramAPIBase = srv.URL
defer func() { telegramAPIBase = orig }()
if err := sendTelegram(context.Background(), "bot-token", "chat-1", Message{Version: "v1.0.0"}); err == nil {
t.Fatal("expected error for 400 response")
}
}
+18
View File
@@ -0,0 +1,18 @@
package notify
import "context"
// webhookPayload is a generic JSON envelope for arbitrary API webhooks
// (e.g. n8n, Zapier, a custom receiver) that don't follow a chat-app schema.
type webhookPayload struct {
Version string `json:"version"`
Notes string `json:"notes"`
}
func newWebhookPayload(msg Message) webhookPayload {
return webhookPayload(msg)
}
func sendWebhook(ctx context.Context, url string, msg Message) error {
return postJSON(ctx, url, newWebhookPayload(msg))
}
+39
View File
@@ -0,0 +1,39 @@
package notify
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestSendWebhook(t *testing.T) {
var got webhookPayload
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewDecoder(r.Body).Decode(&got) //nolint:errcheck
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
if err := sendWebhook(context.Background(), srv.URL, Message{Version: "v1.2.3", Notes: "notes body"}); err != nil {
t.Fatalf("sendWebhook: %v", err)
}
if got.Version != "v1.2.3" {
t.Errorf("Version = %q, want v1.2.3", got.Version)
}
if got.Notes != "notes body" {
t.Errorf("Notes = %q, want %q", got.Notes, "notes body")
}
}
func TestSendWebhookError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
if err := sendWebhook(context.Background(), srv.URL, Message{Version: "v1.0.0"}); err == nil {
t.Fatal("expected error for 500 response")
}
}