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