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