Skip to main content

anodizer_core/config/
announce.rs

1use std::collections::HashMap;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use super::{StringOrBool, deserialize_string_or_bool_opt};
7
8// ---------------------------------------------------------------------------
9// AnnounceConfig
10// ---------------------------------------------------------------------------
11
12#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
13#[serde(default)]
14pub struct AnnounceConfig {
15    /// Template-conditional skip: if rendered to "true", skip the entire announce stage.
16    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
17    pub skip: Option<StringOrBool>,
18    /// Discord announcement configuration.
19    pub discord: Option<DiscordAnnounce>,
20    /// Discourse announcement configuration.
21    pub discourse: Option<DiscourseAnnounce>,
22    /// Slack announcement configuration.
23    pub slack: Option<SlackAnnounce>,
24    /// Generic webhook announcement configuration.
25    pub webhook: Option<WebhookConfig>,
26    /// Telegram announcement configuration.
27    pub telegram: Option<TelegramAnnounce>,
28    /// Microsoft Teams announcement configuration.
29    pub teams: Option<TeamsAnnounce>,
30    /// Mattermost announcement configuration.
31    pub mattermost: Option<MattermostAnnounce>,
32    /// Email announcement configuration. SCH-34 (WAVE 5.6) — accepts the
33    /// historical `smtp:` key as an alias because GR itself renamed
34    /// `smtp:` -> `email:` in v1.21+ and kept the alias for migration.
35    /// Mirroring GR's own alias keeps "use what GR uses today" consistent
36    /// without forcing a re-yaml of legacy GR configs.
37    #[serde(alias = "smtp")]
38    pub email: Option<EmailAnnounce>,
39    /// Reddit announcement configuration.
40    pub reddit: Option<RedditAnnounce>,
41    /// Twitter/X announcement configuration.
42    pub twitter: Option<TwitterAnnounce>,
43    /// Mastodon announcement configuration.
44    pub mastodon: Option<MastodonAnnounce>,
45    /// Bluesky announcement configuration.
46    pub bluesky: Option<BlueskyAnnounce>,
47    /// LinkedIn announcement configuration.
48    pub linkedin: Option<LinkedInAnnounce>,
49    /// OpenCollective announcement configuration.
50    pub opencollective: Option<OpenCollectiveAnnounce>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
54#[serde(default)]
55pub struct BlueskyAnnounce {
56    /// Enable Bluesky announcements (supports template expressions).
57    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
58    pub enabled: Option<StringOrBool>,
59    /// Bluesky handle/username (e.g. "user.bsky.social").
60    pub username: Option<String>,
61    /// Message template for the post. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
62    pub message_template: Option<String>,
63    /// Override the Bluesky PDS (Personal Data Server) URL. Defaults to
64    /// `https://bsky.social`. Set this to point at a self-hosted PDS or
65    /// alternative instance (e.g. `https://pds.example.com`).
66    pub pds_url: Option<String>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
70#[serde(default)]
71pub struct DiscourseAnnounce {
72    /// Enable Discourse announcements (supports template expressions).
73    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
74    pub enabled: Option<StringOrBool>,
75    /// Discourse forum URL (e.g. "https://forum.example.com").
76    pub server: Option<String>,
77    /// Category ID to post in (required, must be non-zero).
78    pub category_id: Option<u64>,
79    /// Username for the API request (default: "system").
80    pub username: Option<String>,
81    /// Title template for the forum topic. Default: "{{ .ProjectName }} {{ .Tag }} is out!"
82    pub title_template: Option<String>,
83    /// Message body template for the forum topic. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
84    pub message_template: Option<String>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
88#[serde(default)]
89pub struct LinkedInAnnounce {
90    /// Enable LinkedIn announcements. Requires LINKEDIN_ACCESS_TOKEN env var (supports template expressions).
91    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
92    pub enabled: Option<StringOrBool>,
93    /// Message template for the LinkedIn share post. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
94    pub message_template: Option<String>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
98#[serde(default)]
99pub struct OpenCollectiveAnnounce {
100    /// Enable OpenCollective announcements. Requires OPENCOLLECTIVE_TOKEN env var (supports template expressions).
101    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
102    pub enabled: Option<StringOrBool>,
103    /// Collective slug (e.g. "my-project").
104    pub slug: Option<String>,
105    /// Title template for the update. Default: "{{ .Tag }}"
106    pub title_template: Option<String>,
107    /// HTML message template for the update. Default includes <br/> and <a> tags with ReleaseURL.
108    pub message_template: Option<String>,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
112#[serde(default)]
113pub struct TwitterAnnounce {
114    /// Enable Twitter/X announcements. Requires TWITTER_CONSUMER_KEY, TWITTER_CONSUMER_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_TOKEN_SECRET env vars (supports template expressions).
115    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
116    pub enabled: Option<StringOrBool>,
117    /// Tweet message template. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
118    pub message_template: Option<String>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
122#[serde(default)]
123pub struct MastodonAnnounce {
124    /// Enable Mastodon announcements. Requires `MASTODON_ACCESS_TOKEN` env var (supports template expressions).
125    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
126    pub enabled: Option<StringOrBool>,
127    /// Mastodon instance URL (e.g. "https://mastodon.social").
128    pub server: Option<String>,
129    /// Toot message template. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
130    pub message_template: Option<String>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
134#[serde(default)]
135pub struct DiscordAnnounce {
136    /// Enable Discord announcements (supports template expressions).
137    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
138    pub enabled: Option<StringOrBool>,
139    /// Discord webhook URL. Use templates like `{{ Env.DISCORD_WEBHOOK_ID }}` to reference environment variables.
140    pub webhook_url: Option<String>,
141    /// Message template for the Discord embed. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
142    pub message_template: Option<String>,
143    /// Author name displayed in the embed.
144    pub author: Option<String>,
145    /// Embed color as a decimal integer string (default: "3888754", GoReleaser blue).
146    /// Parsed to u32 at runtime. Supports template expressions.
147    pub color: Option<String>,
148    /// Icon URL for the embed footer.
149    pub icon_url: Option<String>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
153#[serde(default)]
154pub struct WebhookConfig {
155    /// Enable generic webhook announcements (supports template expressions).
156    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
157    pub enabled: Option<StringOrBool>,
158    /// Webhook endpoint URL (supports template variables).
159    pub endpoint_url: Option<String>,
160    /// Custom HTTP headers to include in the request.
161    ///
162    /// Precedence — **anodizer diverges from GoReleaser here**:
163    /// - anodizer: a config-supplied `Authorization` header wins over the
164    ///   `BASIC_AUTH_HEADER_VALUE` / `BEARER_TOKEN_HEADER_VALUE` env var.
165    /// - GoReleaser (webhook.go:104-115): env-supplied `Authorization` is
166    ///   appended FIRST; most servers honour the first occurrence, so the
167    ///   env value effectively wins.
168    ///
169    /// Migrating configs that relied on env-overriding the config header
170    /// must either remove the config entry or be reconfigured. Use
171    /// templated config (`Authorization: "Bearer {{ .Env.MY_TOKEN }}"`) for
172    /// the cleanest migration.
173    pub headers: Option<HashMap<String, String>>,
174    /// Content-Type header value. Default: "application/json".
175    pub content_type: Option<String>,
176    /// Message body template. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
177    pub message_template: Option<String>,
178    /// When true, skip TLS certificate verification for the webhook endpoint.
179    pub skip_tls_verify: Option<bool>,
180    /// HTTP status codes to accept as success (default: [200, 201, 202, 204]).
181    #[serde(default)]
182    pub expected_status_codes: Vec<u16>,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
186#[serde(default)]
187pub struct TelegramAnnounce {
188    /// Enable Telegram announcements. Requires bot_token and chat_id (supports template expressions).
189    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
190    pub enabled: Option<StringOrBool>,
191    /// Telegram Bot API token. Get one from @BotFather.
192    pub bot_token: Option<String>,
193    /// Telegram chat ID to send the message to (supports template variables).
194    pub chat_id: Option<String>,
195    /// Message template. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
196    pub message_template: Option<String>,
197    /// Parse mode: "MarkdownV2" or "HTML" (defaults to "MarkdownV2").
198    pub parse_mode: Option<String>,
199    /// Message thread ID for sending to a specific topic in a forum group.
200    /// Supports template expressions; parsed to i64 at runtime.
201    pub message_thread_id: Option<String>,
202}
203
204/// Default Adaptive Card title for Teams announcements. Centralised so that a
205/// config-load round-trip (parse → serialise → re-parse) preserves the value
206/// instead of stripping it back to `None`.
207pub const TEAMS_DEFAULT_TITLE_TEMPLATE: &str = "{{ ProjectName }} {{ Tag }} is out!";
208
209fn default_teams_title_template() -> Option<String> {
210    Some(TEAMS_DEFAULT_TITLE_TEMPLATE.to_string())
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
214#[serde(default)]
215pub struct TeamsAnnounce {
216    /// Enable Microsoft Teams announcements (supports template expressions).
217    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
218    pub enabled: Option<StringOrBool>,
219    /// Teams incoming webhook URL.
220    pub webhook_url: Option<String>,
221    /// Message template for the Adaptive Card body. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
222    pub message_template: Option<String>,
223    /// Title template for the Adaptive Card header. Default: "{{ ProjectName }} {{ Tag }} is out!"
224    #[serde(default = "default_teams_title_template")]
225    pub title_template: Option<String>,
226    /// Theme color for the card (hex string, e.g. "0076D7").
227    pub color: Option<String>,
228    /// Icon URL displayed in the card header.
229    pub icon_url: Option<String>,
230}
231
232impl Default for TeamsAnnounce {
233    fn default() -> Self {
234        Self {
235            enabled: None,
236            webhook_url: None,
237            message_template: None,
238            title_template: default_teams_title_template(),
239            color: None,
240            icon_url: None,
241        }
242    }
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
246#[serde(default)]
247pub struct MattermostAnnounce {
248    /// Enable Mattermost announcements (supports template expressions).
249    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
250    pub enabled: Option<StringOrBool>,
251    /// Mattermost incoming webhook URL.
252    pub webhook_url: Option<String>,
253    /// Channel override (e.g. "town-square").
254    pub channel: Option<String>,
255    /// Username override for the bot post.
256    pub username: Option<String>,
257    /// Icon URL for the bot post.
258    pub icon_url: Option<String>,
259    /// Icon emoji for the bot post (e.g. ":rocket:").
260    pub icon_emoji: Option<String>,
261    /// Attachment color (hex string, e.g. "#36a64f").
262    pub color: Option<String>,
263    /// Message template for the Mattermost post. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
264    pub message_template: Option<String>,
265    /// Title template for the Mattermost attachment.
266    pub title_template: Option<String>,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
270#[serde(default)]
271pub struct EmailAnnounce {
272    /// Enable email announcements (supports template expressions).
273    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
274    pub enabled: Option<StringOrBool>,
275    /// SMTP server hostname. When set, uses SMTP transport.
276    /// When absent, falls back to sendmail/msmtp.
277    pub host: Option<String>,
278    /// SMTP server port (default: 587 for STARTTLS).
279    ///
280    /// Anodize-additive UX win (locked 2026-04-28): GoReleaser's
281    /// `internal/pipe/smtp/smtp.go` errors with `errNoPort` when `port` is
282    /// unset (zero value). Anodize defaults to 587 — the IETF submission
283    /// port — so the common case (corporate / SaaS SMTP relays exposing
284    /// STARTTLS on 587) works out of the box without a config knob. The
285    /// `auto` encryption mode then resolves to STARTTLS for 587, which is
286    /// the conventional pairing. Pinned by
287    /// `test_email_smtp_port_defaults_to_587`.
288    pub port: Option<u16>,
289    /// SMTP username (can also be set via SMTP_USERNAME env var).
290    pub username: Option<String>,
291    /// Sender email address.
292    pub from: Option<String>,
293    /// Recipient email addresses.
294    #[serde(default)]
295    pub to: Vec<String>,
296    /// Email subject template. Default: "{{ .ProjectName }} {{ .Tag }} is out!"
297    pub subject_template: Option<String>,
298    /// Email body template.
299    pub message_template: Option<String>,
300    /// Skip TLS certificate verification (default: false).
301    pub insecure_skip_verify: Option<bool>,
302    /// Transport encryption mode. `auto` (the default) picks SMTPS for port
303    /// 465, plain SMTP for port 25, and STARTTLS for everything else; `tls`
304    /// forces SMTPS, `starttls` forces STARTTLS, `none` forces plain SMTP.
305    pub encryption: Option<EmailEncryption>,
306}
307
308/// Email transport encryption mode.
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
310#[serde(rename_all = "lowercase")]
311pub enum EmailEncryption {
312    /// Pick based on port: 465 → SMTPS, 25 → none, otherwise STARTTLS.
313    #[default]
314    Auto,
315    /// Implicit TLS on connect (typically port 465).
316    Tls,
317    /// Plain SMTP that upgrades to TLS via STARTTLS (typically port 587).
318    Starttls,
319    /// Plain SMTP, no TLS. Only safe on trusted local relays (port 25).
320    None,
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
324#[serde(default)]
325pub struct RedditAnnounce {
326    /// Enable Reddit announcements. Requires REDDIT_SECRET and REDDIT_PASSWORD env vars (supports template expressions).
327    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
328    pub enabled: Option<StringOrBool>,
329    /// Reddit application (OAuth client) ID.
330    pub application_id: Option<String>,
331    /// Reddit username for posting.
332    pub username: Option<String>,
333    /// Subreddit to post to (without /r/ prefix).
334    pub sub: Option<String>,
335    /// Title template for the Reddit link post. Default: "{{ .ProjectName }} {{ .Tag }} is out!"
336    pub title_template: Option<String>,
337    /// URL template for the Reddit link post. Default: "{{ .ReleaseURL }}"
338    pub url_template: Option<String>,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
342#[serde(default)]
343pub struct SlackAnnounce {
344    /// Enable Slack announcements (supports template expressions).
345    #[serde(deserialize_with = "deserialize_string_or_bool_opt", default)]
346    pub enabled: Option<StringOrBool>,
347    /// Slack incoming webhook URL. Use template `{{ Env.SLACK_WEBHOOK }}` to reference an environment variable.
348    pub webhook_url: Option<String>,
349    /// Message template for the Slack post. Default: "{{ .ProjectName }} {{ .Tag }} is out! Check it out at {{ .ReleaseURL }}"
350    pub message_template: Option<String>,
351    /// Override the webhook's default channel (e.g. "#releases").
352    pub channel: Option<String>,
353    /// Override the webhook's default username (e.g. "release-bot").
354    pub username: Option<String>,
355    /// Override the webhook's default icon with an emoji (e.g. ":rocket:").
356    pub icon_emoji: Option<String>,
357    /// Override the webhook's default icon with an image URL.
358    pub icon_url: Option<String>,
359    /// Slack Block Kit blocks (typed for schema validation).
360    pub blocks: Option<Vec<SlackBlock>>,
361    /// Slack legacy attachments (typed for schema validation).
362    pub attachments: Option<Vec<SlackAttachment>>,
363}
364
365/// A Slack Block Kit block element.
366/// Common fields are typed; additional block-type-specific fields are captured via flatten.
367#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
368pub struct SlackBlock {
369    /// Block type (e.g., "header", "section", "divider", "actions", "context", "image").
370    #[serde(rename = "type")]
371    pub block_type: String,
372    /// Text object for the block (used by header, section, context types).
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub text: Option<SlackTextObject>,
375    /// Block ID for interactive payloads.
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub block_id: Option<String>,
378    /// Additional block-specific fields (elements, accessory, fields, etc.).
379    #[serde(flatten)]
380    pub extra: HashMap<String, serde_json::Value>,
381}
382
383/// A Slack text composition object.
384#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
385pub struct SlackTextObject {
386    /// Text type: "plain_text" or "mrkdwn".
387    #[serde(rename = "type")]
388    pub text_type: String,
389    /// Text content (supports template variables).
390    pub text: String,
391    /// Whether to render emoji shortcodes (plain_text only).
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub emoji: Option<bool>,
394    /// Whether to render verbatim (mrkdwn only).
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub verbatim: Option<bool>,
397}
398
399/// A Slack legacy attachment.
400/// Common fields are typed; additional fields are captured via flatten.
401#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
402pub struct SlackAttachment {
403    /// Attachment sidebar color (hex string, e.g., "#36a64f" for green).
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub color: Option<String>,
406    /// Main body text of the attachment (supports template variables).
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    pub text: Option<String>,
409    /// Bold title text at the top of the attachment.
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub title: Option<String>,
412    /// Plain-text summary shown in notifications that cannot render attachments.
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    pub fallback: Option<String>,
415    /// Text shown above the attachment block.
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    pub pretext: Option<String>,
418    /// Small text shown at the bottom of the attachment.
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub footer: Option<String>,
421    /// Additional attachment-specific fields.
422    #[serde(flatten)]
423    pub extra: HashMap<String, serde_json::Value>,
424}