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::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`
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    /// Repo-relative (`/`-separated) paths anodizer itself wrote into the
203    /// working tree during this run — e.g. the `[package.metadata.binstall]`
204    /// table the cargo publisher emits into a crate's `Cargo.toml` right
205    /// before `cargo publish`. Dirt on exactly these paths is the tool's own
206    /// expected residue, not operator-authored drift: the clean-tree publish
207    /// guard exempts them so a mutation made for crate A cannot false-trip
208    /// the guard when crate B publishes later in the same run (per-crate
209    /// `--publish-only` iterates the whole publish pipeline once per crate
210    /// against one persistent context). Recorded via
211    /// [`Context::record_tree_mutation`].
212    tree_mutations: std::collections::BTreeSet<String>,
213    /// When true (the default), outbound announce message BODIES have
214    /// known-secret env values masked before send (same policy as log
215    /// redaction). `anodizer notify --allow-secrets` sets this false to send a
216    /// secret deliberately over a trusted channel. Only the outbound body is
217    /// affected; anodizer's own logs are redacted unconditionally regardless of
218    /// this flag.
219    pub redact_body: bool,
220}
221
222/// Live crates.io Trusted-Publishing overlay state (see the `Context`
223/// `cargo_trusted_publishing` field). The `base` is the env source captured
224/// before the token overlay was installed, restored verbatim on teardown.
225struct CargoTrustedPublishing {
226    token: String,
227    base: Arc<dyn EnvSource>,
228}
229
230impl Context {
231    pub fn new(config: Config, options: ContextOptions) -> Self {
232        let mut vars = TemplateVars::new();
233        vars.set("ProjectName", &config.project_name);
234        let ctx = Self {
235            config,
236            artifacts: ArtifactRegistry::new(),
237            options,
238            stage_outputs: StageOutputs::default(),
239            template_vars: vars,
240            git_info: None,
241            token_type: ScmTokenType::GitHub,
242            skip_memento: crate::pipe_skip::SkipMemento::new(),
243            emission_skips: crate::pipe_skip::SkipMemento::new(),
244            publish_report: None,
245            publish_attempted: false,
246            verify_release: None,
247            verify_gate: None,
248            determinism: None,
249            pending_outcome: None,
250            pending_evidence: None,
251            built_crate_names: None,
252            env_source: Arc::new(ProcessEnvSource),
253            secret_env: Arc::new(Mutex::new(Vec::new())),
254            cargo_trusted_publishing: None,
255            #[cfg(feature = "test-helpers")]
256            log_capture: None,
257            render_strict: std::cell::Cell::new(false),
258            tree_mutations: std::collections::BTreeSet::new(),
259            literal_message: false,
260            redact_body: true,
261        };
262        ctx.refresh_secret_env();
263        ctx
264    }
265
266    /// Redact known-secret env values from outbound announce text, using the
267    /// same combined env (template engine env + process env) and the same
268    /// policy as log redaction. Always redacts; gating on `redact_body` is the
269    /// caller's responsibility (see `render_message_with_default`).
270    pub fn redact(&self, s: &str) -> String {
271        crate::redact::with_env(s, &self.env_for_redact())
272    }
273
274    /// Read an environment variable through the injected source.
275    ///
276    /// Production reads `std::env::var(name).ok()`. Tests inject a
277    /// [`MapEnvSource`](crate::MapEnvSource) via
278    /// `TestContextBuilder::env`
279    /// so deterministic branches can be exercised without mutating the
280    /// process env.
281    pub fn env_var(&self, name: &str) -> Option<String> {
282        self.env_source.var(name)
283    }
284
285    /// Replace the injected environment-variable source.
286    ///
287    /// Production migration code uses this when wrapping an
288    /// already-constructed context; tests reach this indirectly through
289    /// `TestContextBuilder::env`.
290    pub fn set_env_source<S: EnvSource + 'static>(&mut self, src: S) {
291        self.env_source = Arc::new(src);
292        self.refresh_secret_env();
293    }
294
295    /// Replace the injected environment-variable source with an already-boxed
296    /// `Arc<dyn EnvSource>`. Used to RESTORE a previously captured base source
297    /// after a temporary overlay (see
298    /// [`Context::begin_cargo_trusted_publishing`]) without re-wrapping it.
299    pub fn set_env_source_arc(&mut self, src: Arc<dyn EnvSource>) {
300        self.env_source = src;
301        self.refresh_secret_env();
302    }
303
304    /// Overlay a minted crates.io Trusted-Publishing token as
305    /// `CARGO_REGISTRY_TOKEN` for the cargo publish+rollback lifecycle.
306    ///
307    /// The current env source is captured as the base, then wrapped in a
308    /// [`LayeredEnvSource`](crate::LayeredEnvSource) that overrides
309    /// `CARGO_REGISTRY_TOKEN` with `token`. This makes the token visible to
310    /// env-driven paths that read through [`Context::env_source`] — notably
311    /// the rollback scope-availability gate — so a partial OIDC publish can
312    /// still yank, even though no ambient token exists. The token is also
313    /// retained as a marker so a later `rollback()` knows a minted token is
314    /// live and must be revoked after the yank.
315    ///
316    /// Paired with [`Context::end_cargo_trusted_publishing`], which restores
317    /// the base source and returns the token for best-effort revocation.
318    pub fn begin_cargo_trusted_publishing(&mut self, token: String) {
319        let base = self.env_source_arc();
320        self.env_source = Arc::new(crate::env_source::LayeredEnvSource::new(
321            Arc::clone(&base),
322            [("CARGO_REGISTRY_TOKEN".to_string(), token.clone())],
323        ));
324        self.cargo_trusted_publishing = Some(CargoTrustedPublishing { token, base });
325        self.refresh_secret_env();
326    }
327
328    /// The minted crates.io Trusted-Publishing token, if an overlay is active.
329    /// `rollback()` reads this to learn (i) that the yank must inject a minted
330    /// token, and (ii) that the token must be revoked once the yank completes.
331    pub fn cargo_trusted_publishing_token(&self) -> Option<&str> {
332        self.cargo_trusted_publishing
333            .as_ref()
334            .map(|s| s.token.as_str())
335    }
336
337    /// Tear down the Trusted-Publishing overlay: restore the captured base env
338    /// source, drop the marker, and return the minted token so the caller can
339    /// revoke it (best-effort). Returns `None` when no overlay is active (the
340    /// `auth: token` / ambient path never mints, so its long-lived token is
341    /// neither overlaid nor revoked).
342    pub fn end_cargo_trusted_publishing(&mut self) -> Option<String> {
343        let state = self.cargo_trusted_publishing.take()?;
344        self.env_source = state.base;
345        self.refresh_secret_env();
346        Some(state.token)
347    }
348
349    /// Borrow the injected environment-variable source as a trait
350    /// object so callers can pass it into helpers that take
351    /// `&dyn EnvSource` / `&E: EnvSource + ?Sized` without re-binding
352    /// each var through [`Context::env_var`].
353    pub fn env_source(&self) -> &dyn EnvSource {
354        self.env_source.as_ref()
355    }
356
357    /// Clone the injected environment-variable source as an `Arc` so
358    /// callers can move it into a `tokio::spawn` future or any other
359    /// `'static` closure. Production-default value is
360    /// [`ProcessEnvSource`]; tests may replace it via
361    /// [`Context::set_env_source`].
362    pub fn env_source_arc(&self) -> Arc<dyn EnvSource> {
363        Arc::clone(&self.env_source)
364    }
365
366    /// Attach an in-memory log-capture sink so every logger derived from
367    /// this context via [`Context::logger`] records to it. Intended for
368    /// tests; production callers leave this `None`.
369    ///
370    /// Gated behind the `test-helpers` Cargo feature.
371    #[cfg(feature = "test-helpers")]
372    pub fn with_log_capture(&mut self, capture: crate::log::LogCapture) {
373        self.log_capture = Some(capture);
374    }
375
376    /// Build the env-pairs list used to seed every [`StageLogger`] created
377    /// via [`Context::logger`]. Combines the template-engine env map
378    /// (config env + `.env` file values) with the injected [`EnvSource`]'s
379    /// full snapshot ([`EnvSource::vars`]), deduplicating by key
380    /// (template-engine values win because they reflect any user
381    /// overrides).
382    ///
383    /// Routes through `self.env_source` — not a raw `std::env::vars()` read
384    /// — so `TestContextBuilder::sealed_env`'s
385    /// documented "never the ambient process environment" promise also
386    /// covers log/announce redaction, not just [`Context::env_var`] point
387    /// lookups. A hermetic test that seals its env must not have an
388    /// unrelated real ambient secret-suffixed var silently mask a literal
389    /// fixture substring in the redacted output.
390    fn env_for_redact(&self) -> Vec<(String, String)> {
391        use std::collections::HashMap;
392        let mut map: HashMap<String, String> = self.env_source.vars().into_iter().collect();
393        for (k, v) in self.template_vars.all_env() {
394            map.insert(k.clone(), v.clone());
395        }
396        map.into_iter().collect()
397    }
398
399    /// Recompute [`Context::env_for_redact`] and publish it into
400    /// [`Context::secret_env`], the live cell every [`StageLogger`] produced
401    /// by [`Context::logger`] shares. Called at every `env_source` mutation
402    /// point so a logger built earlier in the run still redacts a secret
403    /// minted afterward (see the `secret_env` field doc for the concrete
404    /// crates.io Trusted-Publishing scenario this closes).
405    fn refresh_secret_env(&self) {
406        let fresh = self.env_for_redact();
407        *self.secret_env.lock().unwrap_or_else(|e| e.into_inner()) = fresh;
408    }
409}
410
411/// Set the full version-derived template var block (`Tag`, `Version`,
412/// `RawVersion`, `Base`, `Major`, `Minor`, `Patch`, `Prerelease`,
413/// `BuildMetadata`) from a parsed `semver` and the release `tag`. The single
414/// source of truth for this block, shared by [`Context::populate_git_vars`] (the
415/// context's own git version) and [`Context::render_template_for_version`] (a
416/// promotion's target version) so the two can never drift.
417fn set_version_vars(vars: &mut TemplateVars, semver: &crate::git::SemVer, tag: &str) {
418    // RawVersion: major.minor.patch only, no prerelease / build metadata.
419    let raw_version = semver.raw_version_string();
420    // Version: clean semver derived from the parsed struct (handles every
421    // tag_template prefix, e.g. monorepo `core-v0.3.2`).
422    let version = semver.version_string();
423
424    vars.set("Tag", tag);
425    vars.set("Version", &version);
426    vars.set("RawVersion", &raw_version);
427    // `Base`: the numeric base semver, captured before snapshot/nightly version
428    // templating overwrites `Version`, for schemes like
429    // `"{{ .Base }}-nightly.{{ .NightlyBuild }}+{{ .ShortCommit }}"`.
430    vars.set("Base", &raw_version);
431    vars.set("Major", &semver.major.to_string());
432    vars.set("Minor", &semver.minor.to_string());
433    vars.set("Patch", &semver.patch.to_string());
434    vars.set("Prerelease", semver.prerelease.as_deref().unwrap_or(""));
435    vars.set(
436        "BuildMetadata",
437        semver.build_metadata.as_deref().unwrap_or(""),
438    );
439}