diff --git a/CHANGELOG.md b/CHANGELOG.md
index 12f900a..aec385b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/CLAUDE.md b/CLAUDE.md
index bc9b086..3326be1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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.
diff --git a/README.md b/README.md
index 94924ba..4cb2d06 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-
+
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)
diff --git a/ROADMAP.md b/ROADMAP.md
index 9c0ee96..0f1d72d 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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)
diff --git a/cmd/main.go b/cmd/main.go
index f2bbe60..b11e96b 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -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
+ } else {
+ if err := publisher.CreateRelease(context.Background(), nextTag, releaseNotes); err != nil {
+ return fmt.Errorf("create release: %w", err)
+ }
+ logDone("release created: %s", nextTag)
}
- releaseNotes := notes.Generate(nextTag, messages)
- 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)
+ }
+}
diff --git a/cmd/main_test.go b/cmd/main_test.go
index fa36bf2..f1c1de0 100644
--- a/cmd/main_test.go
+++ b/cmd/main_test.go
@@ -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) {
diff --git a/docs/content/changelog.md b/docs/content/changelog.md
index 62bad22..02946fb 100644
--- a/docs/content/changelog.md
+++ b/docs/content/changelog.md
@@ -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
diff --git a/docs/content/configuration.md b/docs/content/configuration.md
index 6b2e2bc..b9b9d25 100644
--- a/docs/content/configuration.md
+++ b/docs/content/configuration.md
@@ -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 ` 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": "", "notes": ""}` for any downstream consumer (n8n, Zapier, a custom receiver).
+
+Each field also has an environment variable fallback — see [Environment variables](#environment-variables) above.
diff --git a/docs/hugo.toml b/docs/hugo.toml
index b818ec3..1ac1fd5 100644
--- a/docs/hugo.toml
+++ b/docs/hugo.toml
@@ -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"
diff --git a/internal/config/config.go b/internal/config/config.go
index a0631e0..8877a6d 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -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"
+ }
+ }
+ }
}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index ef85871..c3e248b 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -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
diff --git a/internal/notify/googlechat.go b/internal/notify/googlechat.go
new file mode 100644
index 0000000..0d91576
--- /dev/null
+++ b/internal/notify/googlechat.go
@@ -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))
+}
diff --git a/internal/notify/googlechat_test.go b/internal/notify/googlechat_test.go
new file mode 100644
index 0000000..3a5b114
--- /dev/null
+++ b/internal/notify/googlechat_test.go
@@ -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")
+ }
+}
diff --git a/internal/notify/notify.go b/internal/notify/notify.go
new file mode 100644
index 0000000..078b116
--- /dev/null
+++ b/internal/notify/notify.go
@@ -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
+}
diff --git a/internal/notify/notify_fuzz_test.go b/internal/notify/notify_fuzz_test.go
new file mode 100644
index 0000000..eef8cb9
--- /dev/null
+++ b/internal/notify/notify_fuzz_test.go
@@ -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` & \"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))
+ })
+}
diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go
new file mode 100644
index 0000000..05cea6b
--- /dev/null
+++ b/internal/notify/notify_test.go
@@ -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)
+ }
+}
diff --git a/internal/notify/slack.go b/internal/notify/slack.go
new file mode 100644
index 0000000..4f5c024
--- /dev/null
+++ b/internal/notify/slack.go
@@ -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))
+}
diff --git a/internal/notify/slack_test.go b/internal/notify/slack_test.go
new file mode 100644
index 0000000..7368013
--- /dev/null
+++ b/internal/notify/slack_test.go
@@ -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")
+ }
+}
diff --git a/internal/notify/teams.go b/internal/notify/teams.go
new file mode 100644
index 0000000..ba36b29
--- /dev/null
+++ b/internal/notify/teams.go
@@ -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))
+}
diff --git a/internal/notify/teams_test.go b/internal/notify/teams_test.go
new file mode 100644
index 0000000..9417833
--- /dev/null
+++ b/internal/notify/teams_test.go
@@ -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")
+ }
+}
diff --git a/internal/notify/telegram.go b/internal/notify/telegram.go
new file mode 100644
index 0000000..bfa1aae
--- /dev/null
+++ b/internal/notify/telegram.go
@@ -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))
+}
diff --git a/internal/notify/telegram_test.go b/internal/notify/telegram_test.go
new file mode 100644
index 0000000..bc7e7f1
--- /dev/null
+++ b/internal/notify/telegram_test.go
@@ -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")
+ }
+}
diff --git a/internal/notify/webhook.go b/internal/notify/webhook.go
new file mode 100644
index 0000000..682237e
--- /dev/null
+++ b/internal/notify/webhook.go
@@ -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))
+}
diff --git a/internal/notify/webhook_test.go b/internal/notify/webhook_test.go
new file mode 100644
index 0000000..bddf38e
--- /dev/null
+++ b/internal/notify/webhook_test.go
@@ -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")
+ }
+}