Skip to main content

anodizer_core/context/
mod.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 serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::path::PathBuf;
15use std::sync::{Arc, LazyLock, Mutex};
16use strum::IntoEnumIterator;
17
18mod mode;
19mod options;
20mod populate;
21mod render;
22mod runtime;
23mod skip;
24mod state;
25#[cfg(test)]
26#[allow(clippy::field_reassign_with_default)]
27mod tests;
28
29pub use options::*;
30pub use populate::{map_arch_to_goarch, map_os_to_goos};
31pub use skip::*;
32pub use state::*;
33
34pub struct Context {
35    pub config: Config,
36    pub artifacts: ArtifactRegistry,
37    pub options: ContextOptions,
38    /// Stage→stage handoff outputs (changelog text, header/footer, etc.).
39    pub stage_outputs: StageOutputs,
40    template_vars: TemplateVars,
41    pub git_info: Option<GitInfo>,
42    /// The resolved SCM token type (GitHub, GitLab, or Gitea).
43    pub token_type: ScmTokenType,
44    /// Aggregated skips from per-sub-config loops (signs, docker_signs,
45    /// publishers, …). Drained by the pipeline runner at end-of-pipeline so
46    /// the summary shows what was intentionally skipped — mirroring
47    /// the skip-memento pattern. The inner `Arc<Mutex<…>>`
48    /// lets parallel stage workers contribute without extra plumbing.
49    pub skip_memento: crate::pipe_skip::SkipMemento,
50    /// Per-expectation skips recorded by the emission-validate pass on a
51    /// target-restricted build (an expectation whose target subset was not
52    /// built in this run, or a cross-platform aggregate with no eligible
53    /// artifact). Kept SEPARATE from [`Self::skip_memento`] on purpose: that
54    /// memento is drained into the default-visible end-of-pipeline summary,
55    /// while these skips surface only as verbose lines plus an aggregate
56    /// count in the stage's one RESULT line — a sharded run would otherwise
57    /// print one summary line per unbuilt-target expectation.
58    pub emission_skips: crate::pipe_skip::SkipMemento,
59    /// Trait-based publisher dispatch report, set by `PublishStage::run`
60    /// when the per-publisher dispatcher finishes. `None` until the
61    /// publish stage executes (or when publishing is skipped entirely
62    /// via snapshot mode / `--skip=publish`). Downstream stages
63    /// (SnapcraftPublishStage, AnnounceStage, future Submitter-group
64    /// stages) consult this to apply the submitter-gate / announce-gate
65    /// rules — see `PublishReport::any_failed`.
66    pub publish_report: Option<PublishReport>,
67    /// Whether `PublishStage::run` entered its body this run. Set before
68    /// the pre-dispatch guards (rerun refusal, runtime allowlist), so a
69    /// guard abort leaves this `true` with `publish_report` still `None`
70    /// — the summary placeholder row uses the pair to distinguish
71    /// "publish skipped" from "publish aborted before dispatch".
72    pub publish_attempted: bool,
73    /// Verify-release verdict, set by `VerifyReleaseStage::run` immediately
74    /// before it returns (clean pass OR `bail!`). `None` until the gate runs
75    /// its checks — it stays `None` on the disabled / skipped / dry-run /
76    /// snapshot early-returns, where no published release exists to verify.
77    ///
78    /// Read by the run-summary builder so the end-of-pipeline Summary states
79    /// the verify-release outcome on a SEPARATE axis from the publisher rows:
80    /// the gate runs after the irreversible publish, so the publishes still
81    /// read `succeeded` while this slot records whether the published release
82    /// has unverified defects to investigate.
83    pub verify_release: Option<VerifyReleaseSummary>,
84    /// Pre-submitter verify-release gate, installed once by the CLI's
85    /// pipeline-composition layer right after construction (never by a
86    /// stage). Invoked by `stage-publish`'s dispatcher immediately before
87    /// the first Submitter-group (one-way-door) publisher would run:
88    /// `Ok(true)` clears the gate, `Ok(false)` or `Err` blocks every
89    /// Submitter-group publisher for the run with
90    /// `SkipReason::VerifyGateBlocked`.
91    ///
92    /// A plain closure field, not a stage, because `stage-publish` cannot
93    /// depend on `stage-verify-release` (the dependency runs the other way:
94    /// `stage-verify-release` already depends on `stage-publish` for its
95    /// terminal landing checks) — routing the call through `Context`, which
96    /// every stage crate depends on, avoids the cycle without inventing a
97    /// second verify taxonomy. `Arc` (not a bare `Box`) so the field can be
98    /// cheaply cloned out of `&mut Context` before being invoked with
99    /// `&mut Context`.
100    pub verify_gate: Option<VerifyGate>,
101    /// SOURCE_DATE_EPOCH seed + non-determinism allow-list state for the
102    /// run. `None` until a stage (typically `BuildStage`) seeds it from
103    /// `resolve_reproducible_epoch(commit_timestamp)`; downstream stages
104    /// (`stage-sbom`, `stage-archive`, `stage-sign`) read `sde` to derive
105    /// deterministic timestamps. Lazy-init by design: tests and snapshot
106    /// runs without a clean commit can still proceed.
107    pub determinism: Option<crate::DeterminismState>,
108    /// Per-publisher outcome override published by `Publisher::run` when
109    /// the artifact reached a non-`Succeeded` terminal state but `run`
110    /// still returned `Ok` (e.g. chocolatey moderation skip,
111    /// winget/krew/homebrew PR-already-exists skip). Dispatch consumes
112    /// this slot via `take_pending_outcome()` immediately after `run`
113    /// returns Ok so the per-publisher row in the summary table reads
114    /// `pending-moderation` / `pending-validation` instead of
115    /// `succeeded`. The slot is single-shot: any unread value is
116    /// cleared at the start of every `run` call.
117    pub pending_outcome: Option<crate::PublisherOutcome>,
118    /// Partial [`PublishEvidence`] published by `Publisher::run` BEFORE it
119    /// returned `Err`, so a publisher that did irreversible work for the
120    /// first N items and then failed on item N+1 can still hand the
121    /// rollback path the authoritative record of what actually went live.
122    ///
123    /// The cargo publisher is the motivating case: a multi-crate publish
124    /// that succeeds on crate A then fails on crate B must yank A. On the
125    /// `Ok` path `run` returns its evidence directly; on the `Err` path
126    /// dispatch consumes this slot via [`Context::take_pending_evidence`]
127    /// and records it on the failed publisher's report row so rollback has
128    /// something to act on. Single-shot — drained at the start of every
129    /// `run` and cleared on the `Ok` path.
130    pub pending_evidence: Option<crate::PublishEvidence>,
131    /// Distinct set of crate names the build stage actually built — i.e.
132    /// those that had at least one in-scope build (or `copy_from`) job after
133    /// target resolution. `None` until [`BuildStage`] runs (e.g. merge mode,
134    /// which pre-loads artifacts and never invokes the build stage).
135    ///
136    /// Read by the binary-artifact guard to distinguish "configured a
137    /// binary-requiring surface but legitimately had no in-scope target in
138    /// this shard" (skip) from "was built yet produced no binary" (a real
139    /// mis-scope to fail on). Populated via [`Context::set_built_crate_names`]
140    /// and read via [`Context::built_crate_names`].
141    built_crate_names: Option<std::collections::HashSet<String>>,
142    /// Injectable environment-variable source. Defaults to
143    /// [`ProcessEnvSource`] (reads `std::env::var`). Tests inject a
144    /// [`MapEnvSource`](crate::MapEnvSource) via
145    /// [`TestContextBuilder::env`](crate::test_helpers::TestContextBuilder::env)
146    /// so deterministic branches can be exercised without mutating the
147    /// process env. Read through [`Context::env_var`]; replace via
148    /// [`Context::set_env_source`].
149    env_source: Arc<dyn EnvSource>,
150    /// Live handle to the secret-redaction table shared with every
151    /// [`StageLogger`] this context has ever produced via [`Context::logger`]
152    /// (each gets a clone of the same `Arc<Mutex<_>>` cell). Refreshed from
153    /// [`Context::env_for_redact`] by [`Context::refresh_secret_env`] at every
154    /// `env_source` mutation point (`set_env_source`, `set_env_source_arc`,
155    /// `begin_cargo_trusted_publishing`, `end_cargo_trusted_publishing`), so a
156    /// logger constructed BEFORE a mid-run credential mint — e.g. crates.io
157    /// Trusted Publishing minting `CARGO_REGISTRY_TOKEN` into `env_source`
158    /// partway through `publish_to_cargo` — still redacts it: `StageLogger::
159    /// redact` reads this cell live rather than a frozen construction-time
160    /// snapshot.
161    secret_env: crate::log::RedactionEnv,
162    /// Live crates.io Trusted-Publishing overlay state, set for the duration
163    /// of a cargo publish that minted a short-lived token via OIDC. Holds the
164    /// minted token (the revoke + yank-injection credential) and the base env
165    /// source captured before the overlay was installed (restored on
166    /// teardown). `None` on the ambient `auth: token` path and outside a
167    /// mint. Managed exclusively through
168    /// [`Context::begin_cargo_trusted_publishing`] /
169    /// [`Context::end_cargo_trusted_publishing`].
170    cargo_trusted_publishing: Option<CargoTrustedPublishing>,
171    /// Optional in-memory log-capture handle. When `Some`, every logger
172    /// produced by [`Context::logger`] attaches it so the test can read
173    /// back aggregated counts of `status` / `warn` / etc. calls without
174    /// having to intercept stderr.
175    ///
176    /// Gated behind the `test-helpers` Cargo feature — production
177    /// binaries do not carry the field at all.
178    #[cfg(feature = "test-helpers")]
179    pub log_capture: Option<crate::log::LogCapture>,
180    /// Runtime-togglable strict-render flag, distinct from the user's global
181    /// `--strict` (`options.strict`). The pre-publish guard flips this on for
182    /// the duration of its in-memory render pass (via [`Context::set_render_strict`])
183    /// so EVERY publisher/announce template it renders propagates its error
184    /// instead of falling back to the raw string — turning a swallowed
185    /// broken-template warning into a release-blocking abort BEFORE any
186    /// irreversible publisher fires. Production publish leaves it `false`, so
187    /// dry-run / snapshot / nightly stay lenient (warn + raw fallback).
188    ///
189    /// A `Cell` (not a plain `bool`) because the render path holds only a
190    /// shared `&Context`: the guard sets it through its `&mut Context`, then
191    /// the deep render helpers toggle-read it through `&Context`.
192    /// [`Context::render_is_strict`] ORs this with `is_strict()`, so the user's
193    /// global `--strict` also makes every render strict everywhere.
194    render_strict: std::cell::Cell<bool>,
195    /// When true, announce message BODIES are treated as already-final text and
196    /// are NOT run through Tera at send time. Set by `anodizer notify` so an
197    /// operator-supplied (possibly untrusted) message — e.g. an on_error error
198    /// string — cannot expand an `Env`-reference into a secret when the
199    /// provider sends it. Only message bodies are affected; titles and other
200    /// templated fields still render normally.
201    pub literal_message: bool,
202    /// When true (the default), outbound announce message BODIES have
203    /// known-secret env values masked before send (same policy as log
204    /// redaction). `anodizer notify --allow-secrets` sets this false to send a
205    /// secret deliberately over a trusted channel. Only the outbound body is
206    /// affected; anodizer's own logs are redacted unconditionally regardless of
207    /// this flag.
208    pub redact_body: bool,
209}
210
211/// Live crates.io Trusted-Publishing overlay state (see the `Context`
212/// `cargo_trusted_publishing` field). The `base` is the env source captured
213/// before the token overlay was installed, restored verbatim on teardown.
214struct CargoTrustedPublishing {
215    token: String,
216    base: Arc<dyn EnvSource>,
217}
218
219impl Context {
220    pub fn new(config: Config, options: ContextOptions) -> Self {
221        let mut vars = TemplateVars::new();
222        vars.set("ProjectName", &config.project_name);
223        let ctx = Self {
224            config,
225            artifacts: ArtifactRegistry::new(),
226            options,
227            stage_outputs: StageOutputs::default(),
228            template_vars: vars,
229            git_info: None,
230            token_type: ScmTokenType::GitHub,
231            skip_memento: crate::pipe_skip::SkipMemento::new(),
232            emission_skips: crate::pipe_skip::SkipMemento::new(),
233            publish_report: None,
234            publish_attempted: false,
235            verify_release: None,
236            verify_gate: None,
237            determinism: None,
238            pending_outcome: None,
239            pending_evidence: None,
240            built_crate_names: None,
241            env_source: Arc::new(ProcessEnvSource),
242            secret_env: Arc::new(Mutex::new(Vec::new())),
243            cargo_trusted_publishing: None,
244            #[cfg(feature = "test-helpers")]
245            log_capture: None,
246            render_strict: std::cell::Cell::new(false),
247            literal_message: false,
248            redact_body: true,
249        };
250        ctx.refresh_secret_env();
251        ctx
252    }
253
254    /// Redact known-secret env values from outbound announce text, using the
255    /// same combined env (template engine env + process env) and the same
256    /// policy as log redaction. Always redacts; gating on `redact_body` is the
257    /// caller's responsibility (see `render_message_with_default`).
258    pub fn redact(&self, s: &str) -> String {
259        crate::redact::with_env(s, &self.env_for_redact())
260    }
261
262    /// Read an environment variable through the injected source.
263    ///
264    /// Production reads `std::env::var(name).ok()`. Tests inject a
265    /// [`MapEnvSource`](crate::MapEnvSource) via
266    /// [`TestContextBuilder::env`](crate::test_helpers::TestContextBuilder::env)
267    /// so deterministic branches can be exercised without mutating the
268    /// process env.
269    pub fn env_var(&self, name: &str) -> Option<String> {
270        self.env_source.var(name)
271    }
272
273    /// Replace the injected environment-variable source.
274    ///
275    /// Production migration code uses this when wrapping an
276    /// already-constructed context; tests reach this indirectly through
277    /// [`TestContextBuilder::env`](crate::test_helpers::TestContextBuilder::env).
278    pub fn set_env_source<S: EnvSource + 'static>(&mut self, src: S) {
279        self.env_source = Arc::new(src);
280        self.refresh_secret_env();
281    }
282
283    /// Replace the injected environment-variable source with an already-boxed
284    /// `Arc<dyn EnvSource>`. Used to RESTORE a previously captured base source
285    /// after a temporary overlay (see
286    /// [`Context::begin_cargo_trusted_publishing`]) without re-wrapping it.
287    pub fn set_env_source_arc(&mut self, src: Arc<dyn EnvSource>) {
288        self.env_source = src;
289        self.refresh_secret_env();
290    }
291
292    /// Overlay a minted crates.io Trusted-Publishing token as
293    /// `CARGO_REGISTRY_TOKEN` for the cargo publish+rollback lifecycle.
294    ///
295    /// The current env source is captured as the base, then wrapped in a
296    /// [`LayeredEnvSource`](crate::LayeredEnvSource) that overrides
297    /// `CARGO_REGISTRY_TOKEN` with `token`. This makes the token visible to
298    /// env-driven paths that read through [`Context::env_source`] — notably
299    /// the rollback scope-availability gate — so a partial OIDC publish can
300    /// still yank, even though no ambient token exists. The token is also
301    /// retained as a marker so a later `rollback()` knows a minted token is
302    /// live and must be revoked after the yank.
303    ///
304    /// Paired with [`Context::end_cargo_trusted_publishing`], which restores
305    /// the base source and returns the token for best-effort revocation.
306    pub fn begin_cargo_trusted_publishing(&mut self, token: String) {
307        let base = self.env_source_arc();
308        self.env_source = Arc::new(crate::env_source::LayeredEnvSource::new(
309            Arc::clone(&base),
310            [("CARGO_REGISTRY_TOKEN".to_string(), token.clone())],
311        ));
312        self.cargo_trusted_publishing = Some(CargoTrustedPublishing { token, base });
313        self.refresh_secret_env();
314    }
315
316    /// The minted crates.io Trusted-Publishing token, if an overlay is active.
317    /// `rollback()` reads this to learn (i) that the yank must inject a minted
318    /// token, and (ii) that the token must be revoked once the yank completes.
319    pub fn cargo_trusted_publishing_token(&self) -> Option<&str> {
320        self.cargo_trusted_publishing
321            .as_ref()
322            .map(|s| s.token.as_str())
323    }
324
325    /// Tear down the Trusted-Publishing overlay: restore the captured base env
326    /// source, drop the marker, and return the minted token so the caller can
327    /// revoke it (best-effort). Returns `None` when no overlay is active (the
328    /// `auth: token` / ambient path never mints, so its long-lived token is
329    /// neither overlaid nor revoked).
330    pub fn end_cargo_trusted_publishing(&mut self) -> Option<String> {
331        let state = self.cargo_trusted_publishing.take()?;
332        self.env_source = state.base;
333        self.refresh_secret_env();
334        Some(state.token)
335    }
336
337    /// Borrow the injected environment-variable source as a trait
338    /// object so callers can pass it into helpers that take
339    /// `&dyn EnvSource` / `&E: EnvSource + ?Sized` without re-binding
340    /// each var through [`Context::env_var`].
341    pub fn env_source(&self) -> &dyn EnvSource {
342        self.env_source.as_ref()
343    }
344
345    /// Clone the injected environment-variable source as an `Arc` so
346    /// callers can move it into a `tokio::spawn` future or any other
347    /// `'static` closure. Production-default value is
348    /// [`ProcessEnvSource`]; tests may replace it via
349    /// [`Context::set_env_source`].
350    pub fn env_source_arc(&self) -> Arc<dyn EnvSource> {
351        Arc::clone(&self.env_source)
352    }
353
354    /// Attach an in-memory log-capture sink so every logger derived from
355    /// this context via [`Context::logger`] records to it. Intended for
356    /// tests; production callers leave this `None`.
357    ///
358    /// Gated behind the `test-helpers` Cargo feature.
359    #[cfg(feature = "test-helpers")]
360    pub fn with_log_capture(&mut self, capture: crate::log::LogCapture) {
361        self.log_capture = Some(capture);
362    }
363
364    /// Build the env-pairs list used to seed every [`StageLogger`] created
365    /// via [`Context::logger`]. Combines the template-engine env map
366    /// (config env + `.env` file values) with the injected [`EnvSource`]'s
367    /// full snapshot ([`EnvSource::vars`]), deduplicating by key
368    /// (template-engine values win because they reflect any user
369    /// overrides).
370    ///
371    /// Routes through `self.env_source` — not a raw `std::env::vars()` read
372    /// — so [`TestContextBuilder::sealed_env`](crate::test_helpers::TestContextBuilder::sealed_env)'s
373    /// documented "never the ambient process environment" promise also
374    /// covers log/announce redaction, not just [`Context::env_var`] point
375    /// lookups. A hermetic test that seals its env must not have an
376    /// unrelated real ambient secret-suffixed var silently mask a literal
377    /// fixture substring in the redacted output.
378    fn env_for_redact(&self) -> Vec<(String, String)> {
379        use std::collections::HashMap;
380        let mut map: HashMap<String, String> = self.env_source.vars().into_iter().collect();
381        for (k, v) in self.template_vars.all_env() {
382            map.insert(k.clone(), v.clone());
383        }
384        map.into_iter().collect()
385    }
386
387    /// Recompute [`Context::env_for_redact`] and publish it into
388    /// [`Context::secret_env`], the live cell every [`StageLogger`] produced
389    /// by [`Context::logger`] shares. Called at every `env_source` mutation
390    /// point so a logger built earlier in the run still redacts a secret
391    /// minted afterward (see the `secret_env` field doc for the concrete
392    /// crates.io Trusted-Publishing scenario this closes).
393    fn refresh_secret_env(&self) {
394        let fresh = self.env_for_redact();
395        *self.secret_env.lock().unwrap_or_else(|e| e.into_inner()) = fresh;
396    }
397}
398
399/// Set the full version-derived template var block (`Tag`, `Version`,
400/// `RawVersion`, `Base`, `Major`, `Minor`, `Patch`, `Prerelease`,
401/// `BuildMetadata`) from a parsed `semver` and the release `tag`. The single
402/// source of truth for this block, shared by [`Context::populate_git_vars`] (the
403/// context's own git version) and [`Context::render_template_for_version`] (a
404/// promotion's target version) so the two can never drift.
405fn set_version_vars(vars: &mut TemplateVars, semver: &crate::git::SemVer, tag: &str) {
406    // RawVersion: major.minor.patch only, no prerelease / build metadata.
407    let raw_version = semver.raw_version_string();
408    // Version: clean semver derived from the parsed struct (handles every
409    // tag_template prefix, e.g. monorepo `core-v0.3.2`).
410    let version = semver.version_string();
411
412    vars.set("Tag", tag);
413    vars.set("Version", &version);
414    vars.set("RawVersion", &raw_version);
415    // `Base`: the numeric base semver, captured before snapshot/nightly version
416    // templating overwrites `Version`, for schemes like
417    // `"{{ .Base }}-nightly.{{ .NightlyBuild }}+{{ .ShortCommit }}"`.
418    vars.set("Base", &raw_version);
419    vars.set("Major", &semver.major.to_string());
420    vars.set("Minor", &semver.minor.to_string());
421    vars.set("Patch", &semver.patch.to_string());
422    vars.set("Prerelease", semver.prerelease.as_deref().unwrap_or(""));
423    vars.set(
424        "BuildMetadata",
425        semver.build_metadata.as_deref().unwrap_or(""),
426    );
427}