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