anodizer_core/context.rs
1use crate::artifact::ArtifactRegistry;
2use crate::config::Config;
3use crate::env_source::{EnvSource, ProcessEnvSource};
4use crate::git::GitInfo;
5use crate::log::{StageLogger, Verbosity};
6use crate::partial::PartialTarget;
7use crate::publish_report::PublishReport;
8use crate::publisher_kind::PublisherKind;
9use crate::scm::ScmTokenType;
10use crate::template::TemplateVars;
11use crate::verify_release_summary::VerifyReleaseSummary;
12use anyhow::Context as _;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::path::PathBuf;
16use std::sync::{Arc, LazyLock};
17use strum::IntoEnumIterator;
18
19/// Rollback policy after the publish stage. `BestEffort` is the default when
20/// pre-flight ran clean; `None` is the implicit default otherwise (callers
21/// should warn that rollback is disabled). The CLI flag `--rollback=<v>`
22/// sets `ContextOptions::rollback_mode` to `Some(v)` to override the
23/// default-resolution at the dispatch site.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
25#[serde(rename_all = "kebab-case")]
26pub enum RollbackMode {
27 /// Do not attempt rollback. Useful when the operator wants to inspect
28 /// half-published state before deciding.
29 None,
30 /// Run best-effort rollback for every reversible publisher whose
31 /// evidence is present in the report. Most irreversible publishers
32 /// (chocolatey moderation, winget PRs, AUR) are never rolled back —
33 /// the Submitter gate is their only protection. The exception is
34 /// cargo: a partial multi-crate publish that left live crates records
35 /// them and gets those crates yanked even on a failed run.
36 #[default]
37 BestEffort,
38}
39
40/// Non-publisher `--skip` tokens for the `release` command: the pipeline
41/// stage / phase names that are NOT publishers.
42///
43/// The publisher tokens are NOT listed here — they are derived from
44/// [`PublisherKind`] and unioned in by [`VALID_RELEASE_SKIPS`], so the
45/// `--skip` publisher vocabulary cannot drift from the registry. Keep ONLY
46/// non-publisher stage tokens here.
47///
48/// Two pairs look like publishers but are stages and belong here:
49/// `snapcraft` is the snap *build* stage (its publisher sibling is
50/// `snapcraft-publish`), and `release` is the GitHub/GitLab/Gitea release
51/// *stage* (its publisher sibling is `github-release`).
52const NON_PUBLISHER_RELEASE_SKIPS: &[&str] = &[
53 "publish",
54 "sign",
55 "validate",
56 "sbom",
57 "attest",
58 "snapcraft",
59 "nfpm",
60 "makeself",
61 "appimage",
62 "flatpak",
63 "srpm",
64 "before",
65 "before-publish",
66 "notarize",
67 "archive",
68 "source",
69 "build",
70 "changelog",
71 "release",
72 "checksum",
73 "upx",
74 "templatefiles",
75 "dmg",
76 "msi",
77 "nsis",
78 "pkg",
79 "appbundle",
80 "verify-release",
81];
82
83/// Valid `--skip` values for the `release` command: every pipeline
84/// stage/phase token ([`NON_PUBLISHER_RELEASE_SKIPS`]) PLUS every publisher
85/// token (derived from [`PublisherKind`]).
86///
87/// Skip tokens are stage names plus publisher names. Every publisher's skip
88/// token is its canonical [`crate::Publisher::name`] / [`PublisherKind::token`]
89/// (the same token `--publishers` keys on and the same one GoReleaser's
90/// `--skip` uses), so homebrew is `homebrew` and chocolatey is `chocolatey` —
91/// there are no short aliases (`brew`/`choco`). This keeps one denylist
92/// vocabulary across the `--skip` and `--publishers` selectors and matches
93/// GoReleaser's `--skip` keys, so a single name works on both tools.
94///
95/// Deriving the publisher half from [`PublisherKind::iter`] is what makes the
96/// vocabulary drift-proof: a newly added publisher is automatically a valid
97/// `--skip` token. (This closed a real gap — nine publisher tokens
98/// — `npm`, `gemfury`, `cloudsmith`, `artifactory`, `uploads`, `dockerhub`,
99/// `mcp`, `schemastore`, `upstream-aur` — had silently fallen out of the old
100/// hand-maintained literal.)
101pub static VALID_RELEASE_SKIPS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
102 NON_PUBLISHER_RELEASE_SKIPS
103 .iter()
104 .copied()
105 .chain(PublisherKind::iter().map(PublisherKind::token))
106 .collect()
107});
108
109/// One entry in anodizer's canonical `--skip` / `--publishers` vocabulary,
110/// emitted by `anodizer vocabulary` for machine consumers (the GitHub Action
111/// derives its skip / publisher token sets from this instead of re-deriving
112/// them in shell).
113///
114/// `is_publisher` marks the publisher tokens (the half of the vocabulary that
115/// `--publishers` also accepts); `is_publish_stage` mirrors
116/// [`PublisherKind::is_publish_stage`] for those, and is always `false` for
117/// the non-publisher pipeline-stage tokens.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
119pub struct ReleaseToken {
120 /// The canonical lowercase token, exactly as `--skip` / `--publishers`
121 /// key on it (e.g. `homebrew`, never `homebrew-cask`; `uploads`, never
122 /// `upload`).
123 pub token: &'static str,
124 /// `true` for the publisher half of the vocabulary — the tokens
125 /// `--publishers` also accepts. `false` for non-publisher stage tokens.
126 pub is_publisher: bool,
127 /// `true` when this is a publisher that fires its publish from a pipeline
128 /// stage rather than the trait-dispatch chokepoint (see
129 /// [`PublisherKind::is_publish_stage`]). Always `false` for non-publisher
130 /// stage tokens.
131 pub is_publish_stage: bool,
132}
133
134/// The full canonical `--skip` / `--publishers` vocabulary as structured
135/// entries, derived entirely from [`NON_PUBLISHER_RELEASE_SKIPS`] and
136/// [`PublisherKind::iter`] — no hand-maintained list. Adding a publisher
137/// variant or a non-publisher stage token updates this automatically.
138///
139/// The set of [`ReleaseToken::token`] values equals [`VALID_RELEASE_SKIPS`]
140/// exactly (enforced by a by-construction test), so anodizer and its
141/// consumers can never disagree on the legal token set.
142pub fn release_skip_vocabulary() -> Vec<ReleaseToken> {
143 NON_PUBLISHER_RELEASE_SKIPS
144 .iter()
145 .map(|&token| ReleaseToken {
146 token,
147 is_publisher: false,
148 is_publish_stage: false,
149 })
150 .chain(PublisherKind::iter().map(|k| ReleaseToken {
151 token: k.token(),
152 is_publisher: true,
153 is_publish_stage: k.is_publish_stage(),
154 }))
155 .collect()
156}
157
158/// Valid --skip values for the `build` command.
159pub const VALID_BUILD_SKIPS: &[&str] = &["pre-hooks", "post-hooks", "validate", "before"];
160
161/// Validate that all skip values are in the allowed set.
162///
163/// Returns `Ok(())` if all values are valid, or `Err` with a descriptive
164/// message listing the invalid value(s) and the full set of valid options.
165pub fn validate_skip_values(skip: &[String], valid: &[&str]) -> Result<(), String> {
166 let invalid: Vec<&str> = dedup_preserving_order(
167 skip.iter()
168 .map(|s| s.as_str())
169 .filter(|s| !valid.contains(s)),
170 );
171 if invalid.is_empty() {
172 Ok(())
173 } else {
174 // The combined skip vocabulary is `VALID_RELEASE_SKIPS ++ publisher
175 // names`, which overlap (e.g. `homebrew`, `cargo` appear in both), so a
176 // raw join prints each shared token twice. De-dup the hint — a consumer
177 // (or the action's skip-token generator) reading "Valid options" should
178 // see one clean vocabulary, not a confusing list with repeats.
179 Err(format!(
180 "invalid --skip value(s): {}. Valid options: {}",
181 invalid.join(", "),
182 dedup_preserving_order(valid.iter().copied()).join(", "),
183 ))
184 }
185}
186
187/// Collect an iterator of string slices, dropping later duplicates while keeping
188/// first-seen order — used so the `--skip` error hint lists each valid token
189/// once even though its source set unions overlapping vocabularies.
190fn dedup_preserving_order<'a>(items: impl Iterator<Item = &'a str>) -> Vec<&'a str> {
191 let mut seen = std::collections::HashSet::new();
192 items.filter(|s| seen.insert(*s)).collect()
193}
194
195pub struct ContextOptions {
196 pub snapshot: bool,
197 pub nightly: bool,
198 pub dry_run: bool,
199 pub quiet: bool,
200 pub verbose: bool,
201 pub debug: bool,
202 pub skip_stages: Vec<String>,
203 /// `--publishers`: per-publisher allowlist. Empty means "no allowlist" —
204 /// every publisher runs (subject to `skip_stages`). Non-empty restricts
205 /// the publish stage to exactly the named publishers. Entries are
206 /// canonical publisher names (`Publisher::name()`, e.g. `npm`, `cargo`).
207 /// Orthogonal to `selected_crates` (which scopes crates, not publishers)
208 /// and to `skip_stages` (the unified denylist, which always wins — see
209 /// [`Context::publisher_deselected`]).
210 pub publisher_allowlist: Vec<String>,
211 pub selected_crates: Vec<String>,
212 pub token: Option<String>,
213 /// Maximum number of parallel build jobs (minimum 1).
214 pub parallelism: usize,
215 /// When set, build only for this single host target triple.
216 pub single_target: Option<String>,
217 /// Path to a custom release notes file (overrides changelog).
218 pub release_notes_path: Option<PathBuf>,
219 /// When true, abort immediately on first error during publishing.
220 pub fail_fast: bool,
221 /// Partial build target for split/merge mode. When set, the build stage
222 /// filters targets to only those matching this partial target.
223 pub partial_target: Option<PartialTarget>,
224 /// When true, running with `--merge` flag (merging artifacts from split builds).
225 pub merge: bool,
226 /// `--publish-only`: load artifacts from a preserved dist (written
227 /// by `anodize check determinism --preserve-dist=...`) and run
228 /// only the sign + publish pipeline. The CLI dispatcher uses this
229 /// flag in `setup_env` to defer the GitHub-token check to the
230 /// config-derived environment preflight (the github-release
231 /// publisher's token ladder plus the sign stage's `KeyEnv`
232 /// requirements), which validates token and sign-key material in
233 /// one collect-all pass and bails fail-closed on missing values.
234 /// Without this deferral, `setup_env`'s token check would fire FIRST
235 /// and pre-empt that richer, per-publisher preflight.
236 pub publish_only: bool,
237 /// `--preflight-secrets`: a check-only secrets gate. Like
238 /// [`Self::publish_only`], it defers `setup_env`'s GitHub-token hard
239 /// error to the config-derived environment preflight (run in
240 /// `SecretsOnly` scope), which validates the token ladder alongside
241 /// every other runner-agnostic credential and then exits with zero
242 /// mutations. Without this deferral, `setup_env` would bail on the
243 /// missing token before the secrets gate could report the full set.
244 pub preflight_secrets: bool,
245 /// Explicit project root directory. When set, stages use this instead of
246 /// discovering the repo root via `git rev-parse --show-toplevel`.
247 pub project_root: Option<PathBuf>,
248 /// Strict mode: configured features that would silently skip become errors.
249 pub strict: bool,
250 /// `--resume-release`: opt-in to continue into a release left over from
251 /// a prior failed attempt. Bypasses the leftover-assets pre-check that
252 /// bails when an existing release already has assets and
253 /// `replace_existing_artifacts` is false.
254 pub resume_release: bool,
255 /// `--replace-existing`: CLI override that forces
256 /// `release.replace_existing_artifacts: true` regardless of config.
257 /// The release stage ORs this with the config value.
258 pub replace_existing_artifacts: bool,
259 /// `--no-post-publish-poll`: skip post-publish polling for the
260 /// chocolatey moderation queue and the winget PR validation pipeline.
261 /// When `true`, the polling runner emits `PostPublishStatus::NotPolled`
262 /// (pending immediately) for every publisher rather than waiting on a
263 /// terminal state. Lets CI users with no patience for long-running
264 /// waits opt out without scattering `post_publish_poll.enabled: false`
265 /// across every publisher block.
266 pub skip_post_publish_poll: bool,
267 /// Whether the publisher dispatcher gates irreversible Submitter
268 /// publishers (chocolatey, winget, AUR-source, krew, snapcraft) on
269 /// the success of every required Assets/Manager publisher that ran
270 /// before them. `None` defaults to `Some(true)` (gate on). The CLI
271 /// flag `--no-gate-submitter` flips this to `Some(false)`. See
272 /// `stage-publish::dispatch::DispatchOptions::gate_submitter` for
273 /// the gating mechanics.
274 pub gate_submitter: Option<bool>,
275 /// `--rollback=<none|best-effort>`: post-publish rollback policy.
276 /// `None` means "resolve from preflight state at dispatch time"
277 /// (best-effort when preflight ran clean, none otherwise with a
278 /// warn). Consumed by the rollback-dispatch task.
279 pub rollback_mode: Option<RollbackMode>,
280 /// `--simulate-failure=<publisher>` (repeatable, hidden, env-gated
281 /// behind `ANODIZE_TEST_HARNESS=1`): names of publishers whose
282 /// `run()` should be skipped and a synthetic `Failed("simulated
283 /// failure: <name>")` recorded in the report instead. Lets the
284 /// failure-mode test harness exercise gate / rollback / report
285 /// paths deterministically without monkey-patching production
286 /// publisher code.
287 pub simulate_failure_publishers: Vec<String>,
288 /// `--rollback-only`: skip publish; re-attempt rollback from a
289 /// prior run report. Requires `from_run` to identify which prior
290 /// run's `report.json` to load. The actual replay logic lands in
291 /// a follow-up task; this field is plumbed so the flag is visible
292 /// in `--help` today.
293 pub rollback_only: bool,
294 /// `--allow-rerun`: force `PublishStage::run` to proceed even
295 /// when a prior `report.json` exists for the current `run_id`.
296 /// The default (false) refuses re-runs to prevent PR-based
297 /// publishers (homebrew / scoop / nix / krew / MCP) from
298 /// duplicating their pull requests against the same tag.
299 ///
300 /// Operators recovering from a partial failure should prefer
301 /// `--rollback-only --from-run=<id>` (which has its own
302 /// idempotency guard via `dist/run-<id>/rollback.json`). The
303 /// rerun flag is an escape hatch for advanced cases where the
304 /// operator has manually verified no duplicate-publish risk
305 /// exists.
306 ///
307 /// Audit ref: 2026-05-15 release-resilience-review finding I4.
308 pub allow_rerun: bool,
309 /// `--show-skipped`: surface the per-crate "no `<publisher>` config
310 /// block" skip lines at default verbosity. In workspace mode every
311 /// PR-based publisher (homebrew / nix / scoop / aur / winget / krew /
312 /// chocolatey) visits every selected crate and skips the ones whose
313 /// config lacks its block; at default verbosity those no-op skips are
314 /// routed to debug (invisible unless `--debug`) so they do not bury the
315 /// real output. Setting this flag forces them back to status — the
316 /// diagnostic escape hatch for "why didn't publisher X run for crate Y?".
317 pub show_skipped: bool,
318 /// `--from-run=<id>`: prior run id whose `report.json` to load
319 /// when running in `--rollback-only` mode. clap enforces the
320 /// `requires = "rollback_only"` relationship at parse time.
321 pub from_run: Option<String>,
322 /// `--allow-nondeterministic <name>=<reason>` (repeatable):
323 /// runtime non-determinism opt-outs for specific artifacts. The
324 /// determinism stage suppresses its non-determinism error for
325 /// any matching artifact name, recording the supplied reason in
326 /// the report. Mutually exclusive with `--strict` at the clap
327 /// layer.
328 pub runtime_nondeterministic_allowlist: Vec<(String, String)>,
329 /// `--summary-json=<path>`: when set, the per-publisher run
330 /// summary is written to this path. Consumed by the run-summary
331 /// task.
332 pub summary_json_path: Option<PathBuf>,
333 /// `--allow-ai-failure`: when true, a failure inside the
334 /// `changelog.ai` enhancement step (transport, non-2xx, parse) is
335 /// logged as a warning and the pre-AI release notes are kept
336 /// verbatim. Default `false` (fail-closed) follows the conventional
337 /// "any hook failure aborts" pattern: a silent fall-back to the
338 /// raw notes ships the wrong body without the operator noticing.
339 pub allow_ai_failure: bool,
340 /// `changelog --from <ref>`: explicit lower bound (range start) for
341 /// changelog commit collection. When set, the changelog stage uses this
342 /// ref as the previous tag instead of auto-discovering the latest matching
343 /// tag. A dedicated option (rather than the always-auto-populated
344 /// `PreviousTag` template var) so a full release run's per-crate
345 /// auto-discovery is never overridden — only an explicit `--from` is.
346 pub changelog_from: Option<String>,
347 /// `changelog ..` / `changelog ..<ref>`: an explicit empty lower bound,
348 /// meaning "from the beginning of history" with no auto-discovered
349 /// previous tag. When `true`, the changelog stage skips tag
350 /// auto-discovery entirely so the range covers all reachable commits up
351 /// to the upper bound — distinguishing the explicit empty-from form from
352 /// an omitted range (which still resolves to the last release tag).
353 pub changelog_full_history: bool,
354 /// `changelog <from>..<to>` / `changelog <tag>`: an explicit UPPER bound
355 /// (range end) for changelog commit collection. When set, the changelog
356 /// stage walks `<from>..<to>` instead of `<from>..HEAD`, so commits AFTER
357 /// `<to>` are excluded. A dedicated option (rather than the always-populated
358 /// `Tag` template var) so the pending / snapshot window — where `Tag`
359 /// resolves to the latest EXISTING tag yet the range must still run to
360 /// HEAD — is never silently bounded to that tag. `None` keeps the upper
361 /// bound at `HEAD` (the pending window since the last release).
362 pub changelog_to: Option<String>,
363 /// Marks the run as the standalone `changelog --format release-notes`
364 /// LOCAL preview, NOT the `release`/`tag` pipeline. The standalone command
365 /// is an inspection tool: it must render the pending window from local git
366 /// with no release-time preconditions, so this flag relaxes three guards
367 /// that are correct for a real release but wrong for a preview:
368 /// - the tag-must-point-at-HEAD + dirty-tree bails in
369 /// `resolve_git_context` (a preview must not require a checkout or a
370 /// clean tree),
371 /// - the snapshot-skip config gate in the changelog stage (a preview
372 /// must render without `changelog.snapshot: true`),
373 /// - the `use: github-native` branch (a preview renders from local git
374 /// instead of requiring a token / emitting empty bodies).
375 ///
376 /// ONLY the standalone changelog command sets this; the release/tag
377 /// pipelines leave it `false` so their guards stay fully intact.
378 pub changelog_preview: bool,
379 /// Marks the run as the standalone `anodizer notify` command — a
380 /// side-channel that sends a one-off message through the configured
381 /// announce integrations, NOT part of the `release` pipeline.
382 ///
383 /// notify is routinely invoked as an `on_error:` hook AFTER a release has
384 /// failed mid-flight, when the working tree is dirty (partial `dist/`,
385 /// in-flight writeback) and HEAD may not sit on the release tag. A
386 /// notification must never be blocked by repo state — losing the alert is
387 /// the worst outcome — so this flag relaxes the three release-time git
388 /// preconditions in `resolve_git_context` that are correct for a real
389 /// release but wrong for a notification: the no-tag bail (falls back to the
390 /// `v0.0.0` synthetic tag), the tag-must-point-at-HEAD bail, and the
391 /// dirty-tree bail. ONLY the notify command sets this; every release/tag
392 /// path leaves it `false` so their guards stay intact.
393 pub notify: bool,
394 /// `--allow-snapshot-publish`: downgrade the publish stage's non-release
395 /// version guard from a hard bail to a warning.
396 ///
397 /// By default the publish, blob, and announce stages REFUSE to ship a
398 /// non-release version (snapshot / dirty / `0.0.0`-sentinel — see
399 /// [`crate::version::guard_release_version`] /
400 /// [`crate::version::is_release_version`]) to an external, often
401 /// irreversible, channel. The canonical accident this prevents: a CI run
402 /// that resolved `0.0.0~SNAPSHOT-<sha>` and pushed it to a package
403 /// registry. This flag is the deliberate opt-in for the legitimate
404 /// "publish a snapshot to a private channel" case; it is the ONLY thing
405 /// required to opt in (the version is not re-stated). Default `false`
406 /// (fail-closed).
407 pub allow_snapshot_publish: bool,
408}
409
410impl Default for ContextOptions {
411 fn default() -> Self {
412 Self {
413 snapshot: false,
414 nightly: false,
415 dry_run: false,
416 quiet: false,
417 verbose: false,
418 debug: false,
419 skip_stages: Vec::new(),
420 publisher_allowlist: Vec::new(),
421 selected_crates: Vec::new(),
422 token: None,
423 parallelism: 4,
424 single_target: None,
425 release_notes_path: None,
426 fail_fast: false,
427 partial_target: None,
428 merge: false,
429 publish_only: false,
430 preflight_secrets: false,
431 project_root: None,
432 strict: false,
433 resume_release: false,
434 replace_existing_artifacts: false,
435 skip_post_publish_poll: false,
436 gate_submitter: None,
437 rollback_mode: None,
438 simulate_failure_publishers: Vec::new(),
439 rollback_only: false,
440 allow_rerun: false,
441 show_skipped: false,
442 from_run: None,
443 runtime_nondeterministic_allowlist: Vec::new(),
444 summary_json_path: None,
445 allow_ai_failure: false,
446 changelog_from: None,
447 changelog_full_history: false,
448 changelog_to: None,
449 changelog_preview: false,
450 notify: false,
451 allow_snapshot_publish: false,
452 }
453 }
454}
455
456/// Stage→stage handoff state produced by stages and consumed by later
457/// stages (as opposed to `config` / `options` which are pipeline inputs,
458/// or `artifacts` which has its own registry). The changelog stage
459/// writes here, the release stage reads here.
460#[derive(Debug, Default)]
461pub struct StageOutputs {
462 /// Set by the changelog stage when `use: github-native` is configured.
463 /// The release stage reads this to set `generate_release_notes(true)`
464 /// on the GitHub API.
465 pub github_native_changelog: bool,
466 /// Per-crate rendered changelog body, keyed by crate name.
467 pub changelogs: HashMap<String, String>,
468 /// Rendered `changelog.header` value, populated by the changelog stage.
469 /// The release stage uses it as a fallback when `release.header` is
470 /// unset so YAML-configured changelog headers reach the GitHub release
471 /// body (the release-header content-loading behaviour).
472 pub changelog_header: Option<String>,
473 /// Rendered `changelog.footer` value, populated by the changelog stage.
474 /// Same fallback semantics as `changelog_header`.
475 pub changelog_footer: Option<String>,
476 /// Per-publisher post-publish polling results, written by the publish
477 /// stage's chocolatey / winget polling fan-out and consumed by the
478 /// release-summary renderer. Stored as opaque JSON to keep core free
479 /// of stage-publish types (the `PostPublishResult` type lives in
480 /// `anodizer-stage-publish::post_publish::status` and serializes
481 /// stably). Empty when polling was disabled or no eligible
482 /// publishers ran.
483 pub post_publish_results: Vec<serde_json::Value>,
484}
485
486pub struct Context {
487 pub config: Config,
488 pub artifacts: ArtifactRegistry,
489 pub options: ContextOptions,
490 /// Stage→stage handoff outputs (changelog text, header/footer, etc.).
491 pub stage_outputs: StageOutputs,
492 template_vars: TemplateVars,
493 pub git_info: Option<GitInfo>,
494 /// The resolved SCM token type (GitHub, GitLab, or Gitea).
495 pub token_type: ScmTokenType,
496 /// Aggregated skips from per-sub-config loops (signs, docker_signs,
497 /// publishers, …). Drained by the pipeline runner at end-of-pipeline so
498 /// the summary shows what was intentionally skipped — mirroring
499 /// the skip-memento pattern. The inner `Arc<Mutex<…>>`
500 /// lets parallel stage workers contribute without extra plumbing.
501 pub skip_memento: crate::pipe_skip::SkipMemento,
502 /// Trait-based publisher dispatch report, set by `PublishStage::run`
503 /// when the per-publisher dispatcher finishes. `None` until the
504 /// publish stage executes (or when publishing is skipped entirely
505 /// via snapshot mode / `--skip=publish`). Downstream stages
506 /// (SnapcraftPublishStage, AnnounceStage, future Submitter-group
507 /// stages) consult this to apply the submitter-gate / announce-gate
508 /// rules — see `PublishReport::any_failed`.
509 pub publish_report: Option<PublishReport>,
510 /// Whether `PublishStage::run` entered its body this run. Set before
511 /// the pre-dispatch guards (rerun refusal, runtime allowlist), so a
512 /// guard abort leaves this `true` with `publish_report` still `None`
513 /// — the summary placeholder row uses the pair to distinguish
514 /// "publish skipped" from "publish aborted before dispatch".
515 pub publish_attempted: bool,
516 /// Verify-release verdict, set by `VerifyReleaseStage::run` immediately
517 /// before it returns (clean pass OR `bail!`). `None` until the gate runs
518 /// its checks — it stays `None` on the disabled / skipped / dry-run /
519 /// snapshot early-returns, where no published release exists to verify.
520 ///
521 /// Read by the run-summary builder so the end-of-pipeline Summary states
522 /// the verify-release outcome on a SEPARATE axis from the publisher rows:
523 /// the gate runs after the irreversible publish, so the publishes still
524 /// read `succeeded` while this slot records whether the published release
525 /// has unverified defects to investigate.
526 pub verify_release: Option<VerifyReleaseSummary>,
527 /// SOURCE_DATE_EPOCH seed + non-determinism allow-list state for the
528 /// run. `None` until a stage (typically `BuildStage`) seeds it from
529 /// `resolve_reproducible_epoch(commit_timestamp)`; downstream stages
530 /// (`stage-sbom`, `stage-archive`, `stage-sign`) read `sde` to derive
531 /// deterministic timestamps. Lazy-init by design: tests and snapshot
532 /// runs without a clean commit can still proceed.
533 pub determinism: Option<crate::DeterminismState>,
534 /// Per-publisher outcome override published by `Publisher::run` when
535 /// the artifact reached a non-`Succeeded` terminal state but `run`
536 /// still returned `Ok` (e.g. chocolatey moderation skip,
537 /// winget/krew/homebrew PR-already-exists skip). Dispatch consumes
538 /// this slot via `take_pending_outcome()` immediately after `run`
539 /// returns Ok so the per-publisher row in the summary table reads
540 /// `pending-moderation` / `pending-validation` instead of
541 /// `succeeded`. The slot is single-shot: any unread value is
542 /// cleared at the start of every `run` call.
543 pub pending_outcome: Option<crate::PublisherOutcome>,
544 /// Partial [`PublishEvidence`] published by `Publisher::run` BEFORE it
545 /// returned `Err`, so a publisher that did irreversible work for the
546 /// first N items and then failed on item N+1 can still hand the
547 /// rollback path the authoritative record of what actually went live.
548 ///
549 /// The cargo publisher is the motivating case: a multi-crate publish
550 /// that succeeds on crate A then fails on crate B must yank A. On the
551 /// `Ok` path `run` returns its evidence directly; on the `Err` path
552 /// dispatch consumes this slot via [`Context::take_pending_evidence`]
553 /// and records it on the failed publisher's report row so rollback has
554 /// something to act on. Single-shot — drained at the start of every
555 /// `run` and cleared on the `Ok` path.
556 pub pending_evidence: Option<crate::PublishEvidence>,
557 /// Distinct set of crate names the build stage actually built — i.e.
558 /// those that had at least one in-scope build (or `copy_from`) job after
559 /// target resolution. `None` until [`BuildStage`] runs (e.g. merge mode,
560 /// which pre-loads artifacts and never invokes the build stage).
561 ///
562 /// Read by the binary-artifact guard to distinguish "configured a
563 /// binary-requiring surface but legitimately had no in-scope target in
564 /// this shard" (skip) from "was built yet produced no binary" (a real
565 /// mis-scope to fail on). Populated via [`Context::set_built_crate_names`]
566 /// and read via [`Context::built_crate_names`].
567 built_crate_names: Option<std::collections::HashSet<String>>,
568 /// Injectable environment-variable source. Defaults to
569 /// [`ProcessEnvSource`] (reads `std::env::var`). Tests inject a
570 /// [`MapEnvSource`](crate::MapEnvSource) via
571 /// [`TestContextBuilder::env`](crate::test_helpers::TestContextBuilder::env)
572 /// so deterministic branches can be exercised without mutating the
573 /// process env. Read through [`Context::env_var`]; replace via
574 /// [`Context::set_env_source`].
575 env_source: Arc<dyn EnvSource>,
576 /// Optional in-memory log-capture handle. When `Some`, every logger
577 /// produced by [`Context::logger`] attaches it so the test can read
578 /// back aggregated counts of `status` / `warn` / etc. calls without
579 /// having to intercept stderr.
580 ///
581 /// Gated behind the `test-helpers` Cargo feature — production
582 /// binaries do not carry the field at all.
583 #[cfg(feature = "test-helpers")]
584 pub log_capture: Option<crate::log::LogCapture>,
585 /// Runtime-togglable strict-render flag, distinct from the user's global
586 /// `--strict` (`options.strict`). The pre-publish guard flips this on for
587 /// the duration of its in-memory render pass (via [`Context::set_render_strict`])
588 /// so EVERY publisher/announce template it renders propagates its error
589 /// instead of falling back to the raw string — turning a swallowed
590 /// broken-template warning into a release-blocking abort BEFORE any
591 /// irreversible publisher fires. Production publish leaves it `false`, so
592 /// dry-run / snapshot / nightly stay lenient (warn + raw fallback).
593 ///
594 /// A `Cell` (not a plain `bool`) because the render path holds only a
595 /// shared `&Context`: the guard sets it through its `&mut Context`, then
596 /// the deep render helpers toggle-read it through `&Context`.
597 /// [`Context::render_is_strict`] ORs this with `is_strict()`, so the user's
598 /// global `--strict` also makes every render strict everywhere.
599 render_strict: std::cell::Cell<bool>,
600 /// When true, announce message BODIES are treated as already-final text and
601 /// are NOT run through Tera at send time. Set by `anodizer notify` so an
602 /// operator-supplied (possibly untrusted) message — e.g. an on_error error
603 /// string — cannot expand an `Env`-reference into a secret when the
604 /// provider sends it. Only message bodies are affected; titles and other
605 /// templated fields still render normally.
606 pub literal_message: bool,
607 /// When true (the default), outbound announce message BODIES have
608 /// known-secret env values masked before send (same policy as log
609 /// redaction). `anodizer notify --allow-secrets` sets this false to send a
610 /// secret deliberately over a trusted channel. Only the outbound body is
611 /// affected; anodizer's own logs are redacted unconditionally regardless of
612 /// this flag.
613 pub redact_body: bool,
614 /// Memoized `std::env::vars()` snapshot — the immutable half of the
615 /// redaction env. `env_for_redact` is called once per provider per
616 /// dispatch (and per `StageLogger`), and the process env never changes
617 /// for the lifetime of a run, so collecting it once and merging only the
618 /// cheap, dynamic template-var portion fresh on each call avoids
619 /// re-walking the whole process environment every time. Lazily filled on
620 /// first use via [`std::cell::OnceCell`] (Context is already `!Sync` via
621 /// `render_strict`, so the single-threaded cell weakens no auto-trait).
622 process_env_cache: std::cell::OnceCell<std::collections::HashMap<String, String>>,
623}
624
625impl Context {
626 pub fn new(config: Config, options: ContextOptions) -> Self {
627 let mut vars = TemplateVars::new();
628 vars.set("ProjectName", &config.project_name);
629 Self {
630 config,
631 artifacts: ArtifactRegistry::new(),
632 options,
633 stage_outputs: StageOutputs::default(),
634 template_vars: vars,
635 git_info: None,
636 token_type: ScmTokenType::GitHub,
637 skip_memento: crate::pipe_skip::SkipMemento::new(),
638 publish_report: None,
639 publish_attempted: false,
640 verify_release: None,
641 determinism: None,
642 pending_outcome: None,
643 pending_evidence: None,
644 built_crate_names: None,
645 env_source: Arc::new(ProcessEnvSource),
646 #[cfg(feature = "test-helpers")]
647 log_capture: None,
648 render_strict: std::cell::Cell::new(false),
649 literal_message: false,
650 redact_body: true,
651 process_env_cache: std::cell::OnceCell::new(),
652 }
653 }
654
655 /// Redact known-secret env values from outbound announce text, using the
656 /// same combined env (template engine env + process env) and the same
657 /// policy as log redaction. Always redacts; gating on `redact_body` is the
658 /// caller's responsibility (see `render_message_with_default`).
659 pub fn redact(&self, s: &str) -> String {
660 crate::redact::with_env(s, &self.env_for_redact())
661 }
662
663 /// Read an environment variable through the injected source.
664 ///
665 /// Production reads `std::env::var(name).ok()`. Tests inject a
666 /// [`MapEnvSource`](crate::MapEnvSource) via
667 /// [`TestContextBuilder::env`](crate::test_helpers::TestContextBuilder::env)
668 /// so deterministic branches can be exercised without mutating the
669 /// process env.
670 pub fn env_var(&self, name: &str) -> Option<String> {
671 self.env_source.var(name)
672 }
673
674 /// Replace the injected environment-variable source.
675 ///
676 /// Production migration code uses this when wrapping an
677 /// already-constructed context; tests reach this indirectly through
678 /// [`TestContextBuilder::env`](crate::test_helpers::TestContextBuilder::env).
679 pub fn set_env_source<S: EnvSource + 'static>(&mut self, src: S) {
680 self.env_source = Arc::new(src);
681 }
682
683 /// Borrow the injected environment-variable source as a trait
684 /// object so callers can pass it into helpers that take
685 /// `&dyn EnvSource` / `&E: EnvSource + ?Sized` without re-binding
686 /// each var through [`Context::env_var`].
687 pub fn env_source(&self) -> &dyn EnvSource {
688 self.env_source.as_ref()
689 }
690
691 /// Clone the injected environment-variable source as an `Arc` so
692 /// callers can move it into a `tokio::spawn` future or any other
693 /// `'static` closure. Production-default value is
694 /// [`ProcessEnvSource`]; tests may replace it via
695 /// [`Context::set_env_source`].
696 pub fn env_source_arc(&self) -> Arc<dyn EnvSource> {
697 Arc::clone(&self.env_source)
698 }
699
700 /// Attach an in-memory log-capture sink so every logger derived from
701 /// this context via [`Context::logger`] records to it. Intended for
702 /// tests; production callers leave this `None`.
703 ///
704 /// Gated behind the `test-helpers` Cargo feature.
705 #[cfg(feature = "test-helpers")]
706 pub fn with_log_capture(&mut self, capture: crate::log::LogCapture) {
707 self.log_capture = Some(capture);
708 }
709
710 /// Publisher-facing override: when `Publisher::run` returns `Ok`
711 /// but the terminal outcome is something other than `Succeeded`
712 /// (chocolatey moderation skip, winget/krew/homebrew
713 /// PR-already-exists skip, …) call this before returning so
714 /// dispatch records the correct `PublisherOutcome` on the report.
715 /// Without this, dispatch defaults to `Succeeded` on any Ok and
716 /// the summary table silently misreports the skip as success.
717 pub fn record_publisher_outcome(&mut self, outcome: crate::PublisherOutcome) {
718 self.pending_outcome = Some(outcome);
719 }
720
721 /// Dispatch-side consumer: take the pending outcome override (if
722 /// any) recorded by the publisher's `run`. Single-shot — the slot
723 /// is empty after this call.
724 pub fn take_pending_outcome(&mut self) -> Option<crate::PublisherOutcome> {
725 self.pending_outcome.take()
726 }
727
728 /// Publisher-side recorder: stash the partial evidence accumulated
729 /// before a failing `run` returns `Err`, so dispatch can attach it to
730 /// the failed report row and rollback has the authoritative record of
731 /// what went live. See [`Context::pending_evidence`].
732 pub fn record_pending_evidence(&mut self, evidence: crate::PublishEvidence) {
733 self.pending_evidence = Some(evidence);
734 }
735
736 /// Dispatch-side consumer: take the partial evidence (if any) a
737 /// publisher recorded before failing. Single-shot — empty after this
738 /// call.
739 pub fn take_pending_evidence(&mut self) -> Option<crate::PublishEvidence> {
740 self.pending_evidence.take()
741 }
742
743 /// Borrow the publisher dispatch report set by `PublishStage::run`,
744 /// or `None` if the publish stage hasn't run yet (or was skipped).
745 pub fn publish_report(&self) -> Option<&PublishReport> {
746 self.publish_report.as_ref()
747 }
748
749 /// Whether the publish stage entered its body this run (even if it
750 /// aborted before dispatching any publisher).
751 pub fn publish_attempted(&self) -> bool {
752 self.publish_attempted
753 }
754
755 /// Record that the publish stage entered its body. Called by
756 /// `PublishStage::run` ahead of its pre-dispatch guards so guard
757 /// aborts are distinguishable from a skipped stage.
758 pub fn set_publish_attempted(&mut self) {
759 self.publish_attempted = true;
760 }
761
762 /// Store the publisher dispatch report. Overwrites any prior value.
763 ///
764 /// Written by the publish stage during a normal release run; rehydrated by
765 /// `--announce-only` from the on-disk `<dist>/run-<id>/report.json` so the
766 /// announce stage sees an equivalent context without re-publishing.
767 pub fn set_publish_report(&mut self, r: PublishReport) {
768 self.publish_report = Some(r);
769 }
770
771 /// Borrow the set of crate names the build stage actually built, or
772 /// `None` if the build stage has not run in this pipeline (merge mode).
773 pub fn built_crate_names(&self) -> Option<&std::collections::HashSet<String>> {
774 self.built_crate_names.as_ref()
775 }
776
777 /// Record the distinct crate names that received at least one in-scope
778 /// build job. Called once by the build stage after job planning.
779 pub fn set_built_crate_names(&mut self, names: std::collections::HashSet<String>) {
780 self.built_crate_names = Some(names);
781 }
782
783 /// Record an intentional skip from a per-sub-config loop
784 /// (`signs`, `docker_signs`, `publishers`, …). `stage` identifies the
785 /// owning stage, `label` identifies the sub-config (id / name / index),
786 /// `reason` is short user-facing text. Duplicate (stage, label, reason)
787 /// tuples are dropped on insert so a per-artifact inner loop cannot emit
788 /// N copies of the same skip message.
789 pub fn remember_skip(&self, stage: &str, label: &str, reason: &str) {
790 self.skip_memento.remember(stage, label, reason);
791 }
792
793 pub fn template_vars(&self) -> &TemplateVars {
794 &self.template_vars
795 }
796
797 pub fn template_vars_mut(&mut self) -> &mut TemplateVars {
798 &mut self.template_vars
799 }
800
801 pub fn render_template(&self, template: &str) -> anyhow::Result<String> {
802 crate::template::render(template, &self.template_vars)
803 }
804
805 /// Render a template if present, returning `None` for `None` input.
806 pub fn render_template_opt(&self, template: Option<&str>) -> anyhow::Result<Option<String>> {
807 template.map(|t| self.render_template(t)).transpose()
808 }
809
810 /// Evaluate a `skip` field, logging at INFO level when it resolves to true.
811 ///
812 /// Returns `Ok(false)` when `skip` is `None` or evaluates falsy. On
813 /// truthy, writes `"{label} skipped"` via `log.status` and returns
814 /// `Ok(true)`. A malformed `skip:` template propagates as `Err` so the
815 /// caller fails fast — silently treating a render error as "not skipped"
816 /// (the prior behavior) shipped configs that the user thought would
817 /// suppress a stage but actually ran it.
818 pub fn skip_with_log(
819 &self,
820 skip: &Option<crate::config::StringOrBool>,
821 log: &StageLogger,
822 label: &str,
823 ) -> anyhow::Result<bool> {
824 let Some(d) = skip else {
825 return Ok(false);
826 };
827 let should_skip = d
828 .try_evaluates_to_true(|s| self.render_template(s))
829 .with_context(|| format!("evaluate skip expression for {label}"))?;
830 if should_skip {
831 log.status(&format!("{} skipped", label));
832 }
833 Ok(should_skip)
834 }
835
836 /// Whether `stage_name` (or a publisher name — the skip list is unified) is
837 /// in the operator's `--skip` denylist.
838 pub fn should_skip(&self, stage_name: &str) -> bool {
839 self.options.skip_stages.iter().any(|s| s == stage_name)
840 }
841
842 /// Whether the named publisher is excluded from this run by operator
843 /// selection. Combines the two selectors the publish dispatch consults
844 /// before running any publisher:
845 ///
846 /// - `--skip` (`skip_stages`, the UNIFIED denylist holding stage names
847 /// AND publisher names) ALWAYS wins: a publisher named there is
848 /// deselected regardless of any allowlist.
849 /// - `--publishers` (`publisher_allowlist`): an EMPTY allowlist deselects
850 /// nothing (every publisher runs); a NON-EMPTY allowlist deselects every
851 /// publisher not listed in it.
852 ///
853 /// Returns `true` when the publisher should be reported
854 /// [`crate::publish_report::SkipReason::Deselected`] instead of dispatched.
855 pub fn publisher_deselected(&self, name: &str) -> bool {
856 self.should_skip(name)
857 || (!self.options.publisher_allowlist.is_empty()
858 && !self.options.publisher_allowlist.iter().any(|s| s == name))
859 }
860
861 /// A distinguished, operator-facing summary line for a deselected
862 /// publisher, naming WHICH selector excluded it so the operator can fix
863 /// their command. `--skip` always wins, so it is tested first: a publisher
864 /// named in both selectors reports the denylist cause.
865 ///
866 /// Shared by the dispatch chokepoint and the out-of-dispatch publish
867 /// stages (blob / snapcraft-publish / docker / docker-sign / announce) so the
868 /// "skipped X — excluded via --skip" / "… — not in --publishers allowlist"
869 /// wording is identical everywhere a publisher is deselected. Call only
870 /// when [`Self::publisher_deselected`] is `true`.
871 pub fn deselected_reason(&self, name: &str) -> String {
872 let reason = if self.should_skip(name) {
873 "excluded via --skip"
874 } else {
875 "not in --publishers allowlist"
876 };
877 format!("skipped {name} — {reason}")
878 }
879
880 /// Check whether "validate" is in the skip list.
881 pub fn skip_validate(&self) -> bool {
882 self.should_skip("validate")
883 }
884
885 pub fn is_dry_run(&self) -> bool {
886 self.options.dry_run
887 }
888
889 pub fn is_snapshot(&self) -> bool {
890 self.options.snapshot
891 }
892
893 /// Whether this run is `anodizer release --publish-only` (publishing a
894 /// preserved dist rather than building from source).
895 ///
896 /// Build-time concerns (notably the `binary_signs:` per-binary signing
897 /// loop, whose output is embedded into archives at build time and has no
898 /// publish-time consumer) are gated off this in publish-only mode, where
899 /// the runner carries only publish-time credentials.
900 pub fn is_publish_only(&self) -> bool {
901 self.options.publish_only
902 }
903
904 pub fn is_strict(&self) -> bool {
905 self.options.strict
906 }
907
908 /// Toggle the runtime strict-render flag (see the `render_strict` field).
909 ///
910 /// The pre-publish guard calls this with `true` before its render pass and
911 /// restores the prior value after, so render-error swallowing is suppressed
912 /// only for that in-memory validation — production publish renders stay
913 /// lenient unless the user passed the global `--strict`. Returns the prior
914 /// value so the caller can restore it.
915 pub fn set_render_strict(&self, on: bool) -> bool {
916 self.render_strict.replace(on)
917 }
918
919 /// Whether template renders should propagate errors (strict) rather than
920 /// warn-and-fall-back-to-raw (lenient).
921 ///
922 /// True when EITHER the guard's transient `render_strict` flag is set OR the
923 /// user passed the global `--strict`, so a malformed publisher/announce
924 /// template fails loud under the guard and under `--strict` everywhere.
925 pub fn render_is_strict(&self) -> bool {
926 self.render_strict.get() || self.is_strict()
927 }
928
929 /// In strict mode, return an error. In normal mode, log a warning and continue.
930 /// Use this for any situation where a configured feature silently skips.
931 pub fn strict_guard(&self, log: &crate::log::StageLogger, msg: &str) -> anyhow::Result<()> {
932 if self.options.strict {
933 anyhow::bail!("{} (strict mode)", msg);
934 }
935 log.warn(msg);
936 Ok(())
937 }
938
939 /// Defense-in-depth helper for upload-style stages.
940 ///
941 /// Returns `true` (after logging the skip) when the context is in snapshot
942 /// mode. Stages that perform external uploads (registries, package indexes,
943 /// object storage, snap store, …) call this at entry so they no-op even
944 /// when invoked directly without the orchestration layer's auto-skip.
945 /// Centralising the check keeps every publish stage consistent and avoids
946 /// per-stage copy-paste.
947 pub fn skip_in_snapshot(&self, log: &crate::log::StageLogger, stage: &str) -> bool {
948 if self.is_snapshot() {
949 // The stage name stays in the line: this guard fires on direct
950 // stage invocation, where no pipeline section header has named
951 // the stage yet.
952 log.status(&format!("skipped {stage} — snapshot mode"));
953 true
954 } else {
955 false
956 }
957 }
958
959 /// Render a template, failing in strict mode on error, or falling back to the raw string.
960 pub fn render_template_strict(
961 &self,
962 template: &str,
963 label: &str,
964 log: &crate::log::StageLogger,
965 ) -> anyhow::Result<String> {
966 match self.render_template(template) {
967 Ok(rendered) => Ok(rendered),
968 Err(e) => {
969 if self.options.strict {
970 anyhow::bail!("{}: failed to render template: {} (strict mode)", label, e);
971 }
972 log.warn(&format!("failed to render template for {}: {}", label, e));
973 Ok(template.to_string())
974 }
975 }
976 }
977
978 pub fn is_nightly(&self) -> bool {
979 self.options.nightly
980 }
981
982 /// Set the `ReleaseURL` template variable.
983 ///
984 /// Should be called after a GitHub release is created, with the URL of
985 /// the created release (e.g. `https://github.com/owner/repo/releases/tag/v1.0.0`).
986 pub fn set_release_url(&mut self, url: &str) {
987 self.template_vars.set("ReleaseURL", url);
988 }
989
990 /// Return the current `Version` template variable, or an empty string if
991 /// not yet populated.
992 pub fn version(&self) -> String {
993 self.template_vars
994 .get("Version")
995 .cloned()
996 .unwrap_or_default()
997 }
998
999 /// Derive the verbosity level from context options.
1000 pub fn verbosity(&self) -> Verbosity {
1001 Verbosity::from_flags(self.options.quiet, self.options.verbose, self.options.debug)
1002 }
1003
1004 /// Resolve the user's `retry:` block into a concrete [`RetryPolicy`],
1005 /// applying defaults when `retry:` is unset. Equivalent to
1006 /// `ctx.config.retry.unwrap_or_default().to_policy()` but centralizes
1007 /// the lookup so a future refactor can hang validation / clamping off
1008 /// a single seam.
1009 pub fn retry_policy(&self) -> crate::retry::RetryPolicy {
1010 self.config.retry.unwrap_or_default().to_policy()
1011 }
1012
1013 /// Create a [`StageLogger`] for the given stage name, pre-attached to
1014 /// the context's env-pairs list so that subprocess stderr / stdout
1015 /// flowing through [`StageLogger::check_output`] is automatically
1016 /// redacted. The env list combines the template-engine env
1017 /// (process + config + `.env` files) and the current `std::env::vars`
1018 /// snapshot, so any secret value reachable to a hook or subprocess is
1019 /// available for scrubbing.
1020 pub fn logger(&self, stage: &'static str) -> StageLogger {
1021 #[allow(unused_mut)]
1022 let mut log = StageLogger::new(stage, self.verbosity()).with_env(self.env_for_redact());
1023 #[cfg(feature = "test-helpers")]
1024 if let Some(cap) = &self.log_capture {
1025 log = log.with_capture_handle(cap.clone());
1026 }
1027 log
1028 }
1029
1030 /// Build the env-pairs list used to seed every [`StageLogger`] created
1031 /// via [`Context::logger`]. Combines the template-engine env map
1032 /// (process env + config env + `.env` file values) with the current
1033 /// `std::env::vars` snapshot, deduplicating by key (template-engine
1034 /// values win because they reflect any user overrides).
1035 ///
1036 /// The `std::env::vars` half is memoized in `process_env_cache` (it is
1037 /// immutable for the process lifetime); only the cheap template-var
1038 /// overlay is rebuilt per call. The merged output is identical to
1039 /// collecting both fresh each time.
1040 fn env_for_redact(&self) -> Vec<(String, String)> {
1041 use std::collections::HashMap;
1042 let mut map: HashMap<String, String> = self
1043 .process_env_cache
1044 .get_or_init(|| std::env::vars().collect())
1045 .clone();
1046 for (k, v) in self.template_vars.all_env() {
1047 map.insert(k.clone(), v.clone());
1048 }
1049 map.into_iter().collect()
1050 }
1051
1052 /// Populate template variables from `self.git_info`.
1053 ///
1054 /// Must be called after `self.git_info` is set. Sets the following vars:
1055 /// - `Tag`, `Version`, `RawVersion` — tag and version strings
1056 /// - `Major`, `Minor`, `Patch` — semver components
1057 /// - `Prerelease` — prerelease suffix (or empty)
1058 /// - `BuildMetadata` — build metadata from semver tag (or empty)
1059 /// - `FullCommit`, `Commit` — full commit SHA (`Commit` is alias for `FullCommit`)
1060 /// - `ShortCommit` — abbreviated commit SHA
1061 /// - `Branch` — current git branch
1062 /// - `CommitDate` — ISO 8601 author date of HEAD commit
1063 /// - `CommitTimestamp` — unix timestamp of HEAD commit
1064 /// - `IsGitDirty` — "true"/"false"
1065 /// - `IsGitClean` — "true"/"false" (inverse of `IsGitDirty`)
1066 /// - `GitTreeState` — "clean"/"dirty"
1067 /// - `GitURL` — git remote URL
1068 /// - `Summary` — git describe summary
1069 /// - `TagSubject` — annotated tag subject or commit subject
1070 /// - `TagContents` — full annotated tag message or commit message
1071 /// - `TagBody` — tag message body or commit message body
1072 /// - `IsSnapshot` — from context options
1073 /// - `IsNightly` — from context options
1074 /// - `IsDraft` — "false" (stages may override to "true")
1075 /// - `IsSingleTarget` — "true"/"false" based on single_target option
1076 /// - `PreviousTag` — previous matching tag, stripped in monorepo mode (or empty)
1077 /// - `PrefixedTag` — full tag with monorepo prefix, or tag_prefix-prepended (Pro addition)
1078 /// - `PrefixedPreviousTag` — full previous tag with prefix (Pro addition)
1079 /// - `PrefixedSummary` — full summary with prefix (Pro addition)
1080 /// - `IsRelease` — "true" if not snapshot and not nightly (Pro addition)
1081 /// - `IsMerging` — "true" if running with --merge flag (Pro addition)
1082 ///
1083 /// **Stage-scoped variables** (NOT set here; set per-artifact during stage execution):
1084 /// - `Binary` — binary name, set by build stage per binary and archive stage per archive
1085 /// - `ArtifactName` — output artifact filename, set by archive stage after creating each archive
1086 /// - `ArtifactPath` — absolute path to artifact, set by archive stage after creating each archive
1087 /// - `ArtifactExt` — artifact file extension (e.g. `.tar.gz`, `.exe`), set alongside ArtifactName
1088 /// - `ArtifactID` — build config `id` field, set by build stage per build config
1089 /// - `Os` — target OS, set by archive/nfpm stages per target
1090 /// - `Arch` — target architecture, set by archive/nfpm stages per target
1091 /// - `Target` — full target triple (e.g. `x86_64-unknown-linux-gnu`), set alongside Os/Arch
1092 /// - `Checksums` — combined checksum file contents, set by checksum stage
1093 pub fn populate_git_vars(&mut self) {
1094 if let Some(ref info) = self.git_info {
1095 // RawVersion: just major.minor.patch, no prerelease or build metadata.
1096 let raw_version = info.semver.raw_version_string();
1097
1098 // Version: clean semver derived from the parsed SemVer struct, not
1099 // from the tag string. The old `tag.strip_prefix('v')` approach
1100 // broke for monorepo workspace tags like `core-v0.3.2` because it
1101 // only stripped a leading 'v', leaving `core-v0.3.2` intact.
1102 // Deriving from the struct handles all tag_template prefixes.
1103 let version = info.semver.version_string();
1104
1105 self.template_vars.set("Tag", &info.tag);
1106 self.template_vars.set("Version", &version);
1107 self.template_vars.set("RawVersion", &raw_version);
1108 // `Base`: the numeric base semver (no prerelease / build metadata),
1109 // captured before snapshot/nightly version templating overwrites
1110 // `Version`. Lets a nightly `version_template` reference the stable
1111 // base for schemes like `"{{ .Base }}-nightly.{{ .NightlyBuild }}+{{ .ShortCommit }}"`.
1112 self.template_vars.set("Base", &raw_version);
1113 self.template_vars
1114 .set("Major", &info.semver.major.to_string());
1115 self.template_vars
1116 .set("Minor", &info.semver.minor.to_string());
1117 self.template_vars
1118 .set("Patch", &info.semver.patch.to_string());
1119 self.template_vars.set(
1120 "Prerelease",
1121 info.semver.prerelease.as_deref().unwrap_or(""),
1122 );
1123 self.template_vars.set(
1124 "BuildMetadata",
1125 info.semver.build_metadata.as_deref().unwrap_or(""),
1126 );
1127 self.template_vars.set("FullCommit", &info.commit);
1128 self.template_vars.set("Commit", &info.commit);
1129 self.template_vars.set("ShortCommit", &info.short_commit);
1130 self.template_vars.set("Branch", &info.branch);
1131 self.template_vars.set("CommitDate", &info.commit_date);
1132 self.template_vars
1133 .set("CommitTimestamp", &info.commit_timestamp);
1134 self.template_vars.set_bool("IsGitDirty", info.dirty);
1135 self.template_vars.set_bool("IsGitClean", !info.dirty);
1136 self.template_vars
1137 .set("GitTreeState", if info.dirty { "dirty" } else { "clean" });
1138 self.template_vars.set("GitURL", &info.remote_url);
1139 self.template_vars.set("Summary", &info.summary);
1140 self.template_vars.set("TagSubject", &info.tag_subject);
1141 self.template_vars.set("TagContents", &info.tag_contents);
1142 self.template_vars.set("TagBody", &info.tag_body);
1143 self.template_vars
1144 .set("PreviousTag", info.previous_tag.as_deref().unwrap_or(""));
1145 self.template_vars
1146 .set("FirstCommit", info.first_commit.as_deref().unwrap_or(""));
1147
1148 // Pro additions: PrefixedTag, PrefixedPreviousTag, PrefixedSummary
1149 //
1150 // When monorepo.tag_prefix is configured, the git tag already
1151 // contains the prefix (e.g. "subproject1/v1.2.3"). In this case:
1152 // - Tag = prefix stripped (e.g. "v1.2.3")
1153 // - PrefixedTag = full tag (e.g. "subproject1/v1.2.3")
1154 // - PrefixedPreviousTag = full previous tag
1155 //
1156 // When monorepo is NOT configured, fall back to the original
1157 // behavior: prepend tag.tag_prefix to construct PrefixedTag.
1158 let monorepo_prefix = self.config.monorepo_tag_prefix();
1159
1160 // monorepo.tag_prefix takes precedence over tag.tag_prefix for
1161 // PrefixedTag / PrefixedPreviousTag / PrefixedSummary behavior.
1162 // When monorepo is configured, info.tag and info.summary already
1163 // contain the prefix from git, so we strip for the base vars and
1164 // use the raw values for the Prefixed variants.
1165 if let Some(prefix) = monorepo_prefix {
1166 // Monorepo mode: the tag in git_info is the FULL prefixed tag.
1167 // PrefixedTag = full tag (already has prefix).
1168 self.template_vars.set("PrefixedTag", &info.tag);
1169
1170 // Tag = prefix stripped. Override the Tag we set above.
1171 let stripped_tag = crate::git::strip_monorepo_prefix(&info.tag, prefix);
1172 self.template_vars.set("Tag", stripped_tag);
1173
1174 // Version: derived from the parsed SemVer struct (same source as
1175 // the non-monorepo path and the build stage's per-crate
1176 // re-scoping) so all three stay byte-identical. `info.semver`
1177 // was parsed from the full prefixed tag, so it already excludes
1178 // the monorepo prefix — no separate string-strip needed.
1179 //
1180 // For a non-semver tag under `--skip=validate`, info.semver is
1181 // the skip-validate fallback, so this yields "0.0.0" rather than
1182 // the old raw prefix-stripped string.
1183 let version = info.semver.version_string();
1184 self.template_vars.set("Version", &version);
1185
1186 // PrefixedPreviousTag = full previous tag (already has prefix).
1187 let prev_tag = info.previous_tag.as_deref().unwrap_or("");
1188 self.template_vars.set("PrefixedPreviousTag", prev_tag);
1189
1190 // PreviousTag = prefix stripped, consistent with Tag being stripped.
1191 let stripped_prev = crate::git::strip_monorepo_prefix(prev_tag, prefix);
1192 self.template_vars.set("PreviousTag", stripped_prev);
1193
1194 // PrefixedSummary: info.summary from `git describe` already
1195 // includes the monorepo prefix (e.g. "subproject1/v1.2.3-0-gabc123d"),
1196 // so use it as-is for the prefixed variant.
1197 self.template_vars.set("PrefixedSummary", &info.summary);
1198 // Summary: strip the monorepo prefix for the base variant.
1199 let stripped_summary = crate::git::strip_monorepo_prefix(&info.summary, prefix);
1200 self.template_vars.set("Summary", stripped_summary);
1201 } else {
1202 // Non-monorepo: prepend tag.tag_prefix to construct PrefixedTag.
1203 let tag_prefix = self
1204 .config
1205 .tag
1206 .as_ref()
1207 .and_then(|t| t.tag_prefix.as_deref())
1208 .unwrap_or("");
1209 self.template_vars
1210 .set("PrefixedTag", &format!("{}{}", tag_prefix, info.tag));
1211 let prev_tag = info.previous_tag.as_deref().unwrap_or("");
1212 let prefixed_prev = if prev_tag.is_empty() {
1213 String::new()
1214 } else {
1215 format!("{}{}", tag_prefix, prev_tag)
1216 };
1217 self.template_vars
1218 .set("PrefixedPreviousTag", &prefixed_prev);
1219 self.template_vars.set(
1220 "PrefixedSummary",
1221 &format!("{}{}", tag_prefix, info.summary),
1222 );
1223 }
1224 }
1225
1226 // `NightlyBuild`: stateless per-base-version build counter derived
1227 // from `git rev-list --count <last-tag>..HEAD`. Resets automatically
1228 // when a new version tag lands (no state anodizer persists). Set
1229 // unconditionally (it is just a count), but intended for nightly /
1230 // snapshot `version_template`s such as
1231 // `"{{ .Base }}-nightly.{{ .NightlyBuild }}+{{ .ShortCommit }}"`.
1232 // Defaults to "0" outside a git repo (synthetic snapshot/scratch
1233 // builds) and on any git error so templates never fail to render.
1234 //
1235 // The monorepo prefix constrains the last-tag lookup to the active
1236 // crate's tags so per-crate workspace runs count since the right
1237 // tag (not the nearest tag from another subproject).
1238 let nightly_build = if self.git_info.is_some() {
1239 let root = self
1240 .options
1241 .project_root
1242 .clone()
1243 .unwrap_or_else(|| PathBuf::from("."));
1244 let monorepo_prefix = self.config.monorepo_tag_prefix();
1245 crate::git::count_commits_since_last_tag_in(&root, monorepo_prefix).unwrap_or(0)
1246 } else {
1247 0
1248 };
1249 self.template_vars
1250 .set_structured("NightlyBuild", tera::Value::from(nightly_build));
1251
1252 // Mode flags are injected as real bools (not "true"/"false" strings)
1253 // so `not IsSnapshot` / `IsSnapshot == false` / bare `{% if … %}`
1254 // forms all evaluate correctly; `{{ IsSnapshot }}` interpolation
1255 // still renders "true"/"false".
1256 self.template_vars
1257 .set_bool("IsSnapshot", self.options.snapshot);
1258 self.template_vars
1259 .set_bool("IsNightly", self.options.nightly);
1260 // Surfaced to user `if_condition:` templates so stages can
1261 // selectively run inside the determinism harness even when
1262 // `not IsSnapshot` would otherwise skip them.
1263 self.template_vars.set_bool(
1264 "IsHarness",
1265 self.env_var("ANODIZER_IN_DETERMINISM_HARNESS").is_some(),
1266 );
1267 // Wire IsDraft from `release.draft`.
1268 let is_draft = self
1269 .config
1270 .release
1271 .as_ref()
1272 .and_then(|r| r.draft)
1273 .unwrap_or(false);
1274 self.template_vars.set_bool("IsDraft", is_draft);
1275 self.template_vars
1276 .set_bool("IsSingleTarget", self.options.single_target.is_some());
1277
1278 // Pro addition: IsRelease — true if this is a regular release (not snapshot, not nightly).
1279 let is_release = !self.options.snapshot && !self.options.nightly;
1280 self.template_vars.set_bool("IsRelease", is_release);
1281
1282 // Pro addition: IsMerging — true if running with --merge flag.
1283 self.template_vars.set_bool("IsMerging", self.options.merge);
1284 }
1285
1286 /// Populate time-related template variables.
1287 ///
1288 /// Sets:
1289 /// - `Date` — UTC time as RFC 3339
1290 /// - `Timestamp` — unix timestamp as string
1291 /// - `Now` — UTC time as RFC 3339
1292 /// - `Year` — four-digit year (e.g. "2026")
1293 /// - `Month` — zero-padded month (e.g. "03")
1294 /// - `Day` — zero-padded day (e.g. "30")
1295 /// - `Hour` — zero-padded hour (e.g. "14")
1296 /// - `Minute` — zero-padded minute (e.g. "05")
1297 ///
1298 /// Time source resolution (first match wins):
1299 ///
1300 /// 1. `SOURCE_DATE_EPOCH` env var — the standard reproducibility contract
1301 /// (set by the determinism harness on every child release subprocess,
1302 /// and the conventional way external CI / packagers signal a fixed
1303 /// epoch). This is load-bearing for byte-stability of `metadata.json`
1304 /// (which embeds `Date`) and any user template that consumes `Date` /
1305 /// `Timestamp` / `Now`. Without this branch, two from-clean runs of
1306 /// the same commit emit metadata.json files that differ in the `date`
1307 /// field, defeating release-asset idempotency.
1308 /// 2. `chrono::Utc::now()` — wall-clock fallback. The
1309 /// legacy semantics for runs without SDE wired in. Note that the
1310 /// template docs explicitly call `.Now` "not deterministic"
1311 /// — under SDE-aware reproducible builds we deviate from that
1312 /// behavior intentionally.
1313 pub fn populate_time_vars(&mut self) {
1314 // Resolution order (SDE first, else wall-clock) is centralized in
1315 // `crate::sde::resolve_now_with_env` so any caller —
1316 // `populate_time_vars`, Tera built-ins, stage-srpm's `%changelog`
1317 // date, nightly `date_str` — sees identical "now" semantics.
1318 // Routes through the injected `env_source` so tests can inject
1319 // SOURCE_DATE_EPOCH via TestContextBuilder::env() without
1320 // mutating the process env.
1321 let now = crate::sde::resolve_now_with_env(self.env_source());
1322 self.template_vars.set("Date", &now.to_rfc3339());
1323 self.template_vars
1324 .set("Timestamp", &now.timestamp().to_string());
1325 self.template_vars.set("Now", &now.to_rfc3339());
1326 self.template_vars
1327 .set("Year", &now.format("%Y").to_string());
1328 self.template_vars
1329 .set("Month", &now.format("%m").to_string());
1330 self.template_vars.set("Day", &now.format("%d").to_string());
1331 self.template_vars
1332 .set("Hour", &now.format("%H").to_string());
1333 self.template_vars
1334 .set("Minute", &now.format("%M").to_string());
1335 }
1336
1337 /// Populate runtime environment variables.
1338 ///
1339 /// Sets:
1340 /// - `RuntimeGoos` — host OS in Go-compatible naming (e.g. "linux", "darwin", "windows")
1341 /// - `RuntimeGoarch` — host architecture in Go-compatible naming (e.g. "amd64", "arm64")
1342 /// - `Runtime_Goos` / `Runtime_Goarch` — nested aliases
1343 /// - `RustcVersion` — host rustc release version (e.g. "1.96.0"), or "" when
1344 /// rustc is unavailable
1345 pub fn populate_runtime_vars(&mut self) {
1346 let goos = map_os_to_goos(std::env::consts::OS);
1347 let goarch = map_arch_to_goarch(std::env::consts::ARCH);
1348 self.template_vars.set("RuntimeGoos", goos);
1349 self.template_vars.set("RuntimeGoarch", goarch);
1350 // Runtime.Goos / Runtime.Goarch — after preprocessing
1351 // the dot becomes an underscore-separated flat key. We expose both forms.
1352 self.template_vars.set("Runtime_Goos", goos);
1353 self.template_vars.set("Runtime_Goarch", goarch);
1354 // RustcVersion is a host-environment fact like OS/arch, so it is set in
1355 // the same call — keeping it a separate populate step risks a call-site
1356 // forgetting to invoke the sibling.
1357 self.populate_rustc_vars();
1358 }
1359
1360 /// Populate the `RustcVersion` built-in template variable.
1361 ///
1362 /// Probes `rustc -vV` and extracts the `release:` line (e.g. `"1.96.0"`).
1363 /// Sets `RustcVersion` to the extracted string, or to `""` when rustc is
1364 /// unavailable or the line is absent — templates that reference
1365 /// `{{ .RustcVersion }}` degrade to an empty value rather than erroring.
1366 fn populate_rustc_vars(&mut self) {
1367 let ver = crate::partial::detect_rustc_version().unwrap_or_default();
1368 self.template_vars.set("RustcVersion", &ver);
1369 }
1370
1371 /// Populate the `ReleaseNotes` template variable from stored changelogs.
1372 ///
1373 /// Should be called after the changelog stage has run and populated
1374 /// `self.stage_outputs.changelogs`. Uses the first crate (by config
1375 /// order) whose changelog is present, or an empty string if no
1376 /// changelogs exist. Config order is deterministic, unlike HashMap
1377 /// iteration order.
1378 pub fn populate_release_notes_var(&mut self) {
1379 // Look up changelogs in config-defined crate order for determinism.
1380 let notes = self
1381 .config
1382 .crates
1383 .iter()
1384 .find_map(|c| self.stage_outputs.changelogs.get(&c.name))
1385 .cloned()
1386 .unwrap_or_default();
1387 self.template_vars.set("ReleaseNotes", ¬es);
1388 }
1389
1390 /// Refresh the `Artifacts` structured template variable from the current
1391 /// artifact registry. Should be called before rendering release body and
1392 /// announce templates so they can iterate over all artifacts.
1393 ///
1394 /// Each artifact is serialized as a map with keys: `name`, `path`, `target`,
1395 /// `kind`, `crate_name`, and `metadata`.
1396 ///
1397 /// **Known metadata keys** (populated by individual stages):
1398 /// - `format` — archive format (e.g. `"tar.gz"`, `"zip"`), set by archive stage
1399 /// - `extra_file` — `"true"` when artifact is an extra file, set by checksum stage
1400 /// - `extra_name_template` — name template override for extra files, set by checksum stage
1401 /// - `digest` — docker image digest (e.g. `sha256:abc123...`), set by docker stage
1402 /// - `id` — artifact ID from config, set by docker and build stages
1403 /// - `binary` — binary name, set by build stage
1404 pub fn refresh_artifacts_var(&mut self) {
1405 // CSV metadata keys we expose as JSON arrays for template iteration.
1406 // Storage remains HashMap<String,String> (flat); only the
1407 // template-exposed view is expanded. The
1408 // ExtraBinaries / ExtraFiles list semantics.
1409 const CSV_LIST_KEYS: &[&str] = &["extra_binaries", "extra_files"];
1410 // JSON-encoded list metadata keys: stored as a JSON-array string in
1411 // `HashMap<String,String>`, exposed as a real array on the template
1412 // side so `{% for p in .Artifacts[0].metadata.Platforms %}` works.
1413 // `Platforms` is the platform-list slice on
1414 // `DockerImageV2` artifacts.
1415 const JSON_LIST_KEYS: &[&str] = &["Platforms"];
1416
1417 let artifacts_value: Vec<serde_json::Value> = self
1418 .artifacts
1419 .all()
1420 .iter()
1421 .map(|a| {
1422 // Rebuild metadata map converting known CSV keys into arrays.
1423 let mut metadata_map = serde_json::Map::with_capacity(a.metadata.len());
1424 for (k, v) in &a.metadata {
1425 if CSV_LIST_KEYS.contains(&k.as_str()) {
1426 let items: Vec<serde_json::Value> = if v.is_empty() {
1427 Vec::new()
1428 } else {
1429 v.split(',')
1430 .map(|s| serde_json::Value::String(s.to_string()))
1431 .collect()
1432 };
1433 metadata_map.insert(k.clone(), serde_json::Value::Array(items));
1434 } else if JSON_LIST_KEYS.contains(&k.as_str()) {
1435 // Decode JSON-array string into a real Value::Array;
1436 // a malformed value falls back to the raw string so
1437 // custom publishers can still inspect it.
1438 let parsed = serde_json::from_str::<serde_json::Value>(v)
1439 .unwrap_or_else(|_| serde_json::Value::String(v.clone()));
1440 metadata_map.insert(k.clone(), parsed);
1441 } else {
1442 metadata_map.insert(k.clone(), serde_json::Value::String(v.clone()));
1443 }
1444 }
1445 serde_json::json!({
1446 "name": a.name,
1447 "path": a.path.to_string_lossy(),
1448 "target": a.target.as_deref().unwrap_or(""),
1449 "kind": a.kind.as_str(),
1450 "crate_name": a.crate_name,
1451 "metadata": serde_json::Value::Object(metadata_map),
1452 })
1453 })
1454 .collect();
1455 // serde_json::Value and tera::Value are the same type under the hood,
1456 // so no conversion is needed — pass values directly.
1457 let tera_value = tera::Value::Array(artifacts_value);
1458 self.template_vars.set_structured("Artifacts", tera_value);
1459 }
1460
1461 /// Populate the `Metadata` structured template variable from config.metadata.
1462 ///
1463 /// Exposes the project metadata block as a nested map with PascalCase keys
1464 /// the `.Metadata.*` namespace:
1465 /// `Description`, `Homepage`, `Documentation`, `License`, `Repository`,
1466 /// `Maintainers`, `ModTimestamp`, `FullDescription` (resolved),
1467 /// `CommitAuthor.{Name,Email}`.
1468 /// Missing fields default to empty strings / empty arrays.
1469 ///
1470 /// `full_description` supports `Inline`, `FromFile` (template-rendered
1471 /// path, read from disk), and `FromUrl` (template-rendered URL +
1472 /// headers, fetched through [`crate::content_source::resolve`] which
1473 /// applies retries, body caps, and CR/LF header-injection guards).
1474 pub fn populate_metadata_var(&mut self) -> anyhow::Result<()> {
1475 // Clone the small scalar fields so we don't hold a borrow on self.config
1476 // across the render_template calls below.
1477 let (
1478 description,
1479 homepage,
1480 documentation,
1481 license,
1482 repository,
1483 maintainers,
1484 mod_timestamp,
1485 full_desc_src,
1486 commit_author,
1487 ) = {
1488 let meta = self.config.metadata.as_ref();
1489 // Description / homepage / documentation / license resolve through
1490 // the project-level fallback: top-level `metadata.*` wins, else the
1491 // primary crate's `Cargo.toml`-derived value. This keeps
1492 // `{{ Metadata.* }}` single-sourced with the per-publisher
1493 // `meta_*_for` resolvers, so dropping a redundant `metadata.license`
1494 // (derivable from Cargo.toml) does not silently empty the var.
1495 let description = self
1496 .config
1497 .meta_description_project()
1498 .unwrap_or("")
1499 .to_string();
1500 let homepage = self
1501 .config
1502 .meta_homepage_project()
1503 .unwrap_or("")
1504 .to_string();
1505 let documentation = self
1506 .config
1507 .meta_documentation_project()
1508 .unwrap_or("")
1509 .to_string();
1510 let license = self.config.meta_license_project().unwrap_or("").to_string();
1511 let repository = self
1512 .config
1513 .meta_repository_project()
1514 .unwrap_or("")
1515 .to_string();
1516 let maintainers: Vec<String> = meta
1517 .and_then(|m| m.maintainers.as_ref())
1518 .cloned()
1519 .unwrap_or_default();
1520 let mod_timestamp = meta
1521 .and_then(|m| m.mod_timestamp.as_deref())
1522 .unwrap_or("")
1523 .to_string();
1524 let full_desc_src = meta.and_then(|m| m.full_description.clone());
1525 let commit_author = meta.and_then(|m| m.commit_author.clone());
1526 (
1527 description,
1528 homepage,
1529 documentation,
1530 license,
1531 repository,
1532 maintainers,
1533 mod_timestamp,
1534 full_desc_src,
1535 commit_author,
1536 )
1537 };
1538
1539 // Resolve full_description through the shared ContentSource resolver
1540 // so Inline, FromFile (template-rendered path), and FromUrl
1541 // (template-rendered URL + headers, retried HTTP fetch with
1542 // body cap and CR/LF guard) all behave the same as the release
1543 // header/footer fields.
1544 let full_description = match full_desc_src {
1545 None => String::new(),
1546 Some(src) => crate::content_source::resolve(&src, "metadata.full_description", self)?,
1547 };
1548
1549 let commit_author_map = serde_json::json!({
1550 "Name": commit_author.as_ref().and_then(|c| c.name.clone()).unwrap_or_default(),
1551 "Email": commit_author.as_ref().and_then(|c| c.email.clone()).unwrap_or_default(),
1552 });
1553
1554 let meta_map = serde_json::json!({
1555 "Description": description,
1556 "Homepage": homepage,
1557 "Documentation": documentation,
1558 "License": license,
1559 "Repository": repository,
1560 "Maintainers": maintainers,
1561 "ModTimestamp": mod_timestamp,
1562 "FullDescription": full_description,
1563 "CommitAuthor": commit_author_map,
1564 });
1565 // serde_json::Value and tera::Value are the same type, so pass directly.
1566 self.template_vars.set_structured("Metadata", meta_map);
1567 Ok(())
1568 }
1569}
1570
1571/// Map Rust's `std::env::consts::OS` to Go-compatible GOOS naming.
1572/// Templates expect Go runtime names (e.g. "darwin" not "macos").
1573pub fn map_os_to_goos(os: &str) -> &str {
1574 match os {
1575 "macos" => "darwin",
1576 other => other, // linux, windows, freebsd, etc. already match
1577 }
1578}
1579
1580/// Map Rust's `std::env::consts::ARCH` to Go-compatible GOARCH naming.
1581/// Templates expect Go runtime names (e.g. "amd64" not "x86_64").
1582pub fn map_arch_to_goarch(arch: &str) -> &str {
1583 match arch {
1584 "x86_64" => "amd64",
1585 "x86" => "386",
1586 "aarch64" => "arm64",
1587 "powerpc64" => "ppc64",
1588 "s390x" => "s390x",
1589 "mips" => "mips",
1590 "mips64" => "mips64",
1591 "riscv64" => "riscv64",
1592 other => other,
1593 }
1594}
1595
1596#[cfg(test)]
1597#[allow(clippy::field_reassign_with_default)]
1598mod tests {
1599 use super::*;
1600 use crate::config::Config;
1601 use crate::git::{GitInfo, SemVer};
1602 use std::collections::BTreeSet;
1603
1604 /// `VALID_RELEASE_SKIPS` MUST recognize every publisher token. Driven off
1605 /// [`PublisherKind::iter`] so a newly added publisher that is not folded
1606 /// into the `--skip` vocabulary trips immediately. Pins the nine tokens
1607 /// that had silently dropped out of the former hand-maintained literal.
1608 #[test]
1609 fn valid_release_skips_is_superset_of_every_publisher_token() {
1610 let skips: BTreeSet<&str> = VALID_RELEASE_SKIPS.iter().copied().collect();
1611 for k in PublisherKind::iter() {
1612 assert!(
1613 skips.contains(k.token()),
1614 "VALID_RELEASE_SKIPS missing publisher token `{}` — `--skip={}` would be \
1615 silently rejected",
1616 k.token(),
1617 k.token(),
1618 );
1619 }
1620 for previously_missing in [
1621 "npm",
1622 "gemfury",
1623 "cloudsmith",
1624 "artifactory",
1625 "uploads",
1626 "dockerhub",
1627 "mcp",
1628 "schemastore",
1629 "upstream-aur",
1630 ] {
1631 assert!(
1632 skips.contains(previously_missing),
1633 "publisher token `{previously_missing}` (one of the nine that had dropped out \
1634 of the old literal) is still not a recognized --skip value"
1635 );
1636 }
1637 }
1638
1639 /// The non-publisher half of the vocabulary must stay disjoint from the
1640 /// publisher tokens, so the union has a single, unambiguous owner per
1641 /// token. (`snapcraft`/`snapcraft-publish` and `release`/`github-release`
1642 /// are the deliberately-distinct stage-vs-publisher pairs.)
1643 #[test]
1644 fn non_publisher_release_skips_disjoint_from_publisher_tokens() {
1645 let publisher_tokens: BTreeSet<&str> =
1646 PublisherKind::iter().map(PublisherKind::token).collect();
1647 for stage in NON_PUBLISHER_RELEASE_SKIPS {
1648 assert!(
1649 !publisher_tokens.contains(stage),
1650 "`{stage}` is listed in NON_PUBLISHER_RELEASE_SKIPS but is also a publisher token"
1651 );
1652 }
1653 }
1654
1655 /// By construction: the token set `anodizer vocabulary` emits equals
1656 /// [`VALID_RELEASE_SKIPS`] exactly — same members, no duplicates. Both are
1657 /// derived from the same SSOT ([`NON_PUBLISHER_RELEASE_SKIPS`] ∪
1658 /// [`PublisherKind::iter`]), so a newly added publisher or stage token
1659 /// flows into both at once; this pins that they can never diverge.
1660 #[test]
1661 fn release_skip_vocabulary_token_set_equals_valid_release_skips() {
1662 let vocab = release_skip_vocabulary();
1663 let emitted: BTreeSet<&str> = vocab.iter().map(|t| t.token).collect();
1664 let valid: BTreeSet<&str> = VALID_RELEASE_SKIPS.iter().copied().collect();
1665 assert_eq!(
1666 emitted, valid,
1667 "`anodizer vocabulary` token set drifted from VALID_RELEASE_SKIPS"
1668 );
1669 assert_eq!(
1670 vocab.len(),
1671 emitted.len(),
1672 "release_skip_vocabulary emitted a duplicate token"
1673 );
1674 }
1675
1676 /// Each vocabulary entry classifies itself consistently with the SSOT:
1677 /// publisher entries carry [`PublisherKind::is_publish_stage`]; the
1678 /// non-publisher stage tokens are never marked as publishers or publish
1679 /// stages.
1680 #[test]
1681 fn release_skip_vocabulary_flags_match_publisher_kind() {
1682 let vocab = release_skip_vocabulary();
1683 for entry in &vocab {
1684 if entry.is_publisher {
1685 let kind = PublisherKind::iter()
1686 .find(|k| k.token() == entry.token)
1687 .unwrap_or_else(|| panic!("publisher entry `{}` has no kind", entry.token));
1688 assert_eq!(
1689 entry.is_publish_stage,
1690 kind.is_publish_stage(),
1691 "is_publish_stage for `{}` drifted from PublisherKind",
1692 entry.token
1693 );
1694 } else {
1695 assert!(
1696 !entry.is_publish_stage,
1697 "non-publisher token `{}` must not be a publish stage",
1698 entry.token
1699 );
1700 assert!(
1701 NON_PUBLISHER_RELEASE_SKIPS.contains(&entry.token),
1702 "non-publisher entry `{}` is not in NON_PUBLISHER_RELEASE_SKIPS",
1703 entry.token
1704 );
1705 }
1706 }
1707 }
1708
1709 fn make_git_info(dirty: bool, prerelease: Option<&str>) -> GitInfo {
1710 let tag = match prerelease {
1711 Some(pre) => format!("v1.2.3-{pre}"),
1712 None => "v1.2.3".to_string(),
1713 };
1714 GitInfo {
1715 tag,
1716 commit: "abc123def456abc123def456abc123def456abc1".to_string(),
1717 short_commit: "abc123d".to_string(),
1718 branch: "main".to_string(),
1719 dirty,
1720 semver: SemVer {
1721 major: 1,
1722 minor: 2,
1723 patch: 3,
1724 prerelease: prerelease.map(|s| s.to_string()),
1725 build_metadata: None,
1726 },
1727 commit_date: "2026-03-25T10:30:00+00:00".to_string(),
1728 commit_timestamp: "1774463400".to_string(),
1729 previous_tag: Some("v1.2.2".to_string()),
1730 remote_url: "https://github.com/test/repo.git".to_string(),
1731 summary: "v1.2.3-0-gabc123d".to_string(),
1732 tag_subject: "Release v1.2.3".to_string(),
1733 tag_contents: "Release v1.2.3\n\nFull release notes here.".to_string(),
1734 tag_body: "Full release notes here.".to_string(),
1735 first_commit: None,
1736 }
1737 }
1738
1739 #[test]
1740 fn test_context_template_vars() {
1741 let mut config = Config::default();
1742 config.project_name = "test-project".to_string();
1743 let ctx = Context::new(config, ContextOptions::default());
1744 assert_eq!(
1745 ctx.template_vars().get("ProjectName"),
1746 Some(&"test-project".to_string())
1747 );
1748 }
1749
1750 #[test]
1751 fn validate_skip_values_hint_dedups_overlapping_vocabulary() {
1752 // The release skip vocabulary is `VALID_RELEASE_SKIPS ++ publisher
1753 // names`, which legitimately overlap. A bad token must surface a hint
1754 // listing each valid option exactly ONCE, in first-seen order — not the
1755 // doubled list a raw `valid.join(", ")` produces.
1756 let valid = ["homebrew", "cargo", "npm", "homebrew", "cargo", "uploads"];
1757 let err = validate_skip_values(&["bogus".to_string()], &valid).unwrap_err();
1758 let opts = err
1759 .split("Valid options: ")
1760 .nth(1)
1761 .expect("hint must carry a Valid options list");
1762 assert_eq!(
1763 opts, "homebrew, cargo, npm, uploads",
1764 "valid options must be de-duplicated in first-seen order"
1765 );
1766 }
1767
1768 #[test]
1769 fn validate_skip_values_dedups_repeated_invalid_tokens() {
1770 // The token must not be a substring of any valid option, or `matches`
1771 // would count the valid-options hint too (`uploads` contains `upload`).
1772 let err = validate_skip_values(
1773 &["bogusxyz".to_string(), "bogusxyz".to_string()],
1774 &VALID_RELEASE_SKIPS,
1775 )
1776 .unwrap_err();
1777 assert_eq!(
1778 err.matches("bogusxyz").count(),
1779 1,
1780 "a repeated invalid token must be reported once: {err}"
1781 );
1782 }
1783
1784 #[test]
1785 fn test_context_should_skip() {
1786 let config = Config::default();
1787 let opts = ContextOptions {
1788 skip_stages: vec!["publish".to_string(), "announce".to_string()],
1789 ..Default::default()
1790 };
1791 let ctx = Context::new(config, opts);
1792 assert!(ctx.should_skip("publish"));
1793 assert!(ctx.should_skip("announce"));
1794 assert!(!ctx.should_skip("build"));
1795 }
1796
1797 #[test]
1798 fn publisher_deselected_empty_selectors_runs_everything() {
1799 let ctx = Context::new(Config::default(), ContextOptions::default());
1800 assert!(!ctx.publisher_deselected("npm"));
1801 assert!(!ctx.publisher_deselected("cargo"));
1802 assert!(!ctx.publisher_deselected("anything"));
1803 }
1804
1805 #[test]
1806 fn publisher_deselected_skip_denylists() {
1807 let opts = ContextOptions {
1808 skip_stages: vec!["npm".to_string()],
1809 ..Default::default()
1810 };
1811 let ctx = Context::new(Config::default(), opts);
1812 assert!(ctx.publisher_deselected("npm"));
1813 assert!(!ctx.publisher_deselected("cargo"));
1814 }
1815
1816 #[test]
1817 fn publisher_deselected_allowlist_excludes_unlisted() {
1818 let opts = ContextOptions {
1819 publisher_allowlist: vec!["cargo".to_string()],
1820 ..Default::default()
1821 };
1822 let ctx = Context::new(Config::default(), opts);
1823 assert!(!ctx.publisher_deselected("cargo"));
1824 assert!(ctx.publisher_deselected("npm"));
1825 }
1826
1827 #[test]
1828 fn publisher_deselected_skip_wins_over_allowlist() {
1829 let opts = ContextOptions {
1830 skip_stages: vec!["cargo".to_string()],
1831 publisher_allowlist: vec!["cargo".to_string()],
1832 ..Default::default()
1833 };
1834 let ctx = Context::new(Config::default(), opts);
1835 assert!(ctx.publisher_deselected("cargo"));
1836 }
1837
1838 #[test]
1839 fn test_context_render_template() {
1840 let mut config = Config::default();
1841 config.project_name = "myapp".to_string();
1842 let ctx = Context::new(config, ContextOptions::default());
1843 let result = ctx.render_template("{{ .ProjectName }}-release").unwrap();
1844 assert_eq!(result, "myapp-release");
1845 }
1846
1847 #[test]
1848 fn test_populate_git_vars_sets_all_expected_vars() {
1849 let config = Config::default();
1850 let mut ctx = Context::new(config, ContextOptions::default());
1851 ctx.git_info = Some(make_git_info(false, None));
1852 ctx.populate_git_vars();
1853
1854 let v = ctx.template_vars();
1855 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
1856 assert_eq!(v.get("Version"), Some(&"1.2.3".to_string()));
1857 assert_eq!(v.get("RawVersion"), Some(&"1.2.3".to_string()));
1858 assert_eq!(v.get("Major"), Some(&"1".to_string()));
1859 assert_eq!(v.get("Minor"), Some(&"2".to_string()));
1860 assert_eq!(v.get("Patch"), Some(&"3".to_string()));
1861 assert_eq!(v.get("Prerelease"), Some(&"".to_string()));
1862 assert_eq!(
1863 v.get("FullCommit"),
1864 Some(&"abc123def456abc123def456abc123def456abc1".to_string())
1865 );
1866 assert_eq!(v.get("ShortCommit"), Some(&"abc123d".to_string()));
1867 assert_eq!(v.get("Branch"), Some(&"main".to_string()));
1868 assert_eq!(
1869 v.get("CommitDate"),
1870 Some(&"2026-03-25T10:30:00+00:00".to_string())
1871 );
1872 assert_eq!(v.get("CommitTimestamp"), Some(&"1774463400".to_string()));
1873 assert_eq!(v.get("PreviousTag"), Some(&"v1.2.2".to_string()));
1874 // Base mirrors the numeric base semver, set before any
1875 // snapshot/nightly version templating overwrites Version.
1876 assert_eq!(v.get("Base"), Some(&"1.2.3".to_string()));
1877 }
1878
1879 #[test]
1880 fn test_nightly_build_defaults_to_zero_without_git_info() {
1881 // No git_info (synthetic snapshot/scratch build): NightlyBuild must
1882 // render as "0" so version_templates referencing it never fail.
1883 let config = Config::default();
1884 let mut ctx = Context::new(config, ContextOptions::default());
1885 ctx.git_info = None;
1886 ctx.populate_git_vars();
1887 assert_eq!(
1888 ctx.template_vars().get_structured("NightlyBuild"),
1889 Some(&tera::Value::from(0u64))
1890 );
1891 }
1892
1893 #[test]
1894 fn test_commit_is_alias_for_full_commit() {
1895 let config = Config::default();
1896 let mut ctx = Context::new(config, ContextOptions::default());
1897 ctx.git_info = Some(make_git_info(false, None));
1898 ctx.populate_git_vars();
1899
1900 let v = ctx.template_vars();
1901 assert_eq!(v.get("Commit"), v.get("FullCommit"));
1902 }
1903
1904 #[test]
1905 fn test_populate_git_vars_prerelease() {
1906 let config = Config::default();
1907 let mut ctx = Context::new(config, ContextOptions::default());
1908 ctx.git_info = Some(make_git_info(false, Some("rc.1")));
1909 ctx.populate_git_vars();
1910
1911 let v = ctx.template_vars();
1912 assert_eq!(v.get("Version"), Some(&"1.2.3-rc.1".to_string()));
1913 assert_eq!(v.get("RawVersion"), Some(&"1.2.3".to_string()));
1914 assert_eq!(v.get("Prerelease"), Some(&"rc.1".to_string()));
1915 }
1916
1917 #[test]
1918 fn test_build_metadata_template_var() {
1919 let config = Config::default();
1920 let mut ctx = Context::new(config, ContextOptions::default());
1921 let mut info = make_git_info(false, None);
1922 info.tag = "v1.2.3+build.42".to_string();
1923 info.semver.build_metadata = Some("build.42".to_string());
1924 ctx.git_info = Some(info);
1925 ctx.populate_git_vars();
1926
1927 let v = ctx.template_vars();
1928 assert_eq!(v.get("BuildMetadata"), Some(&"build.42".to_string()));
1929 // Version should include build metadata (strip v prefix only)
1930 assert_eq!(v.get("Version"), Some(&"1.2.3+build.42".to_string()));
1931 }
1932
1933 #[test]
1934 fn test_build_metadata_empty_when_none() {
1935 let config = Config::default();
1936 let mut ctx = Context::new(config, ContextOptions::default());
1937 ctx.git_info = Some(make_git_info(false, None));
1938 ctx.populate_git_vars();
1939
1940 assert_eq!(
1941 ctx.template_vars().get("BuildMetadata"),
1942 Some(&"".to_string())
1943 );
1944 }
1945
1946 #[test]
1947 fn test_populate_git_vars_monorepo_prefixed_tag() {
1948 // Workspace tags like "core-v0.3.2" should produce Version="0.3.2",
1949 // not "core-v0.3.2" (which breaks RPM Version fields and templates).
1950 let config = Config::default();
1951 let mut ctx = Context::new(config, ContextOptions::default());
1952 let mut info = make_git_info(false, None);
1953 info.tag = "core-v0.3.2".to_string();
1954 info.semver = SemVer {
1955 major: 0,
1956 minor: 3,
1957 patch: 2,
1958 prerelease: None,
1959 build_metadata: None,
1960 };
1961 ctx.git_info = Some(info);
1962 ctx.populate_git_vars();
1963
1964 let v = ctx.template_vars();
1965 assert_eq!(v.get("Tag"), Some(&"core-v0.3.2".to_string()));
1966 assert_eq!(v.get("Version"), Some(&"0.3.2".to_string()));
1967 assert_eq!(v.get("RawVersion"), Some(&"0.3.2".to_string()));
1968 assert_eq!(v.get("Major"), Some(&"0".to_string()));
1969 assert_eq!(v.get("Minor"), Some(&"3".to_string()));
1970 assert_eq!(v.get("Patch"), Some(&"2".to_string()));
1971 }
1972
1973 #[test]
1974 fn test_populate_git_vars_monorepo_prefixed_tag_with_prerelease() {
1975 let config = Config::default();
1976 let mut ctx = Context::new(config, ContextOptions::default());
1977 let mut info = make_git_info(false, None);
1978 info.tag = "operator-v1.0.0-rc.1".to_string();
1979 info.semver = SemVer {
1980 major: 1,
1981 minor: 0,
1982 patch: 0,
1983 prerelease: Some("rc.1".to_string()),
1984 build_metadata: None,
1985 };
1986 ctx.git_info = Some(info);
1987 ctx.populate_git_vars();
1988
1989 let v = ctx.template_vars();
1990 assert_eq!(v.get("Tag"), Some(&"operator-v1.0.0-rc.1".to_string()));
1991 assert_eq!(v.get("Version"), Some(&"1.0.0-rc.1".to_string()));
1992 assert_eq!(v.get("RawVersion"), Some(&"1.0.0".to_string()));
1993 }
1994
1995 #[test]
1996 fn test_git_tree_state_clean() {
1997 let config = Config::default();
1998 let mut ctx = Context::new(config, ContextOptions::default());
1999 ctx.git_info = Some(make_git_info(false, None));
2000 ctx.populate_git_vars();
2001
2002 let v = ctx.template_vars();
2003 assert_eq!(
2004 v.get_structured("IsGitDirty"),
2005 Some(&tera::Value::Bool(false))
2006 );
2007 assert_eq!(v.get("GitTreeState"), Some(&"clean".to_string()));
2008 }
2009
2010 #[test]
2011 fn test_git_tree_state_dirty() {
2012 let config = Config::default();
2013 let mut ctx = Context::new(config, ContextOptions::default());
2014 ctx.git_info = Some(make_git_info(true, None));
2015 ctx.populate_git_vars();
2016
2017 let v = ctx.template_vars();
2018 assert_eq!(
2019 v.get_structured("IsGitDirty"),
2020 Some(&tera::Value::Bool(true))
2021 );
2022 assert_eq!(v.get("GitTreeState"), Some(&"dirty".to_string()));
2023 }
2024
2025 #[test]
2026 fn test_is_snapshot_reflects_context_options() {
2027 let config = Config::default();
2028 let opts = ContextOptions {
2029 snapshot: true,
2030 ..Default::default()
2031 };
2032 let mut ctx = Context::new(config, opts);
2033 ctx.git_info = Some(make_git_info(false, None));
2034 ctx.populate_git_vars();
2035
2036 assert_eq!(
2037 ctx.template_vars().get_structured("IsSnapshot"),
2038 Some(&tera::Value::Bool(true))
2039 );
2040
2041 // Non-snapshot
2042 let config2 = Config::default();
2043 let opts2 = ContextOptions {
2044 snapshot: false,
2045 ..Default::default()
2046 };
2047 let mut ctx2 = Context::new(config2, opts2);
2048 ctx2.git_info = Some(make_git_info(false, None));
2049 ctx2.populate_git_vars();
2050
2051 assert_eq!(
2052 ctx2.template_vars().get_structured("IsSnapshot"),
2053 Some(&tera::Value::Bool(false))
2054 );
2055 }
2056
2057 #[test]
2058 fn test_is_draft_defaults_to_false() {
2059 let config = Config::default();
2060 let mut ctx = Context::new(config, ContextOptions::default());
2061 ctx.git_info = Some(make_git_info(false, None));
2062 ctx.populate_git_vars();
2063
2064 assert_eq!(
2065 ctx.template_vars().get_structured("IsDraft"),
2066 Some(&tera::Value::Bool(false))
2067 );
2068 }
2069
2070 #[test]
2071 fn test_previous_tag_empty_when_none() {
2072 let config = Config::default();
2073 let mut ctx = Context::new(config, ContextOptions::default());
2074 let mut info = make_git_info(false, None);
2075 info.previous_tag = None;
2076 ctx.git_info = Some(info);
2077 ctx.populate_git_vars();
2078
2079 assert_eq!(
2080 ctx.template_vars().get("PreviousTag"),
2081 Some(&"".to_string())
2082 );
2083 }
2084
2085 /// Regression: `populate_time_vars` MUST derive `Date` / `Timestamp` /
2086 /// `Now` (and the calendar fields) from `SOURCE_DATE_EPOCH` when the
2087 /// env var is set — the standard reproducible-build contract the
2088 /// determinism harness depends on. Two from-clean runs of the same
2089 /// commit otherwise emit `dist/metadata.json` files that differ in
2090 /// the embedded `date` field, drifting `metadata.json` AND its
2091 /// `.sha256` sidecar across runs. CI run 25975073213 surfaced this
2092 /// drift on every platform shard before the fix landed.
2093 #[test]
2094 fn populate_time_vars_uses_source_date_epoch_when_set() {
2095 // 1_715_000_000 = 2024-05-06T12:53:20+00:00 — picked to be safely
2096 // earlier than wall-clock so a wall-clock-derived assertion would
2097 // visibly fail.
2098 let env = crate::MapEnvSource::new().with("SOURCE_DATE_EPOCH", "1715000000");
2099 let config = Config::default();
2100 let mut ctx = Context::new(config, ContextOptions::default());
2101 ctx.set_env_source(env);
2102 ctx.populate_time_vars();
2103
2104 let v = ctx.template_vars();
2105 assert_eq!(
2106 v.get("Timestamp"),
2107 Some(&"1715000000".to_string()),
2108 "Timestamp must equal SOURCE_DATE_EPOCH seconds"
2109 );
2110 assert_eq!(
2111 v.get("Date"),
2112 Some(&"2024-05-06T12:53:20+00:00".to_string()),
2113 "Date must be RFC 3339 derived from SDE"
2114 );
2115 assert_eq!(v.get("Year"), Some(&"2024".to_string()));
2116 assert_eq!(v.get("Month"), Some(&"05".to_string()));
2117 assert_eq!(v.get("Day"), Some(&"06".to_string()));
2118 }
2119
2120 #[test]
2121 fn test_populate_time_vars() {
2122 // Wall-clock fallback path: empty MapEnvSource has no
2123 // SOURCE_DATE_EPOCH, so we exercise the chrono::Utc::now() branch.
2124 let env = crate::MapEnvSource::new();
2125 let config = Config::default();
2126 let mut ctx = Context::new(config, ContextOptions::default());
2127 ctx.set_env_source(env);
2128 ctx.populate_time_vars();
2129
2130 let v = ctx.template_vars();
2131
2132 // Date should be RFC 3339 format (e.g. 2026-03-30T12:00:00+00:00)
2133 let date = v
2134 .get("Date")
2135 .unwrap_or_else(|| panic!("Date should be set"));
2136 assert!(
2137 date.contains('T') && date.len() > 10,
2138 "Date should be RFC 3339, got: {date}"
2139 );
2140
2141 // Timestamp should be numeric
2142 let ts = v
2143 .get("Timestamp")
2144 .unwrap_or_else(|| panic!("Timestamp should be set"));
2145 assert!(
2146 ts.parse::<i64>().is_ok(),
2147 "Timestamp should be a numeric string, got: {ts}"
2148 );
2149
2150 // Now should be ISO 8601
2151 let now = v.get("Now").unwrap_or_else(|| panic!("Now should be set"));
2152 assert!(now.contains('T'), "Now should be ISO 8601, got: {now}");
2153 }
2154
2155 #[test]
2156 fn test_env_vars_accessible_in_templates() {
2157 let mut config = Config::default();
2158 config.project_name = "myapp".to_string();
2159 let mut ctx = Context::new(config, ContextOptions::default());
2160 ctx.template_vars_mut().set_env("MY_VAR", "hello-world");
2161 ctx.template_vars_mut().set_env("DEPLOY_ENV", "staging");
2162
2163 let result = ctx
2164 .render_template("{{ .Env.MY_VAR }}-{{ .Env.DEPLOY_ENV }}")
2165 .unwrap();
2166 assert_eq!(result, "hello-world-staging");
2167 }
2168
2169 #[test]
2170 fn test_populate_git_vars_without_git_info_still_sets_snapshot() {
2171 let config = Config::default();
2172 let opts = ContextOptions {
2173 snapshot: true,
2174 ..Default::default()
2175 };
2176 let mut ctx = Context::new(config, opts);
2177 // Don't set git_info — populate_git_vars should still set IsSnapshot/IsDraft
2178 ctx.populate_git_vars();
2179
2180 assert_eq!(
2181 ctx.template_vars().get_structured("IsSnapshot"),
2182 Some(&tera::Value::Bool(true))
2183 );
2184 assert_eq!(
2185 ctx.template_vars().get_structured("IsDraft"),
2186 Some(&tera::Value::Bool(false))
2187 );
2188 // Git-specific vars should NOT be set
2189 assert_eq!(ctx.template_vars().get("Tag"), None);
2190 }
2191
2192 #[test]
2193 fn test_is_nightly_set_when_nightly_mode_active() {
2194 let config = Config::default();
2195 let opts = ContextOptions {
2196 nightly: true,
2197 ..Default::default()
2198 };
2199 let mut ctx = Context::new(config, opts);
2200 ctx.git_info = Some(make_git_info(false, None));
2201 ctx.populate_git_vars();
2202
2203 assert_eq!(
2204 ctx.template_vars().get_structured("IsNightly"),
2205 Some(&tera::Value::Bool(true)),
2206 "IsNightly should be 'true' when nightly mode is active"
2207 );
2208 assert!(ctx.is_nightly(), "is_nightly() should return true");
2209 }
2210
2211 #[test]
2212 fn test_is_nightly_false_by_default() {
2213 let config = Config::default();
2214 let mut ctx = Context::new(config, ContextOptions::default());
2215 ctx.git_info = Some(make_git_info(false, None));
2216 ctx.populate_git_vars();
2217
2218 assert_eq!(
2219 ctx.template_vars().get_structured("IsNightly"),
2220 Some(&tera::Value::Bool(false)),
2221 "IsNightly should default to 'false'"
2222 );
2223 assert!(
2224 !ctx.is_nightly(),
2225 "is_nightly() should return false by default"
2226 );
2227 }
2228
2229 #[test]
2230 fn test_version_returns_populated_value() {
2231 let config = Config::default();
2232 let mut ctx = Context::new(config, ContextOptions::default());
2233 ctx.git_info = Some(make_git_info(false, None));
2234 ctx.populate_git_vars();
2235
2236 assert_eq!(ctx.version(), "1.2.3");
2237 }
2238
2239 #[test]
2240 fn test_version_returns_empty_when_not_set() {
2241 let config = Config::default();
2242 let ctx = Context::new(config, ContextOptions::default());
2243 assert_eq!(ctx.version(), "");
2244 }
2245
2246 #[test]
2247 fn test_is_nightly_without_git_info() {
2248 let config = Config::default();
2249 let opts = ContextOptions {
2250 nightly: true,
2251 ..Default::default()
2252 };
2253 let mut ctx = Context::new(config, opts);
2254 // No git_info set — populate_git_vars still sets IsNightly
2255 ctx.populate_git_vars();
2256
2257 assert_eq!(
2258 ctx.template_vars().get_structured("IsNightly"),
2259 Some(&tera::Value::Bool(true)),
2260 "IsNightly should be set even without git info"
2261 );
2262 }
2263
2264 #[test]
2265 fn test_is_git_clean_when_not_dirty() {
2266 let config = Config::default();
2267 let mut ctx = Context::new(config, ContextOptions::default());
2268 ctx.git_info = Some(make_git_info(false, None));
2269 ctx.populate_git_vars();
2270
2271 assert_eq!(
2272 ctx.template_vars().get_structured("IsGitClean"),
2273 Some(&tera::Value::Bool(true))
2274 );
2275 }
2276
2277 #[test]
2278 fn test_is_git_clean_when_dirty() {
2279 let config = Config::default();
2280 let mut ctx = Context::new(config, ContextOptions::default());
2281 ctx.git_info = Some(make_git_info(true, None));
2282 ctx.populate_git_vars();
2283
2284 assert_eq!(
2285 ctx.template_vars().get_structured("IsGitClean"),
2286 Some(&tera::Value::Bool(false))
2287 );
2288 }
2289
2290 #[test]
2291 fn test_git_url_set_from_git_info() {
2292 let config = Config::default();
2293 let mut ctx = Context::new(config, ContextOptions::default());
2294 ctx.git_info = Some(make_git_info(false, None));
2295 ctx.populate_git_vars();
2296
2297 assert_eq!(
2298 ctx.template_vars().get("GitURL"),
2299 Some(&"https://github.com/test/repo.git".to_string())
2300 );
2301 }
2302
2303 #[test]
2304 fn test_summary_set_from_git_info() {
2305 let config = Config::default();
2306 let mut ctx = Context::new(config, ContextOptions::default());
2307 ctx.git_info = Some(make_git_info(false, None));
2308 ctx.populate_git_vars();
2309
2310 assert_eq!(
2311 ctx.template_vars().get("Summary"),
2312 Some(&"v1.2.3-0-gabc123d".to_string())
2313 );
2314 }
2315
2316 #[test]
2317 fn test_tag_subject_set_from_git_info() {
2318 let config = Config::default();
2319 let mut ctx = Context::new(config, ContextOptions::default());
2320 ctx.git_info = Some(make_git_info(false, None));
2321 ctx.populate_git_vars();
2322
2323 assert_eq!(
2324 ctx.template_vars().get("TagSubject"),
2325 Some(&"Release v1.2.3".to_string())
2326 );
2327 }
2328
2329 #[test]
2330 fn test_tag_contents_set_from_git_info() {
2331 let config = Config::default();
2332 let mut ctx = Context::new(config, ContextOptions::default());
2333 ctx.git_info = Some(make_git_info(false, None));
2334 ctx.populate_git_vars();
2335
2336 assert_eq!(
2337 ctx.template_vars().get("TagContents"),
2338 Some(&"Release v1.2.3\n\nFull release notes here.".to_string())
2339 );
2340 }
2341
2342 #[test]
2343 fn test_tag_body_set_from_git_info() {
2344 let config = Config::default();
2345 let mut ctx = Context::new(config, ContextOptions::default());
2346 ctx.git_info = Some(make_git_info(false, None));
2347 ctx.populate_git_vars();
2348
2349 assert_eq!(
2350 ctx.template_vars().get("TagBody"),
2351 Some(&"Full release notes here.".to_string())
2352 );
2353 }
2354
2355 #[test]
2356 fn test_is_single_target_false_by_default() {
2357 let config = Config::default();
2358 let mut ctx = Context::new(config, ContextOptions::default());
2359 ctx.git_info = Some(make_git_info(false, None));
2360 ctx.populate_git_vars();
2361
2362 assert_eq!(
2363 ctx.template_vars().get_structured("IsSingleTarget"),
2364 Some(&tera::Value::Bool(false))
2365 );
2366 }
2367
2368 #[test]
2369 fn test_is_single_target_true_when_set() {
2370 let config = Config::default();
2371 let opts = ContextOptions {
2372 single_target: Some("x86_64-unknown-linux-gnu".to_string()),
2373 ..Default::default()
2374 };
2375 let mut ctx = Context::new(config, opts);
2376 ctx.git_info = Some(make_git_info(false, None));
2377 ctx.populate_git_vars();
2378
2379 assert_eq!(
2380 ctx.template_vars().get_structured("IsSingleTarget"),
2381 Some(&tera::Value::Bool(true))
2382 );
2383 }
2384
2385 #[test]
2386 #[serial_test::serial]
2387 fn test_populate_runtime_vars() {
2388 let config = Config::default();
2389 let mut ctx = Context::new(config, ContextOptions::default());
2390 ctx.populate_runtime_vars();
2391
2392 let v = ctx.template_vars();
2393
2394 let goos = v
2395 .get("RuntimeGoos")
2396 .unwrap_or_else(|| panic!("RuntimeGoos should be set"));
2397 assert!(
2398 !goos.is_empty(),
2399 "RuntimeGoos should not be empty, got: {goos}"
2400 );
2401 // RuntimeGoos uses Go naming (e.g. "darwin" not "macos")
2402 assert_eq!(goos, map_os_to_goos(std::env::consts::OS));
2403
2404 let goarch = v
2405 .get("RuntimeGoarch")
2406 .unwrap_or_else(|| panic!("RuntimeGoarch should be set"));
2407 assert!(
2408 !goarch.is_empty(),
2409 "RuntimeGoarch should not be empty, got: {goarch}"
2410 );
2411 // RuntimeGoarch uses Go naming (e.g. "amd64" not "x86_64")
2412 assert_eq!(goarch, map_arch_to_goarch(std::env::consts::ARCH));
2413 }
2414
2415 #[test]
2416 fn test_populate_release_notes_var_with_changelogs() {
2417 let mut config = Config::default();
2418 config.crates.push(crate::config::CrateConfig {
2419 name: "my-crate".to_string(),
2420 ..Default::default()
2421 });
2422 let mut ctx = Context::new(config, ContextOptions::default());
2423 ctx.stage_outputs
2424 .changelogs
2425 .insert("my-crate".to_string(), "## Changes\n- fix bug".to_string());
2426 ctx.populate_release_notes_var();
2427
2428 assert_eq!(
2429 ctx.template_vars().get("ReleaseNotes"),
2430 Some(&"## Changes\n- fix bug".to_string())
2431 );
2432 }
2433
2434 #[test]
2435 fn test_populate_release_notes_var_empty_when_no_changelogs() {
2436 let config = Config::default();
2437 let mut ctx = Context::new(config, ContextOptions::default());
2438 ctx.populate_release_notes_var();
2439
2440 assert_eq!(
2441 ctx.template_vars().get("ReleaseNotes"),
2442 Some(&"".to_string())
2443 );
2444 }
2445
2446 #[test]
2447 fn test_populate_release_notes_var_deterministic_with_multiple_crates() {
2448 let mut config = Config::default();
2449 config.crates.push(crate::config::CrateConfig {
2450 name: "crate-a".to_string(),
2451 ..Default::default()
2452 });
2453 config.crates.push(crate::config::CrateConfig {
2454 name: "crate-b".to_string(),
2455 ..Default::default()
2456 });
2457 let mut ctx = Context::new(config, ContextOptions::default());
2458 ctx.stage_outputs
2459 .changelogs
2460 .insert("crate-a".to_string(), "notes-a".to_string());
2461 ctx.stage_outputs
2462 .changelogs
2463 .insert("crate-b".to_string(), "notes-b".to_string());
2464 ctx.populate_release_notes_var();
2465
2466 // Should always pick the first crate in config order, not arbitrary HashMap order
2467 assert_eq!(
2468 ctx.template_vars().get("ReleaseNotes"),
2469 Some(&"notes-a".to_string())
2470 );
2471 }
2472
2473 #[test]
2474 fn test_outputs_accessible_in_templates() {
2475 let mut config = Config::default();
2476 config.project_name = "myapp".to_string();
2477 let mut ctx = Context::new(config, ContextOptions::default());
2478 ctx.template_vars_mut().set_output("build_id", "abc123");
2479 ctx.template_vars_mut()
2480 .set_output("deploy_url", "https://example.com");
2481
2482 let result = ctx
2483 .render_template("{{ .Outputs.build_id }}-{{ .Outputs.deploy_url }}")
2484 .unwrap();
2485 assert_eq!(result, "abc123-https://example.com");
2486 }
2487
2488 #[test]
2489 fn test_artifact_ext_and_target_template_vars() {
2490 let mut config = Config::default();
2491 config.project_name = "myapp".to_string();
2492 let mut ctx = Context::new(config, ContextOptions::default());
2493 ctx.template_vars_mut().set("ArtifactName", "myapp.tar.gz");
2494 ctx.template_vars_mut().set("ArtifactExt", ".tar.gz");
2495 ctx.template_vars_mut()
2496 .set("Target", "x86_64-unknown-linux-gnu");
2497
2498 let result = ctx
2499 .render_template("{{ .ArtifactExt }}_{{ .Target }}")
2500 .unwrap();
2501 assert_eq!(result, ".tar.gz_x86_64-unknown-linux-gnu");
2502 }
2503
2504 #[test]
2505 fn test_checksums_template_var() {
2506 let mut config = Config::default();
2507 config.project_name = "myapp".to_string();
2508 let mut ctx = Context::new(config, ContextOptions::default());
2509 let checksum_text = "abc123 myapp.tar.gz\ndef456 myapp.zip\n";
2510 ctx.template_vars_mut().set("Checksums", checksum_text);
2511
2512 let result = ctx.render_template("{{ .Checksums }}").unwrap();
2513 assert_eq!(result, checksum_text);
2514 }
2515
2516 // --- Pro template variable tests ---
2517
2518 #[test]
2519 fn test_prefixed_tag_with_tag_prefix() {
2520 let mut config = Config::default();
2521 config.tag = Some(crate::config::TagConfig {
2522 tag_prefix: Some("api/".to_string()),
2523 ..Default::default()
2524 });
2525 let mut ctx = Context::new(config, ContextOptions::default());
2526 ctx.git_info = Some(make_git_info(false, None));
2527 ctx.populate_git_vars();
2528
2529 assert_eq!(
2530 ctx.template_vars().get("PrefixedTag"),
2531 Some(&"api/v1.2.3".to_string())
2532 );
2533 }
2534
2535 #[test]
2536 fn test_prefixed_tag_without_tag_prefix() {
2537 let config = Config::default();
2538 let mut ctx = Context::new(config, ContextOptions::default());
2539 ctx.git_info = Some(make_git_info(false, None));
2540 ctx.populate_git_vars();
2541
2542 // No tag_prefix configured — PrefixedTag should equal Tag
2543 assert_eq!(
2544 ctx.template_vars().get("PrefixedTag"),
2545 Some(&"v1.2.3".to_string())
2546 );
2547 }
2548
2549 #[test]
2550 fn test_prefixed_previous_tag_with_tag_prefix() {
2551 let mut config = Config::default();
2552 config.tag = Some(crate::config::TagConfig {
2553 tag_prefix: Some("api/".to_string()),
2554 ..Default::default()
2555 });
2556 let mut ctx = Context::new(config, ContextOptions::default());
2557 ctx.git_info = Some(make_git_info(false, None));
2558 ctx.populate_git_vars();
2559
2560 assert_eq!(
2561 ctx.template_vars().get("PrefixedPreviousTag"),
2562 Some(&"api/v1.2.2".to_string())
2563 );
2564 }
2565
2566 #[test]
2567 fn test_prefixed_previous_tag_empty_when_no_previous() {
2568 let mut config = Config::default();
2569 config.tag = Some(crate::config::TagConfig {
2570 tag_prefix: Some("api/".to_string()),
2571 ..Default::default()
2572 });
2573 let mut ctx = Context::new(config, ContextOptions::default());
2574 let mut info = make_git_info(false, None);
2575 info.previous_tag = None;
2576 ctx.git_info = Some(info);
2577 ctx.populate_git_vars();
2578
2579 // When there is no previous tag, PrefixedPreviousTag should be empty
2580 // (not just the prefix).
2581 assert_eq!(
2582 ctx.template_vars().get("PrefixedPreviousTag"),
2583 Some(&"".to_string())
2584 );
2585 }
2586
2587 #[test]
2588 fn test_prefixed_summary_with_tag_prefix() {
2589 let mut config = Config::default();
2590 config.tag = Some(crate::config::TagConfig {
2591 tag_prefix: Some("api/".to_string()),
2592 ..Default::default()
2593 });
2594 let mut ctx = Context::new(config, ContextOptions::default());
2595 ctx.git_info = Some(make_git_info(false, None));
2596 ctx.populate_git_vars();
2597
2598 assert_eq!(
2599 ctx.template_vars().get("PrefixedSummary"),
2600 Some(&"api/v1.2.3-0-gabc123d".to_string())
2601 );
2602 }
2603
2604 #[test]
2605 fn test_is_release_true_for_normal_release() {
2606 let config = Config::default();
2607 let opts = ContextOptions {
2608 snapshot: false,
2609 nightly: false,
2610 ..Default::default()
2611 };
2612 let mut ctx = Context::new(config, opts);
2613 ctx.git_info = Some(make_git_info(false, None));
2614 ctx.populate_git_vars();
2615
2616 assert_eq!(
2617 ctx.template_vars().get_structured("IsRelease"),
2618 Some(&tera::Value::Bool(true))
2619 );
2620 }
2621
2622 #[test]
2623 fn test_is_release_false_for_snapshot() {
2624 let config = Config::default();
2625 let opts = ContextOptions {
2626 snapshot: true,
2627 ..Default::default()
2628 };
2629 let mut ctx = Context::new(config, opts);
2630 ctx.git_info = Some(make_git_info(false, None));
2631 ctx.populate_git_vars();
2632
2633 assert_eq!(
2634 ctx.template_vars().get_structured("IsRelease"),
2635 Some(&tera::Value::Bool(false))
2636 );
2637 }
2638
2639 #[test]
2640 fn test_is_release_false_for_nightly() {
2641 let config = Config::default();
2642 let opts = ContextOptions {
2643 nightly: true,
2644 ..Default::default()
2645 };
2646 let mut ctx = Context::new(config, opts);
2647 ctx.git_info = Some(make_git_info(false, None));
2648 ctx.populate_git_vars();
2649
2650 assert_eq!(
2651 ctx.template_vars().get_structured("IsRelease"),
2652 Some(&tera::Value::Bool(false))
2653 );
2654 }
2655
2656 #[test]
2657 fn test_is_merging_true_when_merge_flag_set() {
2658 let config = Config::default();
2659 let opts = ContextOptions {
2660 merge: true,
2661 ..Default::default()
2662 };
2663 let mut ctx = Context::new(config, opts);
2664 ctx.git_info = Some(make_git_info(false, None));
2665 ctx.populate_git_vars();
2666
2667 assert_eq!(
2668 ctx.template_vars().get_structured("IsMerging"),
2669 Some(&tera::Value::Bool(true))
2670 );
2671 }
2672
2673 #[test]
2674 fn test_is_merging_false_by_default() {
2675 let config = Config::default();
2676 let mut ctx = Context::new(config, ContextOptions::default());
2677 ctx.git_info = Some(make_git_info(false, None));
2678 ctx.populate_git_vars();
2679
2680 assert_eq!(
2681 ctx.template_vars().get_structured("IsMerging"),
2682 Some(&tera::Value::Bool(false))
2683 );
2684 }
2685
2686 #[test]
2687 fn test_refresh_artifacts_var_empty() {
2688 let config = Config::default();
2689 let mut ctx = Context::new(config, ContextOptions::default());
2690 ctx.refresh_artifacts_var();
2691
2692 // Should render as an empty array
2693 let result = ctx
2694 .render_template("{% for a in Artifacts %}{{ a.name }}{% endfor %}")
2695 .unwrap();
2696 assert_eq!(result, "");
2697 }
2698
2699 #[test]
2700 fn test_refresh_artifacts_var_with_artifacts() {
2701 use crate::artifact::{Artifact, ArtifactKind};
2702 use std::collections::HashMap;
2703 use std::path::PathBuf;
2704
2705 let config = Config::default();
2706 let mut ctx = Context::new(config, ContextOptions::default());
2707 // Artifacts are created with empty `name` — ArtifactRegistry::add()
2708 // auto-derives the name from the path's filename component when name
2709 // is empty (see artifact.rs add() implementation).
2710 ctx.artifacts.add(Artifact {
2711 kind: ArtifactKind::Archive,
2712 name: String::new(),
2713 path: PathBuf::from("dist/myapp-1.0.0-linux-amd64.tar.gz"),
2714 target: Some("x86_64-unknown-linux-gnu".to_string()),
2715 crate_name: "myapp".to_string(),
2716 metadata: HashMap::from([("format".to_string(), "tar.gz".to_string())]),
2717 size: None,
2718 });
2719 ctx.artifacts.add(Artifact {
2720 kind: ArtifactKind::Binary,
2721 name: String::new(),
2722 path: PathBuf::from("dist/myapp"),
2723 target: Some("x86_64-unknown-linux-gnu".to_string()),
2724 crate_name: "myapp".to_string(),
2725 metadata: HashMap::new(),
2726 size: None,
2727 });
2728 ctx.refresh_artifacts_var();
2729
2730 // Iterate over artifacts and collect names
2731 let result = ctx
2732 .render_template("{% for a in Artifacts %}{{ a.name }},{% endfor %}")
2733 .unwrap();
2734 assert!(result.contains("myapp-1.0.0-linux-amd64.tar.gz"));
2735 assert!(result.contains("myapp"));
2736
2737 // Check kind field
2738 let result_kinds = ctx
2739 .render_template("{% for a in Artifacts %}{{ a.kind }},{% endfor %}")
2740 .unwrap();
2741 assert!(result_kinds.contains("archive"));
2742 assert!(result_kinds.contains("binary"));
2743 }
2744
2745 #[test]
2746 fn test_populate_metadata_var_with_mod_timestamp() {
2747 let mut config = Config::default();
2748 config.metadata = Some(crate::config::MetadataConfig {
2749 mod_timestamp: Some("{{ .CommitTimestamp }}".to_string()),
2750 ..Default::default()
2751 });
2752 let mut ctx = Context::new(config, ContextOptions::default());
2753 ctx.populate_metadata_var().unwrap();
2754
2755 // Metadata should be accessible as a nested map with PascalCase keys
2756 let result = ctx.render_template("{{ Metadata.ModTimestamp }}").unwrap();
2757 assert_eq!(result, "{{ .CommitTimestamp }}");
2758 }
2759
2760 #[test]
2761 fn test_populate_metadata_var_empty_when_no_config() {
2762 let config = Config::default();
2763 let mut ctx = Context::new(config, ContextOptions::default());
2764 ctx.populate_metadata_var().unwrap();
2765
2766 // Should render empty strings for missing fields (PascalCase keys)
2767 let result = ctx.render_template("{{ Metadata.Description }}").unwrap();
2768 assert_eq!(result, "");
2769 }
2770
2771 #[test]
2772 fn test_populate_metadata_var_reads_from_config() {
2773 let mut config = Config::default();
2774 config.metadata = Some(crate::config::MetadataConfig {
2775 description: Some("A test project".to_string()),
2776 homepage: Some("https://example.com".to_string()),
2777 documentation: Some("https://docs.example.com".to_string()),
2778 license: Some("MIT".to_string()),
2779 repository: Some("https://github.com/example/test".to_string()),
2780 maintainers: Some(vec!["Alice".to_string(), "Bob".to_string()]),
2781 mod_timestamp: Some("1234567890".to_string()),
2782 ..Default::default()
2783 });
2784 let mut ctx = Context::new(config, ContextOptions::default());
2785 ctx.populate_metadata_var().unwrap();
2786
2787 let desc = ctx.render_template("{{ Metadata.Description }}").unwrap();
2788 assert_eq!(desc, "A test project");
2789
2790 let home = ctx.render_template("{{ Metadata.Homepage }}").unwrap();
2791 assert_eq!(home, "https://example.com");
2792
2793 let repo = ctx.render_template("{{ Metadata.Repository }}").unwrap();
2794 assert_eq!(repo, "https://github.com/example/test");
2795
2796 let docs = ctx.render_template("{{ Metadata.Documentation }}").unwrap();
2797 assert_eq!(docs, "https://docs.example.com");
2798
2799 let lic = ctx.render_template("{{ Metadata.License }}").unwrap();
2800 assert_eq!(lic, "MIT");
2801
2802 let ts = ctx.render_template("{{ Metadata.ModTimestamp }}").unwrap();
2803 assert_eq!(ts, "1234567890");
2804 }
2805
2806 #[test]
2807 fn test_populate_metadata_var_license_falls_back_to_derived() {
2808 // No top-level `metadata.license`: the var must derive from the
2809 // primary crate's Cargo.toml-derived license (here, a dual SPDX
2810 // expression), not render empty.
2811 let mut config = Config::default();
2812 config.crates = vec![crate::config::CrateConfig {
2813 name: "anodizer".to_string(),
2814 ..Default::default()
2815 }];
2816 config.derived_metadata.insert(
2817 "anodizer".to_string(),
2818 crate::config::MetadataConfig {
2819 description: Some("Derived desc".to_string()),
2820 homepage: Some("https://derived.example".to_string()),
2821 documentation: Some("https://derived.docs".to_string()),
2822 license: Some("MIT OR Apache-2.0".to_string()),
2823 ..Default::default()
2824 },
2825 );
2826 let mut ctx = Context::new(config, ContextOptions::default());
2827 ctx.populate_metadata_var().unwrap();
2828
2829 assert_eq!(
2830 ctx.render_template("{{ Metadata.License }}").unwrap(),
2831 "MIT OR Apache-2.0"
2832 );
2833 assert_eq!(
2834 ctx.render_template("{{ Metadata.Description }}").unwrap(),
2835 "Derived desc"
2836 );
2837 assert_eq!(
2838 ctx.render_template("{{ Metadata.Homepage }}").unwrap(),
2839 "https://derived.example"
2840 );
2841 assert_eq!(
2842 ctx.render_template("{{ Metadata.Documentation }}").unwrap(),
2843 "https://derived.docs"
2844 );
2845 }
2846
2847 #[test]
2848 fn test_populate_metadata_var_top_level_license_wins_over_derived() {
2849 // Explicit top-level `metadata.license` still wins over the derived
2850 // Cargo.toml value.
2851 let mut config = Config::default();
2852 config.crates = vec![crate::config::CrateConfig {
2853 name: "anodizer".to_string(),
2854 ..Default::default()
2855 }];
2856 config.derived_metadata.insert(
2857 "anodizer".to_string(),
2858 crate::config::MetadataConfig {
2859 license: Some("MIT OR Apache-2.0".to_string()),
2860 ..Default::default()
2861 },
2862 );
2863 config.metadata = Some(crate::config::MetadataConfig {
2864 license: Some("GPL-3.0".to_string()),
2865 ..Default::default()
2866 });
2867 let mut ctx = Context::new(config, ContextOptions::default());
2868 ctx.populate_metadata_var().unwrap();
2869
2870 assert_eq!(
2871 ctx.render_template("{{ Metadata.License }}").unwrap(),
2872 "GPL-3.0"
2873 );
2874 }
2875
2876 #[test]
2877 fn test_populate_metadata_var_documentation_renders() {
2878 let mut config = Config::default();
2879 config.metadata = Some(crate::config::MetadataConfig {
2880 documentation: Some("https://docs.rs/anodizer".to_string()),
2881 ..Default::default()
2882 });
2883 let mut ctx = Context::new(config, ContextOptions::default());
2884 ctx.populate_metadata_var().unwrap();
2885
2886 let docs = ctx.render_template("{{ Metadata.Documentation }}").unwrap();
2887 assert_eq!(docs, "https://docs.rs/anodizer");
2888 }
2889
2890 #[test]
2891 fn test_populate_metadata_var_documentation_empty_when_unset() {
2892 let mut ctx = Context::new(Config::default(), ContextOptions::default());
2893 ctx.populate_metadata_var().unwrap();
2894
2895 let docs = ctx.render_template("{{ Metadata.Documentation }}").unwrap();
2896 assert_eq!(docs, "");
2897 }
2898
2899 #[test]
2900 fn test_populate_metadata_var_full_description_inline() {
2901 use crate::config::ContentSource;
2902 let mut config = Config::default();
2903 config.metadata = Some(crate::config::MetadataConfig {
2904 full_description: Some(ContentSource::Inline(
2905 "A long-form description of the project.".to_string(),
2906 )),
2907 ..Default::default()
2908 });
2909 let mut ctx = Context::new(config, ContextOptions::default());
2910 ctx.populate_metadata_var().unwrap();
2911 let rendered = ctx
2912 .render_template("{{ Metadata.FullDescription }}")
2913 .unwrap();
2914 assert_eq!(rendered, "A long-form description of the project.");
2915 }
2916
2917 #[test]
2918 fn test_populate_metadata_var_full_description_from_file() {
2919 use crate::config::ContentSource;
2920 let tmp = tempfile::tempdir().unwrap();
2921 let desc_path = tmp.path().join("DESCRIPTION.md");
2922 std::fs::write(&desc_path, "read from disk").unwrap();
2923 let mut config = Config::default();
2924 config.metadata = Some(crate::config::MetadataConfig {
2925 full_description: Some(ContentSource::FromFile {
2926 from_file: desc_path.to_string_lossy().into_owned(),
2927 }),
2928 ..Default::default()
2929 });
2930 let mut ctx = Context::new(config, ContextOptions::default());
2931 ctx.populate_metadata_var().unwrap();
2932 let rendered = ctx
2933 .render_template("{{ Metadata.FullDescription }}")
2934 .unwrap();
2935 assert_eq!(rendered, "read from disk");
2936 }
2937
2938 #[test]
2939 fn test_populate_metadata_var_full_description_from_url_resolves() {
2940 // `from_url` routes through the shared `content_source::resolve`
2941 // helper. We stand up a oneshot HTTP responder so the test is
2942 // hermetic (no real network) and verify the body lands in the
2943 // rendered Metadata.FullDescription variable.
2944 use crate::config::ContentSource;
2945 use crate::test_helpers::responder::spawn_oneshot_http_responder;
2946
2947 let body = "long form description body";
2948 let body_len = body.len();
2949 let response: &'static str = Box::leak(
2950 format!("HTTP/1.1 200 OK\r\nContent-Length: {body_len}\r\n\r\n{body}").into_boxed_str(),
2951 );
2952 let (addr, _calls) = spawn_oneshot_http_responder(vec![response]);
2953
2954 let mut config = Config::default();
2955 config.metadata = Some(crate::config::MetadataConfig {
2956 full_description: Some(ContentSource::FromUrl {
2957 from_url: format!("http://{addr}/description.md"),
2958 headers: None,
2959 }),
2960 ..Default::default()
2961 });
2962 let mut ctx = Context::new(config, ContextOptions::default());
2963 ctx.populate_metadata_var()
2964 .expect("from_url should resolve through content_source");
2965 let rendered = ctx
2966 .render_template("{{ Metadata.FullDescription }}")
2967 .unwrap();
2968 assert_eq!(rendered, body);
2969 }
2970
2971 #[test]
2972 fn test_populate_metadata_var_commit_author() {
2973 use crate::config::CommitAuthorConfig;
2974 let mut config = Config::default();
2975 config.metadata = Some(crate::config::MetadataConfig {
2976 commit_author: Some(CommitAuthorConfig {
2977 name: Some("Alice Developer".to_string()),
2978 email: Some("alice@example.com".to_string()),
2979 signing: None,
2980 use_github_app_token: false,
2981 }),
2982 ..Default::default()
2983 });
2984 let mut ctx = Context::new(config, ContextOptions::default());
2985 ctx.populate_metadata_var().unwrap();
2986 let name = ctx
2987 .render_template("{{ Metadata.CommitAuthor.Name }}")
2988 .unwrap();
2989 assert_eq!(name, "Alice Developer");
2990 let email = ctx
2991 .render_template("{{ Metadata.CommitAuthor.Email }}")
2992 .unwrap();
2993 assert_eq!(email, "alice@example.com");
2994 }
2995
2996 #[test]
2997 fn test_artifact_id_template_var() {
2998 let mut config = Config::default();
2999 config.project_name = "myapp".to_string();
3000 let mut ctx = Context::new(config, ContextOptions::default());
3001 ctx.template_vars_mut().set("ArtifactID", "default");
3002
3003 let result = ctx.render_template("{{ .ArtifactID }}").unwrap();
3004 assert_eq!(result, "default");
3005 }
3006
3007 #[test]
3008 fn test_artifact_id_empty_when_not_set() {
3009 let mut config = Config::default();
3010 config.project_name = "myapp".to_string();
3011 let mut ctx = Context::new(config, ContextOptions::default());
3012 ctx.template_vars_mut().set("ArtifactID", "");
3013
3014 let result = ctx.render_template("{{ .ArtifactID }}").unwrap();
3015 assert_eq!(result, "");
3016 }
3017
3018 #[test]
3019 fn test_pro_vars_rendered_in_templates() {
3020 // Test that all Pro vars can be used in templates together
3021 let mut config = Config::default();
3022 config.tag = Some(crate::config::TagConfig {
3023 tag_prefix: Some("api/".to_string()),
3024 ..Default::default()
3025 });
3026 let opts = ContextOptions {
3027 snapshot: false,
3028 nightly: false,
3029 merge: true,
3030 ..Default::default()
3031 };
3032 let mut ctx = Context::new(config, opts);
3033 ctx.git_info = Some(make_git_info(false, None));
3034 ctx.populate_git_vars();
3035
3036 let result = ctx
3037 .render_template(
3038 "{% if IsRelease %}release{% endif %}-{% if IsMerging %}merge{% endif %}-{{ .PrefixedTag }}",
3039 )
3040 .unwrap();
3041 assert_eq!(result, "release-merge-api/v1.2.3");
3042 }
3043
3044 #[test]
3045 fn test_is_release_without_git_info() {
3046 // IsRelease should still be set even without git info
3047 let config = Config::default();
3048 let opts = ContextOptions {
3049 snapshot: false,
3050 nightly: false,
3051 ..Default::default()
3052 };
3053 let mut ctx = Context::new(config, opts);
3054 ctx.populate_git_vars();
3055
3056 assert_eq!(
3057 ctx.template_vars().get_structured("IsRelease"),
3058 Some(&tera::Value::Bool(true))
3059 );
3060 }
3061
3062 #[test]
3063 fn test_is_merging_without_git_info() {
3064 // IsMerging should still be set even without git info
3065 let config = Config::default();
3066 let opts = ContextOptions {
3067 merge: true,
3068 ..Default::default()
3069 };
3070 let mut ctx = Context::new(config, opts);
3071 ctx.populate_git_vars();
3072
3073 assert_eq!(
3074 ctx.template_vars().get_structured("IsMerging"),
3075 Some(&tera::Value::Bool(true))
3076 );
3077 }
3078
3079 // -----------------------------------------------------------------------
3080 // Monorepo template variable tests
3081 // -----------------------------------------------------------------------
3082
3083 /// Parity proof: in monorepo mode `populate_git_vars` derives `Version`
3084 /// from the shared `SemVer::version_string()` helper — the SAME source the
3085 /// build stage's per-crate `crate_template_overrides` uses — so the two
3086 /// can't drift. Exercised with a prerelease + build-metadata tag, the case
3087 /// where the old raw string-strip and the struct derivation could diverge.
3088 #[test]
3089 fn test_monorepo_version_matches_shared_semver_helper() {
3090 let mut config = Config::default();
3091 config.monorepo = Some(crate::config::MonorepoConfig {
3092 tag_prefix: Some("core/".to_string()),
3093 dir: None,
3094 });
3095 let mut ctx = Context::new(config, ContextOptions::default());
3096
3097 let semver = SemVer {
3098 major: 2,
3099 minor: 1,
3100 patch: 0,
3101 prerelease: Some("rc.1".to_string()),
3102 build_metadata: Some("build.7".to_string()),
3103 };
3104 let mut info = make_git_info(false, None);
3105 info.tag = "core/v2.1.0-rc.1+build.7".to_string();
3106 info.semver = semver.clone();
3107 ctx.git_info = Some(info);
3108 ctx.populate_git_vars();
3109
3110 let v = ctx.template_vars();
3111 // populate_git_vars (monorepo path) and the build stage's per-crate
3112 // derivation both route through SemVer::version_string().
3113 assert_eq!(v.get("Version"), Some(&semver.version_string()));
3114 assert_eq!(v.get("Version"), Some(&"2.1.0-rc.1+build.7".to_string()));
3115 assert_eq!(v.get("RawVersion"), Some(&semver.raw_version_string()));
3116 assert_eq!(v.get("RawVersion"), Some(&"2.1.0".to_string()));
3117 // Tag is still the monorepo-stripped value.
3118 assert_eq!(v.get("Tag"), Some(&"v2.1.0-rc.1+build.7".to_string()));
3119 }
3120
3121 #[test]
3122 fn test_monorepo_tag_prefix_strips_tag_for_template_var() {
3123 let mut config = Config::default();
3124 config.monorepo = Some(crate::config::MonorepoConfig {
3125 tag_prefix: Some("subproject1/".to_string()),
3126 dir: None,
3127 });
3128 let mut ctx = Context::new(config, ContextOptions::default());
3129
3130 // Simulate a monorepo tag: the full prefixed tag is stored in git_info.
3131 let mut info = make_git_info(false, None);
3132 info.tag = "subproject1/v1.2.3".to_string();
3133 info.previous_tag = Some("subproject1/v1.2.2".to_string());
3134 info.summary = "subproject1/v1.2.3-0-gabc123d".to_string();
3135 ctx.git_info = Some(info);
3136 ctx.populate_git_vars();
3137
3138 let v = ctx.template_vars();
3139 // Tag should have the prefix stripped.
3140 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
3141 // Version should derive from stripped tag.
3142 assert_eq!(v.get("Version"), Some(&"1.2.3".to_string()));
3143 // PrefixedTag should retain the full tag.
3144 assert_eq!(
3145 v.get("PrefixedTag"),
3146 Some(&"subproject1/v1.2.3".to_string())
3147 );
3148 // PreviousTag should be stripped (consistent with Tag).
3149 assert_eq!(v.get("PreviousTag"), Some(&"v1.2.2".to_string()));
3150 // PrefixedPreviousTag should retain the full tag.
3151 assert_eq!(
3152 v.get("PrefixedPreviousTag"),
3153 Some(&"subproject1/v1.2.2".to_string())
3154 );
3155 // Summary should be stripped.
3156 assert_eq!(v.get("Summary"), Some(&"v1.2.3-0-gabc123d".to_string()));
3157 // PrefixedSummary should retain the full summary.
3158 assert_eq!(
3159 v.get("PrefixedSummary"),
3160 Some(&"subproject1/v1.2.3-0-gabc123d".to_string())
3161 );
3162 }
3163
3164 #[test]
3165 fn test_monorepo_prefixed_previous_tag() {
3166 let mut config = Config::default();
3167 config.monorepo = Some(crate::config::MonorepoConfig {
3168 tag_prefix: Some("svc/".to_string()),
3169 dir: None,
3170 });
3171 let mut ctx = Context::new(config, ContextOptions::default());
3172
3173 let mut info = make_git_info(false, None);
3174 info.tag = "svc/v2.0.0".to_string();
3175 info.previous_tag = Some("svc/v1.9.0".to_string());
3176 ctx.git_info = Some(info);
3177 ctx.populate_git_vars();
3178
3179 let v = ctx.template_vars();
3180 // PrefixedPreviousTag should be the full previous tag.
3181 assert_eq!(
3182 v.get("PrefixedPreviousTag"),
3183 Some(&"svc/v1.9.0".to_string())
3184 );
3185 // PreviousTag should be stripped (prefix removed), consistent with Tag.
3186 assert_eq!(v.get("PreviousTag"), Some(&"v1.9.0".to_string()));
3187 }
3188
3189 #[test]
3190 fn test_no_monorepo_falls_back_to_tag_prefix() {
3191 // When monorepo is not set, PrefixedTag should use tag.tag_prefix.
3192 let mut config = Config::default();
3193 config.tag = Some(crate::config::TagConfig {
3194 tag_prefix: Some("release/".to_string()),
3195 ..Default::default()
3196 });
3197 let mut ctx = Context::new(config, ContextOptions::default());
3198 ctx.git_info = Some(make_git_info(false, None));
3199 ctx.populate_git_vars();
3200
3201 let v = ctx.template_vars();
3202 // Tag is plain "v1.2.3" (not stripped because no monorepo).
3203 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
3204 // PrefixedTag should prepend tag_prefix.
3205 assert_eq!(v.get("PrefixedTag"), Some(&"release/v1.2.3".to_string()));
3206 assert_eq!(
3207 v.get("PrefixedPreviousTag"),
3208 Some(&"release/v1.2.2".to_string())
3209 );
3210 }
3211
3212 #[test]
3213 fn test_monorepo_overrides_tag_prefix_for_prefixed_vars() {
3214 // When both monorepo.tag_prefix and tag.tag_prefix are set,
3215 // monorepo should take precedence for PrefixedTag.
3216 let mut config = Config::default();
3217 config.tag = Some(crate::config::TagConfig {
3218 tag_prefix: Some("release/".to_string()),
3219 ..Default::default()
3220 });
3221 config.monorepo = Some(crate::config::MonorepoConfig {
3222 tag_prefix: Some("svc/".to_string()),
3223 dir: None,
3224 });
3225 let mut ctx = Context::new(config, ContextOptions::default());
3226
3227 let mut info = make_git_info(false, None);
3228 info.tag = "svc/v1.2.3".to_string();
3229 info.previous_tag = Some("svc/v1.2.2".to_string());
3230 ctx.git_info = Some(info);
3231 ctx.populate_git_vars();
3232
3233 let v = ctx.template_vars();
3234 // Monorepo takes precedence: Tag is stripped.
3235 assert_eq!(v.get("Tag"), Some(&"v1.2.3".to_string()));
3236 // PrefixedTag is the full monorepo tag, NOT tag_prefix-prepended.
3237 assert_eq!(v.get("PrefixedTag"), Some(&"svc/v1.2.3".to_string()));
3238 }
3239
3240 #[test]
3241 fn test_monorepo_prefixed_summary() {
3242 let mut config = Config::default();
3243 config.monorepo = Some(crate::config::MonorepoConfig {
3244 tag_prefix: Some("pkg/".to_string()),
3245 dir: None,
3246 });
3247 let mut ctx = Context::new(config, ContextOptions::default());
3248
3249 let mut info = make_git_info(false, None);
3250 info.tag = "pkg/v1.2.3".to_string();
3251 // In a real monorepo, `git describe` already includes the prefix in the summary.
3252 info.summary = "pkg/v1.2.3-0-gabc123d".to_string();
3253 ctx.git_info = Some(info);
3254 ctx.populate_git_vars();
3255
3256 // PrefixedSummary is info.summary as-is (already contains prefix).
3257 assert_eq!(
3258 ctx.template_vars().get("PrefixedSummary"),
3259 Some(&"pkg/v1.2.3-0-gabc123d".to_string())
3260 );
3261 // Summary should have the prefix stripped.
3262 assert_eq!(
3263 ctx.template_vars().get("Summary"),
3264 Some(&"v1.2.3-0-gabc123d".to_string())
3265 );
3266 }
3267
3268 #[test]
3269 fn test_monorepo_no_previous_tag() {
3270 let mut config = Config::default();
3271 config.monorepo = Some(crate::config::MonorepoConfig {
3272 tag_prefix: Some("svc/".to_string()),
3273 dir: None,
3274 });
3275 let mut ctx = Context::new(config, ContextOptions::default());
3276
3277 let mut info = make_git_info(false, None);
3278 info.tag = "svc/v1.0.0".to_string();
3279 info.previous_tag = None;
3280 ctx.git_info = Some(info);
3281 ctx.populate_git_vars();
3282
3283 let v = ctx.template_vars();
3284 assert_eq!(v.get("PrefixedPreviousTag"), Some(&"".to_string()));
3285 // PreviousTag should also be empty when no previous tag exists.
3286 assert_eq!(v.get("PreviousTag"), Some(&"".to_string()));
3287 }
3288
3289 // -----------------------------------------------------------------------
3290 // Integration test: full monorepo flow
3291 // -----------------------------------------------------------------------
3292
3293 #[test]
3294 fn test_monorepo_full_flow_all_vars() {
3295 // End-to-end test: config with monorepo.tag_prefix + dir
3296 // → context creation → populate_git_vars → verify ALL template vars.
3297 let mut config = Config::default();
3298 config.project_name = "mymonorepo".to_string();
3299 config.monorepo = Some(crate::config::MonorepoConfig {
3300 tag_prefix: Some("services/api/".to_string()),
3301 dir: Some("services/api".to_string()),
3302 });
3303
3304 // Verify Config helper methods work
3305 assert_eq!(config.monorepo_tag_prefix(), Some("services/api/"));
3306 assert_eq!(config.monorepo_dir(), Some("services/api"));
3307
3308 let mut ctx = Context::new(config, ContextOptions::default());
3309
3310 // Simulate git info as it would appear in a monorepo:
3311 // tag and summary already contain the prefix from git.
3312 let mut info = make_git_info(false, None);
3313 info.tag = "services/api/v2.1.0".to_string();
3314 info.previous_tag = Some("services/api/v2.0.5".to_string());
3315 info.summary = "services/api/v2.1.0-0-gabc123d".to_string();
3316 info.semver = crate::git::SemVer {
3317 major: 2,
3318 minor: 1,
3319 patch: 0,
3320 prerelease: None,
3321 build_metadata: None,
3322 };
3323 ctx.git_info = Some(info);
3324 ctx.populate_git_vars();
3325
3326 let v = ctx.template_vars();
3327
3328 // Base vars should have the prefix STRIPPED.
3329 assert_eq!(v.get("Tag"), Some(&"v2.1.0".to_string()));
3330 assert_eq!(v.get("Version"), Some(&"2.1.0".to_string()));
3331 assert_eq!(v.get("RawVersion"), Some(&"2.1.0".to_string()));
3332 assert_eq!(v.get("Major"), Some(&"2".to_string()));
3333 assert_eq!(v.get("Minor"), Some(&"1".to_string()));
3334 assert_eq!(v.get("Patch"), Some(&"0".to_string()));
3335 assert_eq!(v.get("PreviousTag"), Some(&"v2.0.5".to_string()));
3336 assert_eq!(v.get("Summary"), Some(&"v2.1.0-0-gabc123d".to_string()));
3337
3338 // Prefixed vars should retain the FULL prefix.
3339 assert_eq!(
3340 v.get("PrefixedTag"),
3341 Some(&"services/api/v2.1.0".to_string())
3342 );
3343 assert_eq!(
3344 v.get("PrefixedPreviousTag"),
3345 Some(&"services/api/v2.0.5".to_string())
3346 );
3347 assert_eq!(
3348 v.get("PrefixedSummary"),
3349 Some(&"services/api/v2.1.0-0-gabc123d".to_string())
3350 );
3351
3352 // Project name should be available.
3353 assert_eq!(v.get("ProjectName"), Some(&"mymonorepo".to_string()));
3354 }
3355
3356 #[test]
3357 fn context_env_var_defaults_to_process_env_source() {
3358 let ctx = Context::new(Config::default(), ContextOptions::default());
3359 // A deliberately weird name no real shell will ever export.
3360 assert_eq!(ctx.env_var("ANODIZER_T3_UNSET_VAR"), None);
3361 }
3362
3363 #[test]
3364 fn context_env_var_routes_to_injected_source() {
3365 let mut ctx = Context::new(Config::default(), ContextOptions::default());
3366 ctx.set_env_source(crate::MapEnvSource::new().with("INJECTED", "yes"));
3367 assert_eq!(ctx.env_var("INJECTED"), Some("yes".to_string()));
3368 // The injected source REPLACES the process source — `PATH` is set
3369 // in every realistic execution environment, but the map does not
3370 // know about it, so the read must return `None`.
3371 assert_eq!(ctx.env_var("PATH"), None);
3372 }
3373
3374 #[test]
3375 #[serial_test::serial]
3376 fn populate_runtime_vars_sets_rustc_version() {
3377 let config = Config::default();
3378 let mut ctx = Context::new(config, ContextOptions::default());
3379 // RustcVersion is folded into populate_runtime_vars — exercising the
3380 // public entry point proves the delegation wires the var through.
3381 ctx.populate_runtime_vars();
3382
3383 let ver = ctx
3384 .template_vars()
3385 .get("RustcVersion")
3386 .expect("RustcVersion should be set after populate_runtime_vars");
3387 // On a host with rustc on PATH the var must be non-empty and start
3388 // with a digit (e.g. "1.96.0"). On a host without rustc the var is
3389 // empty but must still be present (no missing-key footgun).
3390 if !ver.is_empty() {
3391 assert!(
3392 ver.chars().next().is_some_and(|c| c.is_ascii_digit()),
3393 "RustcVersion should start with a digit: {ver}"
3394 );
3395 }
3396 }
3397}