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    /// The version each crate would release from this tree, keyed by crate
74    /// name, when that differs from what the tree records. Empty on a run
75    /// whose HEAD carries the release tag: the manifests and the crate tags
76    /// already name the version. The standalone preflight fills it on an
77    /// untagged HEAD from the plan `anodizer tag` would cut, so a publisher
78    /// that reads a crate's version from its manifest or its latest tag
79    /// probes the version the release will carry after the tag writeback.
80    pub planned_crate_versions: std::collections::HashMap<String, String>,
81    /// Verify-release verdict, set by `VerifyReleaseStage::run` immediately
82    /// before it returns (clean pass OR `bail!`). `None` until the gate runs
83    /// its checks — it stays `None` on the disabled / skipped / dry-run /
84    /// snapshot early-returns, where no published release exists to verify.
85    ///
86    /// Read by the run-summary builder so the end-of-pipeline Summary states
87    /// the verify-release outcome on a SEPARATE axis from the publisher rows:
88    /// the gate runs after the irreversible publish, so the publishes still
89    /// read `succeeded` while this slot records whether the published release
90    /// has unverified defects to investigate.
91    pub verify_release: Option<VerifyReleaseSummary>,
92    /// Pre-submitter verify-release gate, installed once by the CLI's
93    /// pipeline-composition layer right after construction (never by a
94    /// stage). Invoked by `stage-publish`'s dispatcher immediately before
95    /// the first Submitter-group (one-way-door) publisher would run:
96    /// `Ok(true)` clears the gate, `Ok(false)` or `Err` blocks every
97    /// Submitter-group publisher for the run with
98    /// `SkipReason::VerifyGateBlocked`.
99    ///
100    /// A plain closure field, not a stage, because `stage-publish` cannot
101    /// depend on `stage-verify-release` (the dependency runs the other way:
102    /// `stage-verify-release` already depends on `stage-publish` for its
103    /// terminal landing checks) — routing the call through `Context`, which
104    /// every stage crate depends on, avoids the cycle without inventing a
105    /// second verify taxonomy. `Arc` (not a bare `Box`) so the field can be
106    /// cheaply cloned out of `&mut Context` before being invoked with
107    /// `&mut Context`.
108    pub verify_gate: Option<VerifyGate>,
109    /// SOURCE_DATE_EPOCH seed + non-determinism allow-list state for the
110    /// run. `None` until a stage (typically `BuildStage`) seeds it from
111    /// `resolve_reproducible_epoch(commit_timestamp)`; downstream stages
112    /// (`stage-sbom`, `stage-archive`, `stage-sign`) read `sde` to derive
113    /// deterministic timestamps. Lazy-init by design: tests and snapshot
114    /// runs without a clean commit can still proceed.
115    pub determinism: Option<crate::DeterminismState>,
116    /// Per-publisher outcome override published by `Publisher::run` when
117    /// the artifact reached a non-`Succeeded` terminal state but `run`
118    /// still returned `Ok` (e.g. chocolatey moderation skip,
119    /// winget/krew/homebrew PR-already-exists skip). Dispatch consumes
120    /// this slot via `take_pending_outcome()` immediately after `run`
121    /// returns Ok so the per-publisher row in the summary table reads
122    /// `pending-moderation` / `pending-validation` instead of
123    /// `succeeded`. The slot is single-shot: any unread value is
124    /// cleared at the start of every `run` call.
125    pub pending_outcome: Option<crate::PublisherOutcome>,
126    /// Partial `PublishEvidence` published by `Publisher::run` BEFORE it
127    /// returned `Err`, so a publisher that did irreversible work for the
128    /// first N items and then failed on item N+1 can still hand the
129    /// rollback path the authoritative record of what actually went live.
130    ///
131    /// The cargo publisher is the motivating case: a multi-crate publish
132    /// that succeeds on crate A then fails on crate B must yank A. On the
133    /// `Ok` path `run` returns its evidence directly; on the `Err` path
134    /// dispatch consumes this slot via [`Context::take_pending_evidence`]
135    /// and records it on the failed publisher's report row so rollback has
136    /// something to act on. Single-shot — the slot is drained at the start of every
137    /// `run` and cleared on the `Ok` path.
138    pub pending_evidence: Option<crate::PublishEvidence>,
139    /// Distinct set of crate names the build stage actually built — i.e.
140    /// those that had at least one in-scope build (or `copy_from`) job after
141    /// target resolution. `None` until `BuildStage` runs (e.g. merge mode,
142    /// which pre-loads artifacts and never invokes the build stage).
143    ///
144    /// Read by the binary-artifact guard to distinguish "configured a
145    /// binary-requiring surface but legitimately had no in-scope target in
146    /// this shard" (skip) from "was built yet produced no binary" (a real
147    /// mis-scope to fail on). Populated via [`Context::set_built_crate_names`]
148    /// and read via [`Context::built_crate_names`].
149    built_crate_names: Option<std::collections::HashSet<String>>,
150    /// Injectable environment-variable source. Defaults to
151    /// [`ProcessEnvSource`] (reads `std::env::var`). Tests inject a
152    /// [`MapEnvSource`](crate::MapEnvSource) via
153    /// `TestContextBuilder::env`
154    /// so deterministic branches can be exercised without mutating the
155    /// process env. Read through [`Context::env_var`]; replace via
156    /// [`Context::set_env_source`].
157    env_source: Arc<dyn EnvSource>,
158    /// Live handle to the secret-redaction table shared with every
159    /// [`StageLogger`] this context has ever produced via [`Context::logger`]
160    /// (each gets a clone of the same `Arc<Mutex<_>>` cell). Refreshed from
161    /// [`Context::env_for_redact`] by [`Context::refresh_secret_env`] at every
162    /// `env_source` mutation point (`set_env_source`, `set_env_source_arc`,
163    /// `begin_cargo_trusted_publishing`, `end_cargo_trusted_publishing`), so a
164    /// logger constructed BEFORE a mid-run credential request — e.g. crates.io
165    /// Trusted Publishing creating `CARGO_REGISTRY_TOKEN` into `env_source`
166    /// partway through `publish_to_cargo` — still redacts it: `StageLogger::
167    /// redact` reads this cell live rather than a frozen construction-time
168    /// snapshot.
169    secret_env: crate::log::RedactionEnv,
170    /// Live crates.io Trusted-Publishing overlay state, set for the duration
171    /// of a cargo publish that issued a short-lived token via OIDC. Holds the
172    /// issued token (the revoke + yank-injection credential) and the base env
173    /// source captured before the overlay was installed (restored on
174    /// teardown). `None` on the ambient `auth: token` path and outside a
175    /// token request. Managed exclusively through
176    /// [`Context::begin_cargo_trusted_publishing`] /
177    /// [`Context::end_cargo_trusted_publishing`].
178    cargo_trusted_publishing: Option<CargoTrustedPublishing>,
179    /// Optional in-memory log-capture handle. When `Some`, every logger
180    /// produced by [`Context::logger`] attaches it so the test can read
181    /// back aggregated counts of `status` / `warn` / etc. calls without
182    /// having to intercept stderr.
183    ///
184    /// Gated behind the `test-helpers` Cargo feature — production
185    /// binaries do not carry the field at all.
186    #[cfg(feature = "test-helpers")]
187    pub log_capture: Option<crate::log::LogCapture>,
188    /// The builder-owned temp directory behind a test context's `dist`,
189    /// removed when the context drops.
190    ///
191    /// Set only by `TestContextBuilder` when the test named no explicit dist;
192    /// an explicit `dist` leaves this `None` and stays the caller's own
193    /// directory to manage.
194    ///
195    /// Gated behind the `test-helpers` Cargo feature — production
196    /// binaries do not carry the field at all.
197    #[cfg(feature = "test-helpers")]
198    pub private_dist: Option<tempfile::TempDir>,
199    /// Runtime-togglable strict-render flag, distinct from the user's global
200    /// `--strict` (`options.strict`). The pre-publish guard flips this on for
201    /// the duration of its in-memory render pass (via [`Context::set_render_strict`])
202    /// so EVERY publisher/announce template it renders propagates its error
203    /// instead of falling back to the raw string — turning a swallowed
204    /// broken-template warning into a release-blocking abort BEFORE any
205    /// irreversible publisher fires. Production publish leaves it `false`, so
206    /// dry-run / snapshot / nightly stay lenient (warn + raw fallback).
207    ///
208    /// A `Cell` (not a plain `bool`) because the render path holds only a
209    /// shared `&Context`: the guard sets it through its `&mut Context`, then
210    /// the deep render helpers toggle-read it through `&Context`.
211    /// [`Context::render_is_strict`] ORs this with `is_strict()`, so the user's
212    /// global `--strict` also makes every render strict everywhere.
213    render_strict: std::cell::Cell<bool>,
214    /// When true, announce message BODIES are treated as already-final text and
215    /// are NOT run through Tera at send time. Set by `anodizer notify` so an
216    /// operator-supplied (possibly untrusted) message — e.g. an on_error error
217    /// string — cannot expand an `Env`-reference into a secret when the
218    /// provider sends it. Only message bodies are affected; titles and other
219    /// templated fields still render normally.
220    pub literal_message: bool,
221    /// Repo-relative (`/`-separated) paths anodizer itself wrote into the
222    /// working tree during this run — e.g. the `[package.metadata.binstall]`
223    /// table the cargo publisher emits into a crate's `Cargo.toml` right
224    /// before `cargo publish`. Dirt on exactly these paths is the tool's own
225    /// expected residue, not operator-authored drift: the clean-tree publish
226    /// guard exempts them so a mutation made for crate A cannot false-trip
227    /// the guard when crate B publishes later in the same run (per-crate
228    /// `--publish-only` iterates the whole publish pipeline once per crate
229    /// against one persistent context). Recorded via
230    /// [`Context::record_tree_mutation`].
231    tree_mutations: std::collections::BTreeSet<String>,
232    /// When true (the default), outbound announce message BODIES have
233    /// known-secret env values masked before send (same policy as log
234    /// redaction). `anodizer notify --allow-secrets` sets this false to send a
235    /// secret deliberately over a trusted channel. Only the outbound body is
236    /// affected; anodizer's own logs are redacted unconditionally regardless of
237    /// this flag.
238    pub redact_body: bool,
239}
240
241/// Live crates.io Trusted-Publishing overlay state (see the `Context`
242/// `cargo_trusted_publishing` field). The `base` is the env source captured
243/// before the token overlay was installed, restored verbatim on teardown.
244struct CargoTrustedPublishing {
245    token: String,
246    base: Arc<dyn EnvSource>,
247}
248
249impl Context {
250    pub fn new(config: Config, options: ContextOptions) -> Self {
251        let mut vars = TemplateVars::new();
252        vars.set("ProjectName", &config.project_name);
253        let ctx = Self {
254            config,
255            artifacts: ArtifactRegistry::new(),
256            options,
257            stage_outputs: StageOutputs::default(),
258            template_vars: vars,
259            git_info: None,
260            token_type: ScmTokenType::GitHub,
261            skip_memento: crate::pipe_skip::SkipMemento::new(),
262            emission_skips: crate::pipe_skip::SkipMemento::new(),
263            publish_report: None,
264            publish_attempted: false,
265            planned_crate_versions: std::collections::HashMap::new(),
266            verify_release: None,
267            verify_gate: None,
268            determinism: None,
269            pending_outcome: None,
270            pending_evidence: None,
271            built_crate_names: None,
272            env_source: Arc::new(ProcessEnvSource),
273            secret_env: Arc::new(Mutex::new(Vec::new())),
274            cargo_trusted_publishing: None,
275            #[cfg(feature = "test-helpers")]
276            log_capture: None,
277            #[cfg(feature = "test-helpers")]
278            private_dist: None,
279            render_strict: std::cell::Cell::new(false),
280            tree_mutations: std::collections::BTreeSet::new(),
281            literal_message: false,
282            redact_body: true,
283        };
284        ctx.refresh_secret_env();
285        ctx
286    }
287
288    /// Redact known-secret env values from outbound announce text, using the
289    /// same combined env (template engine env + process env) and the same
290    /// policy as log redaction. Always redacts; gating on `redact_body` is the
291    /// caller's responsibility (see `render_message_with_default`).
292    pub fn redact(&self, s: &str) -> String {
293        crate::redact::with_env(s, &self.env_for_redact())
294    }
295
296    /// Read an environment variable through the injected source.
297    ///
298    /// Production reads `std::env::var(name).ok()`. Tests inject a
299    /// [`MapEnvSource`](crate::MapEnvSource) via
300    /// `TestContextBuilder::env`
301    /// so deterministic branches can be exercised without mutating the
302    /// process env.
303    pub fn env_var(&self, name: &str) -> Option<String> {
304        self.env_source.var(name)
305    }
306
307    /// True inside the hermetic determinism-harness rebuild, which exports
308    /// `ANODIZER_IN_DETERMINISM_HARNESS=1` to every child it spawns. The one
309    /// spelling of that question: the `IsHarness` template variable, the
310    /// sign stage's ephemeral-key and offline-cosign paths and the build
311    /// stage's intermediate pruning all read it here.
312    pub fn in_determinism_harness(&self) -> bool {
313        self.env_var("ANODIZER_IN_DETERMINISM_HARNESS").is_some()
314    }
315
316    /// Replace the injected environment-variable source.
317    ///
318    /// Production migration code uses this when wrapping an
319    /// already-constructed context; tests reach this indirectly through
320    /// `TestContextBuilder::env`.
321    pub fn set_env_source<S: EnvSource + 'static>(&mut self, src: S) {
322        self.env_source = Arc::new(src);
323        self.refresh_secret_env();
324    }
325
326    /// Replace the injected environment-variable source with an already-boxed
327    /// `Arc<dyn EnvSource>`. Used to RESTORE a previously captured base source
328    /// after a temporary overlay (see
329    /// [`Context::begin_cargo_trusted_publishing`]) without re-wrapping it.
330    pub fn set_env_source_arc(&mut self, src: Arc<dyn EnvSource>) {
331        self.env_source = src;
332        self.refresh_secret_env();
333    }
334
335    /// Overlay an issued crates.io Trusted-Publishing token as
336    /// `CARGO_REGISTRY_TOKEN` for the cargo publish+rollback lifecycle.
337    ///
338    /// The current env source is captured as the base, then wrapped in a
339    /// [`LayeredEnvSource`](crate::LayeredEnvSource) that overrides
340    /// `CARGO_REGISTRY_TOKEN` with `token`. This makes the token visible to
341    /// env-driven paths that read through [`Context::env_source`] — notably
342    /// the rollback scope-availability gate — so a partial OIDC publish can
343    /// still yank, even though no ambient token exists. The token is also
344    /// retained as a marker so a later `rollback()` knows an issued token is
345    /// live and must be revoked after the yank.
346    ///
347    /// Paired with [`Context::end_cargo_trusted_publishing`], which restores
348    /// the base source and returns the token for best-effort revocation.
349    pub fn begin_cargo_trusted_publishing(&mut self, token: String) {
350        let base = self.env_source_arc();
351        self.env_source = Arc::new(crate::env_source::LayeredEnvSource::new(
352            Arc::clone(&base),
353            [("CARGO_REGISTRY_TOKEN".to_string(), token.clone())],
354        ));
355        self.cargo_trusted_publishing = Some(CargoTrustedPublishing { token, base });
356        self.refresh_secret_env();
357    }
358
359    /// The issued crates.io Trusted-Publishing token, if an overlay is active.
360    /// `rollback()` reads this to learn (i) that the yank must inject an issued
361    /// token, and (ii) that the token must be revoked once the yank completes.
362    pub fn cargo_trusted_publishing_token(&self) -> Option<&str> {
363        self.cargo_trusted_publishing
364            .as_ref()
365            .map(|s| s.token.as_str())
366    }
367
368    /// Tear down the Trusted-Publishing overlay: restore the captured base env
369    /// source, drop the marker, and return the issued token so the caller can
370    /// revoke it (best-effort). Returns `None` when no overlay is active (the
371    /// `auth: token` / ambient path never requests one, so its long-lived token is
372    /// neither overlaid nor revoked).
373    pub fn end_cargo_trusted_publishing(&mut self) -> Option<String> {
374        let state = self.cargo_trusted_publishing.take()?;
375        self.env_source = state.base;
376        self.refresh_secret_env();
377        Some(state.token)
378    }
379
380    /// Borrow the injected environment-variable source as a trait
381    /// object so callers can pass it into helpers that take
382    /// `&dyn EnvSource` / `&E: EnvSource + ?Sized` without re-binding
383    /// each var through [`Context::env_var`].
384    pub fn env_source(&self) -> &dyn EnvSource {
385        self.env_source.as_ref()
386    }
387
388    /// Clone the injected environment-variable source as an `Arc` so
389    /// callers can move it into a `tokio::spawn` future or any other
390    /// `'static` closure. Production-default value is
391    /// [`ProcessEnvSource`]; tests may replace it via
392    /// [`Context::set_env_source`].
393    pub fn env_source_arc(&self) -> Arc<dyn EnvSource> {
394        Arc::clone(&self.env_source)
395    }
396
397    /// Attach an in-memory log-capture sink so every logger derived from
398    /// this context via [`Context::logger`] records to it. Intended for
399    /// tests; production callers leave this `None`.
400    ///
401    /// Gated behind the `test-helpers` Cargo feature.
402    #[cfg(feature = "test-helpers")]
403    pub fn with_log_capture(&mut self, capture: crate::log::LogCapture) {
404        self.log_capture = Some(capture);
405    }
406
407    /// Build the env-pairs list used to seed every [`StageLogger`] created
408    /// via [`Context::logger`]. Combines the template-engine env map
409    /// (config env + `.env` file values) with the injected [`EnvSource`]'s
410    /// full snapshot ([`EnvSource::vars`]), deduplicating by key
411    /// (template-engine values win because they reflect any user
412    /// overrides).
413    ///
414    /// Routes through `self.env_source` — not a raw `std::env::vars()` read
415    /// — so `TestContextBuilder::sealed_env`'s
416    /// documented "never the ambient process environment" promise also
417    /// covers log/announce redaction, not just [`Context::env_var`] point
418    /// lookups. A hermetic test that seals its env must not have an
419    /// unrelated real ambient secret-suffixed var silently mask a literal
420    /// fixture substring in the redacted output.
421    fn env_for_redact(&self) -> Vec<(String, String)> {
422        use std::collections::HashMap;
423        let mut map: HashMap<String, String> = self.env_source.vars().into_iter().collect();
424        for (k, v) in self.template_vars.all_env() {
425            map.insert(k.clone(), v.clone());
426        }
427        map.into_iter().collect()
428    }
429
430    /// Recompute [`Context::env_for_redact`] and publish it into
431    /// [`Context::secret_env`], the live cell every [`StageLogger`] produced
432    /// by [`Context::logger`] shares. Called at every `env_source` mutation
433    /// point so a logger built earlier in the run still redacts a secret
434    /// issued afterward (see the `secret_env` field doc for the concrete
435    /// crates.io Trusted-Publishing scenario this closes).
436    fn refresh_secret_env(&self) {
437        let fresh = self.env_for_redact();
438        *self.secret_env.lock().unwrap_or_else(|e| e.into_inner()) = fresh;
439    }
440}
441
442/// Set the full version-derived template var block (`Tag`, `Version`,
443/// `RawVersion`, `Base`, `Major`, `Minor`, `Patch`, `Prerelease`,
444/// `BuildMetadata`) from a parsed `semver` and the release `tag`. The single
445/// source of truth for this block, shared by [`Context::populate_git_vars`] (the
446/// context's own git version) and [`Context::render_template_for_version`] (a
447/// promotion's target version) so the two can never drift.
448fn set_version_vars(vars: &mut TemplateVars, semver: &crate::git::SemVer, tag: &str) {
449    // RawVersion: major.minor.patch only, no prerelease / build metadata.
450    let raw_version = semver.raw_version_string();
451    // Version: clean semver derived from the parsed struct (handles every
452    // tag_template prefix, e.g. monorepo `core-v0.3.2`).
453    let version = semver.version_string();
454
455    vars.set("Tag", tag);
456    vars.set("Version", &version);
457    vars.set("RawVersion", &raw_version);
458    // `Base`: the numeric base semver, captured before snapshot/nightly version
459    // templating overwrites `Version`, for schemes like
460    // `"{{ .Base }}-nightly.{{ .NightlyBuild }}+{{ .ShortCommit }}"`.
461    vars.set("Base", &raw_version);
462    vars.set("Major", &semver.major.to_string());
463    vars.set("Minor", &semver.minor.to_string());
464    vars.set("Patch", &semver.patch.to_string());
465    vars.set("Prerelease", semver.prerelease.as_deref().unwrap_or(""));
466    vars.set(
467        "BuildMetadata",
468        semver.build_metadata.as_deref().unwrap_or(""),
469    );
470}