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}