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}