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