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
+8
View File
@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [1.8.0] - 2026-07-12
### Added
- **Release notifications** — new `internal/notify` package; best-effort notification on release to Slack, Microsoft Teams, Google Chat, Telegram, and/or a generic JSON webhook; every target is independently opt-in and a failed notification never fails the release
- **`notify` config section** — `slack_webhook_url`, `teams_webhook_url`, `google_chat_webhook_url`, `telegram_bot_token` + `telegram_chat_id`, `webhook_url`; each also configurable via env var (`SLACK_WEBHOOK_URL`, `TEAMS_WEBHOOK_URL`, `GOOGLE_CHAT_WEBHOOK_URL`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, `RELEASER_WEBHOOK_URL`)
- **`FuzzMessagePayloads`** in `internal/notify` — 100% per-package statement coverage maintained across all 15 packages
## [1.7.1] - 2026-07-12
### Added
+15
View File
@@ -15,9 +15,12 @@ internal/config/ — .releaser.yml loader + env var overlay + source trackin
internal/ghclient/ — minimal GitHub Releases API client
internal/gitutil/ — go-git helpers: tag discovery, commit walker, push
internal/glclient/ — minimal GitLab Releases API client
internal/gradle/ — build.gradle / build.gradle.kts version reader/writer
internal/maven/ — pom.xml version reader/writer
internal/node/ — package.json version reader/writer
internal/notes/ — release notes body generator
internal/notify/ — Slack / Teams / Google Chat / Telegram / webhook release notifications
internal/pyproject/ — pyproject.toml version reader/writer
internal/version/ — semver next-version calculator
```
@@ -67,6 +70,7 @@ All seed cases must pass. The table below is authoritative — keep it in sync w
| `internal/maven` | `FuzzReadVersion`, `FuzzReplaceProjectVersion` | reads/rewrites arbitrary XML file content |
| `internal/node` | `FuzzReadVersion`, `FuzzWriteVersion` | reads/rewrites arbitrary JSON file content |
| `internal/notes` | `FuzzGenerate` | generates notes from arbitrary commit messages |
| `internal/notify` | `FuzzMessagePayloads` | builds notification JSON payloads from arbitrary release notes |
Packages **not** requiring fuzz tests (no free-form text parsing): `internal/config` (yaml.v3 handles parsing), `internal/ghclient` (HTTP client, no text parsing), `internal/gitutil` (git operations), `internal/version` (typed inputs only), `cmd` (CLI orchestration). When adding a new package, check whether it parses text or rewrites files — if yes, add a row above.
@@ -75,6 +79,17 @@ Fuzz seed corpus guidelines:
- Include empty string, binary/non-UTF-8 bytes (`"\x00\xff"`), and inputs that stress known branches (e.g. existing `## [version]` heading for changelog).
- The fuzz body must never assert on return values — only verify no panic.
## Documentation sync
**Every change that adds, changes, or removes a feature, flag, or config key must update all of the following in the same commit:**
- `README.md` — features list, usage examples, option tables, version badge
- `CHANGELOG.md` — new dated `## [X.Y.Z]` entry (see `/release` skill for format)
- `ROADMAP.md` — mark shipped items `✓`, add a "Shipped in vX.Y.Z" note, drop outdated caveats
- Hugo docs under `docs/content/` (`installation.md`, `usage.md`, `configuration.md`, `ci-integration.md`, `changelog.md`) — whichever pages describe the changed behavior
Treat these as one unit: a PR that changes CLI behavior or config but leaves any of the above stale is incomplete. `docs/content/changelog.md` only needs an entry for tagged (minor/major) releases, matching the pattern already in that file — patch-only releases are covered by the root `CHANGELOG.md` but not duplicated there.
## Dependency rules
- **No new external dependencies** unless absolutely necessary. The project deliberately avoids pulling in large ecosystems.
+16 -2
View File
@@ -2,7 +2,7 @@
<img src="docs/static/images/releaser-logo-128.png" alt="releaser logo" width="128">
![release](https://img.shields.io/badge/release-v1.7.1-blue.svg)
![release](https://img.shields.io/badge/release-v1.8.0-blue.svg)
A CI-friendly release automation tool for GitFlow workflows using Conventional Commits.
@@ -28,6 +28,7 @@ release/1.2 branch
3. **Commit analysis** — parses Conventional Commits between last tag and HEAD
4. **Version bump** — increments patch (the minor is owned by the branch)
5. **Release** — updates `pom.xml` / `package.json` / `build.gradle`, commits, tags, creates GitLab or GitHub release
6. **Notify** — best-effort notification to Slack, Microsoft Teams, Google Chat, Telegram, and/or a generic webhook
## Version bump rules
@@ -139,6 +140,14 @@ gitlab:
github:
token: "" # env GITHUB_TOKEN (never commit this)
repo: "" # "owner/repo" format
notify: # opt-in — every field independent; failures are warnings, not errors
# slack_webhook_url: "" # or env SLACK_WEBHOOK_URL
# teams_webhook_url: "" # or env TEAMS_WEBHOOK_URL
# google_chat_webhook_url: "" # or env GOOGLE_CHAT_WEBHOOK_URL
# telegram_bot_token: "" # or env TELEGRAM_BOT_TOKEN (both required)
# telegram_chat_id: "" # or env TELEGRAM_CHAT_ID
# webhook_url: "" # generic {"version","notes"} JSON POST; or env RELEASER_WEBHOOK_URL
```
### Environment variables
@@ -150,8 +159,13 @@ github:
| `CI_PROJECT_ID` | GitLab project identifier (numeric) |
| `CI_PROJECT_PATH` | GitLab project identifier (fallback) |
| `GITHUB_TOKEN` | GitHub API auth |
| `SLACK_WEBHOOK_URL` | Slack release notification |
| `TEAMS_WEBHOOK_URL` | Microsoft Teams release notification |
| `GOOGLE_CHAT_WEBHOOK_URL` | Google Chat release notification |
| `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | Telegram release notification (both required) |
| `RELEASER_WEBHOOK_URL` | Generic webhook release notification |
When both `github.*` and `gitlab.*` are configured, GitHub takes precedence.
When both `github.*` and `gitlab.*` are configured, GitHub takes precedence. All `notify.*` targets are independent — any combination may be configured at once, and a failed notification never fails the release.
## CI integration (GitLab CI example)
+1 -1
View File
@@ -87,4 +87,4 @@
- ~~Gradle support (`build.gradle` / `build.gradle.kts`)~~ — ✓ shipped v1.6.0 (`internal/gradle`; Groovy + Kotlin DSL; multi-module via `gradle.build_files`; `--gradle` flag)
- ~~Python `pyproject.toml` version bump (`[project].version` and `[tool.poetry].version`; single and multi-path like Maven's `pom_paths`)~~ — ✓ shipped v1.7.0 (`internal/pyproject`; PEP 621 + Poetry; `python.pyproject_tomls`; `--pyproject` flag)
- Slack / Teams notification on release
- ~~Slack / Teams notification on release~~ — ✓ shipped v1.8.0 (`internal/notify`; Slack, Microsoft Teams, Google Chat, Telegram, and generic webhook; each opt-in via `notify.*` config or env var; best-effort — never fails the release)
+65 -17
View File
@@ -23,6 +23,7 @@ import (
"git.k3nny.fr/releaser/internal/pyproject"
"git.k3nny.fr/releaser/internal/node"
"git.k3nny.fr/releaser/internal/notes"
"git.k3nny.fr/releaser/internal/notify"
semver "git.k3nny.fr/releaser/internal/version"
)
@@ -122,6 +123,29 @@ github:
# Repository in "owner/repo" format.
# repo: ""
notify:
# Every field below is opt-in. A target is only used when its required
# fields are set (via this file, or the matching environment variable).
# Notification failures never fail the release — they're logged as warnings.
# Slack incoming webhook URL. Falls back to SLACK_WEBHOOK_URL.
# slack_webhook_url: ""
# Microsoft Teams incoming webhook URL. Falls back to TEAMS_WEBHOOK_URL.
# teams_webhook_url: ""
# Google Chat incoming webhook URL. Falls back to GOOGLE_CHAT_WEBHOOK_URL.
# google_chat_webhook_url: ""
# Telegram bot token and chat ID — both required. Fall back to
# TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID.
# telegram_bot_token: ""
# telegram_chat_id: ""
# Generic webhook URL — POSTed a {"version": ..., "notes": ...} JSON body.
# Falls back to RELEASER_WEBHOOK_URL.
# webhook_url: ""
`
var (
@@ -265,6 +289,15 @@ type options struct {
releaseEnvFile string
}
// maskedIfSet reports whether a secret-like config value is set, without
// printing the value itself.
func maskedIfSet(v string) string {
if v != "" {
return "(set)"
}
return "(not set)"
}
func printVerboseConfig(cfg config.Config, src config.Sources) {
logSection("configuration")
rows := []struct{ key, val string }{
@@ -320,20 +353,16 @@ func printVerboseConfig(cfg config.Config, src config.Sources) {
return strings.Join(paths, ", ")
}()},
{"gitlab.url", cfg.GitLab.URL},
{"gitlab.token", func() string {
if cfg.GitLab.Token != "" {
return "(set)"
}
return "(not set)"
}()},
{"gitlab.token", maskedIfSet(cfg.GitLab.Token)},
{"gitlab.project", cfg.GitLab.Project},
{"github.token", func() string {
if cfg.GitHub.Token != "" {
return "(set)"
}
return "(not set)"
}()},
{"github.token", maskedIfSet(cfg.GitHub.Token)},
{"github.repo", cfg.GitHub.Repo},
{"notify.slack_webhook_url", maskedIfSet(cfg.Notify.SlackWebhookURL)},
{"notify.teams_webhook_url", maskedIfSet(cfg.Notify.TeamsWebhookURL)},
{"notify.google_chat_webhook_url", maskedIfSet(cfg.Notify.GoogleChatWebhookURL)},
{"notify.telegram_bot_token", maskedIfSet(cfg.Notify.TelegramBotToken)},
{"notify.telegram_chat_id", maskedIfSet(cfg.Notify.TelegramChatID)},
{"notify.webhook_url", maskedIfSet(cfg.Notify.WebhookURL)},
}
for _, r := range rows {
source := src[r.key]
@@ -682,7 +711,10 @@ func run(o options) error {
}
logDone("pushed")
releaseNotes := notes.Generate(nextTag, messages)
if o.noRelease {
notifyRelease(cfg, nextTag, releaseNotes)
fmt.Printf("released %s\n", nextTag)
return nil
}
@@ -694,15 +726,31 @@ func run(o options) error {
}
if publisher == nil {
logWarn("no release provider configured — skipping release creation")
fmt.Printf("released %s\n", nextTag)
return nil
}
releaseNotes := notes.Generate(nextTag, messages)
} else {
if err := publisher.CreateRelease(context.Background(), nextTag, releaseNotes); err != nil {
return fmt.Errorf("create release: %w", err)
}
logDone("release created: %s", nextTag)
}
notifyRelease(cfg, nextTag, releaseNotes)
fmt.Printf("released %s\n", nextTag)
return nil
}
// notifyRelease sends best-effort release notifications to every configured
// target. Failures are logged as warnings, not errors — the release itself
// already succeeded by the time this runs.
func notifyRelease(cfg config.Config, tagName, releaseNotes string) {
notifyCfg := notify.Config{
SlackWebhookURL: cfg.Notify.SlackWebhookURL,
TeamsWebhookURL: cfg.Notify.TeamsWebhookURL,
GoogleChatWebhookURL: cfg.Notify.GoogleChatWebhookURL,
TelegramBotToken: cfg.Notify.TelegramBotToken,
TelegramChatID: cfg.Notify.TelegramChatID,
WebhookURL: cfg.Notify.WebhookURL,
}
for _, err := range notify.SendAll(context.Background(), notifyCfg, notify.Message{Version: tagName, Notes: releaseNotes}) {
logWarn("notification failed: %v", err)
}
}
+112
View File
@@ -526,6 +526,118 @@ func TestRunWithGitLab(t *testing.T) {
}
}
func TestRunWithNotify(t *testing.T) {
var notified bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
notified = true
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
_, dir := setupRepoWithRemote(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
t.Setenv("SLACK_WEBHOOK_URL", srv.URL)
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("release with notify: unexpected error: %v", err)
}
if !notified {
t.Error("expected Slack webhook to be called")
}
}
func TestRunNotifyFailureDoesNotFailRelease(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
_, dir := setupRepoWithRemote(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
t.Setenv("SLACK_WEBHOOK_URL", srv.URL)
old := os.Stderr
r, wPipe, _ := os.Pipe()
os.Stderr = wPipe
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
wPipe.Close()
os.Stderr = old
rawBytes, _ := io.ReadAll(r)
output := string(rawBytes)
if err != nil {
t.Fatalf("a failed notification must not fail the release: %v", err)
}
if !strings.Contains(output, "notification failed") {
t.Errorf("expected a notification-failed warning, got:\n%s", output)
}
}
func TestRunNoReleaseStillNotifies(t *testing.T) {
var notified bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
notified = true
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
_, dir := setupRepoWithRemote(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
t.Setenv("SLACK_WEBHOOK_URL", srv.URL)
err := execCmd(t, "--no-release", "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("--no-release: unexpected error: %v", err)
}
if !notified {
t.Error("expected Slack webhook to be called even with --no-release")
}
}
func TestRunNoPushSkipsNotify(t *testing.T) {
var notified bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
notified = true
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
_, dir := setupRepo(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
t.Setenv("SLACK_WEBHOOK_URL", srv.URL)
err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("--no-push: unexpected error: %v", err)
}
if notified {
t.Error("--no-push must not send notifications — nothing was published")
}
}
// ── main() tests via exitFn ───────────────────────────────────────────────────
func TestMainSuccess(t *testing.T) {
+6
View File
@@ -3,6 +3,12 @@ title: Changelog
weight: 50
---
## v1.8.0 — 2026-07-12
### Added
- **Release notifications** — best-effort notification on release to Slack, Microsoft Teams, Google Chat, Telegram, and/or a generic JSON webhook via the new `notify` config section; every target is independently opt-in (config file or env var), and a failed notification never fails the release
## v1.7.0 — 2026-07-12
### Added
+34
View File
@@ -69,6 +69,14 @@ gitlab:
github:
token: "" # prefer env GITHUB_TOKEN
repo: "" # "owner/repo" format
notify: # opt-in — every field independent; failures are warnings, not errors
# slack_webhook_url: "" # or env SLACK_WEBHOOK_URL
# teams_webhook_url: "" # or env TEAMS_WEBHOOK_URL
# google_chat_webhook_url: "" # or env GOOGLE_CHAT_WEBHOOK_URL
# telegram_bot_token: "" # or env TELEGRAM_BOT_TOKEN (both required)
# telegram_chat_id: "" # or env TELEGRAM_CHAT_ID
# webhook_url: "" # generic {"version","notes"} JSON POST; or env RELEASER_WEBHOOK_URL
```
{{< hint info >}}
@@ -84,6 +92,11 @@ When both `github.*` and `gitlab.*` are configured, GitHub takes precedence.
| `CI_PROJECT_ID` | GitLab project identifier (numeric) |
| `CI_PROJECT_PATH` | GitLab project identifier (fallback) |
| `GITHUB_TOKEN` | GitHub API auth |
| `SLACK_WEBHOOK_URL` | Slack release notification |
| `TEAMS_WEBHOOK_URL` | Microsoft Teams release notification |
| `GOOGLE_CHAT_WEBHOOK_URL` | Google Chat release notification |
| `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | Telegram release notification (both required) |
| `RELEASER_WEBHOOK_URL` | Generic webhook release notification |
## Config sources
@@ -177,3 +190,24 @@ gradle:
```
The `--gradle <path>` CLI flag sets a single build file path and clears `build_files`.
## Notifications
The `notify` section is opt-in — every target is independent, and any combination may be configured at once. A notification failure is logged as a warning and never fails the release; notifications fire once the tag has been pushed, whether or not a GitLab/GitHub release is also created (`--no-release` still notifies; `--no-push` and `--no-commit` do not, since nothing was published).
```yaml
notify:
slack_webhook_url: "https://hooks.slack.com/services/..."
teams_webhook_url: "https://outlook.office.com/webhook/..."
google_chat_webhook_url: "https://chat.googleapis.com/v1/spaces/.../messages?key=..."
telegram_bot_token: "123456:ABC-..."
telegram_chat_id: "-1001234567890"
webhook_url: "https://example.com/hooks/releaser"
```
- **Slack** / **Google Chat** — a single `{"text": "..."}` incoming-webhook payload.
- **Microsoft Teams** — a legacy O365 connector `MessageCard` payload (the format Teams incoming webhook URLs accept).
- **Telegram** — `telegram_bot_token` and `telegram_chat_id` are both required; posts to the Bot API `sendMessage` endpoint as plain text (no `parse_mode`, since release notes come from free-form commit messages and aren't guaranteed to be valid Telegram Markdown).
- **Generic webhook** — POSTs `{"version": "<tag>", "notes": "<release notes>"}` for any downstream consumer (n8n, Zapier, a custom receiver).
Each field also has an environment variable fallback — see [Environment variables](#environment-variables) above.
+4
View File
@@ -20,3 +20,7 @@ pygmentsCodeFences = true
geekdocMenuBundle = true
geekdocBreadcrumb = false
geekdocToC = true
[params.geekdocContentLicense]
name = "Apache License 2.0"
link = "https://git.k3nny.fr/k3nny/releaser/src/branch/main/LICENSE"
+84
View File
@@ -21,6 +21,7 @@ type Config struct {
Python PythonConfig `yaml:"python"`
GitLab GitLabConfig `yaml:"gitlab"`
GitHub GitHubConfig `yaml:"github"`
Notify NotifyConfig `yaml:"notify"`
}
type GitConfig struct {
@@ -120,6 +121,17 @@ type GitHubConfig struct {
Repo string `yaml:"repo"` // "owner/repo"
}
// NotifyConfig configures best-effort release notifications. Every field is
// opt-in — a target is only used when its required fields are non-empty.
type NotifyConfig struct {
SlackWebhookURL string `yaml:"slack_webhook_url"`
TeamsWebhookURL string `yaml:"teams_webhook_url"`
GoogleChatWebhookURL string `yaml:"google_chat_webhook_url"`
TelegramBotToken string `yaml:"telegram_bot_token"`
TelegramChatID string `yaml:"telegram_chat_id"`
WebhookURL string `yaml:"webhook_url"`
}
func defaults() Config {
return Config{
Git: GitConfig{
@@ -162,6 +174,12 @@ func defaultSources() Sources {
"gitlab.project": "default",
"github.token": "default",
"github.repo": "default",
"notify.slack_webhook_url": "default",
"notify.teams_webhook_url": "default",
"notify.google_chat_webhook_url": "default",
"notify.telegram_bot_token": "default",
"notify.telegram_chat_id": "default",
"notify.webhook_url": "default",
}
}
@@ -260,6 +278,24 @@ func LoadWithSources(dir string) (Config, Sources, error) {
if overlay.GitHub.Repo != "" {
src["github.repo"] = "config file"
}
if overlay.Notify.SlackWebhookURL != "" {
src["notify.slack_webhook_url"] = "config file"
}
if overlay.Notify.TeamsWebhookURL != "" {
src["notify.teams_webhook_url"] = "config file"
}
if overlay.Notify.GoogleChatWebhookURL != "" {
src["notify.google_chat_webhook_url"] = "config file"
}
if overlay.Notify.TelegramBotToken != "" {
src["notify.telegram_bot_token"] = "config file"
}
if overlay.Notify.TelegramChatID != "" {
src["notify.telegram_chat_id"] = "config file"
}
if overlay.Notify.WebhookURL != "" {
src["notify.webhook_url"] = "config file"
}
return cfg, src, nil
}
@@ -310,4 +346,52 @@ func (c *Config) ApplyEnvWithSources(src Sources) {
}
}
}
if c.Notify.SlackWebhookURL == "" {
if v := os.Getenv("SLACK_WEBHOOK_URL"); v != "" {
c.Notify.SlackWebhookURL = v
if src != nil {
src["notify.slack_webhook_url"] = "env: SLACK_WEBHOOK_URL"
}
}
}
if c.Notify.TeamsWebhookURL == "" {
if v := os.Getenv("TEAMS_WEBHOOK_URL"); v != "" {
c.Notify.TeamsWebhookURL = v
if src != nil {
src["notify.teams_webhook_url"] = "env: TEAMS_WEBHOOK_URL"
}
}
}
if c.Notify.GoogleChatWebhookURL == "" {
if v := os.Getenv("GOOGLE_CHAT_WEBHOOK_URL"); v != "" {
c.Notify.GoogleChatWebhookURL = v
if src != nil {
src["notify.google_chat_webhook_url"] = "env: GOOGLE_CHAT_WEBHOOK_URL"
}
}
}
if c.Notify.TelegramBotToken == "" {
if v := os.Getenv("TELEGRAM_BOT_TOKEN"); v != "" {
c.Notify.TelegramBotToken = v
if src != nil {
src["notify.telegram_bot_token"] = "env: TELEGRAM_BOT_TOKEN"
}
}
}
if c.Notify.TelegramChatID == "" {
if v := os.Getenv("TELEGRAM_CHAT_ID"); v != "" {
c.Notify.TelegramChatID = v
if src != nil {
src["notify.telegram_chat_id"] = "env: TELEGRAM_CHAT_ID"
}
}
}
if c.Notify.WebhookURL == "" {
if v := os.Getenv("RELEASER_WEBHOOK_URL"); v != "" {
c.Notify.WebhookURL = v
if src != nil {
src["notify.webhook_url"] = "env: RELEASER_WEBHOOK_URL"
}
}
}
}
+92
View File
@@ -381,6 +381,98 @@ func TestLoadPythonPathsSources(t *testing.T) {
}
}
func TestLoadNotifySources(t *testing.T) {
dir := t.TempDir()
content := `
notify:
slack_webhook_url: "https://hooks.slack.example/x"
teams_webhook_url: "https://outlook.office.example/y"
google_chat_webhook_url: "https://chat.googleapis.example/z"
telegram_bot_token: "bot-token"
telegram_chat_id: "chat-1"
webhook_url: "https://example.com/webhook"
`
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil {
t.Fatal(err)
}
cfg, src, err := LoadWithSources(dir)
if err != nil {
t.Fatal(err)
}
if cfg.Notify.SlackWebhookURL != "https://hooks.slack.example/x" {
t.Errorf("SlackWebhookURL = %q", cfg.Notify.SlackWebhookURL)
}
if cfg.Notify.TeamsWebhookURL != "https://outlook.office.example/y" {
t.Errorf("TeamsWebhookURL = %q", cfg.Notify.TeamsWebhookURL)
}
if cfg.Notify.GoogleChatWebhookURL != "https://chat.googleapis.example/z" {
t.Errorf("GoogleChatWebhookURL = %q", cfg.Notify.GoogleChatWebhookURL)
}
if cfg.Notify.TelegramBotToken != "bot-token" {
t.Errorf("TelegramBotToken = %q", cfg.Notify.TelegramBotToken)
}
if cfg.Notify.TelegramChatID != "chat-1" {
t.Errorf("TelegramChatID = %q", cfg.Notify.TelegramChatID)
}
if cfg.Notify.WebhookURL != "https://example.com/webhook" {
t.Errorf("WebhookURL = %q", cfg.Notify.WebhookURL)
}
wantConfigFile := []string{
"notify.slack_webhook_url", "notify.teams_webhook_url", "notify.google_chat_webhook_url",
"notify.telegram_bot_token", "notify.telegram_chat_id", "notify.webhook_url",
}
for _, key := range wantConfigFile {
if got := src[key]; got != "config file" {
t.Errorf("src[%q] = %q, want %q", key, got, "config file")
}
}
}
func TestApplyEnvWithSourcesNotify(t *testing.T) {
t.Setenv("SLACK_WEBHOOK_URL", "https://hooks.slack.example/env")
t.Setenv("TEAMS_WEBHOOK_URL", "https://outlook.office.example/env")
t.Setenv("GOOGLE_CHAT_WEBHOOK_URL", "https://chat.googleapis.example/env")
t.Setenv("TELEGRAM_BOT_TOKEN", "env-bot-token")
t.Setenv("TELEGRAM_CHAT_ID", "env-chat-id")
t.Setenv("RELEASER_WEBHOOK_URL", "https://example.com/env-webhook")
cfg := defaults()
src := defaultSources()
cfg.ApplyEnvWithSources(src)
cases := []struct {
got, want, srcKey, wantSrc string
}{
{cfg.Notify.SlackWebhookURL, "https://hooks.slack.example/env", "notify.slack_webhook_url", "env: SLACK_WEBHOOK_URL"},
{cfg.Notify.TeamsWebhookURL, "https://outlook.office.example/env", "notify.teams_webhook_url", "env: TEAMS_WEBHOOK_URL"},
{cfg.Notify.GoogleChatWebhookURL, "https://chat.googleapis.example/env", "notify.google_chat_webhook_url", "env: GOOGLE_CHAT_WEBHOOK_URL"},
{cfg.Notify.TelegramBotToken, "env-bot-token", "notify.telegram_bot_token", "env: TELEGRAM_BOT_TOKEN"},
{cfg.Notify.TelegramChatID, "env-chat-id", "notify.telegram_chat_id", "env: TELEGRAM_CHAT_ID"},
{cfg.Notify.WebhookURL, "https://example.com/env-webhook", "notify.webhook_url", "env: RELEASER_WEBHOOK_URL"},
}
for _, c := range cases {
if c.got != c.want {
t.Errorf("got %q, want %q", c.got, c.want)
}
if src[c.srcKey] != c.wantSrc {
t.Errorf("src[%q] = %q, want %q", c.srcKey, src[c.srcKey], c.wantSrc)
}
}
}
func TestApplyEnvNotifyDoesNotOverwrite(t *testing.T) {
t.Setenv("SLACK_WEBHOOK_URL", "https://hooks.slack.example/env")
cfg := defaults()
cfg.Notify.SlackWebhookURL = "https://hooks.slack.example/config"
cfg.ApplyEnv()
if cfg.Notify.SlackWebhookURL != "https://hooks.slack.example/config" {
t.Errorf("SlackWebhookURL overwritten: got %q", cfg.Notify.SlackWebhookURL)
}
}
func TestLoadPartialOverride(t *testing.T) {
dir := t.TempDir()
// Only override tag_prefix — commit_message should keep its default
+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")
}
}