Skip to main content

shipper_core/engine/preflight/
mod.rs

1//! Preflight pipeline: dry-run, registry probes, ownership, finishability.
2//!
3//! The public entry points (`engine::run_preflight*`) are thin wrappers that
4//! delegate into [`run`]. Phase-specific logic lives in the sibling submodules
5//! (`dry_run`, `package_check`, `duration`).
6
7use std::path::Path;
8
9use anyhow::{Result, bail};
10use chrono::Utc;
11
12use crate::engine::{Reporter, init_registry_client, policy_effects};
13use crate::git;
14use crate::ops::auth;
15use crate::plan::PlannedWorkspace;
16use crate::runtime::execution::resolve_state_dir;
17use crate::state::events;
18use crate::types::{
19    AuthType, EventType, Finishability, PreflightReport, PublishEvent, RuntimeOptions,
20};
21
22pub(in crate::engine) mod dry_run;
23pub(in crate::engine) mod duration;
24pub(in crate::engine) mod package_check;
25
26/// Run-time options that only affect preflight behavior (#100).
27///
28/// Kept separate from [`RuntimeOptions`] so that CLI-level "just this
29/// invocation" toggles (e.g. `--preflight-only`) don't need to thread
30/// through every other engine entry point. A `Default` instance
31/// preserves historical behavior: reads and appends the authoritative
32/// `events.jsonl` log.
33#[derive(Debug, Clone, Copy, Default)]
34pub struct PreflightRunOptions {
35    /// If `true`, the preflight run is session-isolated (#100 /
36    /// `shipper preflight --preflight-only`):
37    ///
38    /// - Does not touch the authoritative `events.jsonl` log; writes
39    ///   its events to a sidecar at
40    ///   `<state_dir>/preflight-only-<session>.events.jsonl`.
41    /// - Does not load or inspect any prior `events.jsonl`; the
42    ///   resulting `Finishability` is a fresh read of the current
43    ///   workspace + registry, independent of any accumulated publish
44    ///   or resume state.
45    /// - Never writes `state.json`.
46    ///
47    /// `false` (the default) preserves the original behavior: the
48    /// authoritative append-only `events.jsonl` is extended.
49    pub fresh_audit: bool,
50}
51
52pub(in crate::engine) fn run(
53    ws: &mut PlannedWorkspace,
54    opts: &RuntimeOptions,
55    reporter: &mut dyn Reporter,
56    run_opts: PreflightRunOptions,
57) -> Result<PreflightReport> {
58    let workspace_root = &ws.workspace_root;
59    let effects = policy_effects(opts);
60    let state_dir = resolve_state_dir(workspace_root, &opts.state_dir);
61
62    let events_path = resolve_events_path(&state_dir, run_opts);
63
64    let mut event_log = events::EventLog::new();
65
66    event_log.record(PublishEvent {
67        timestamp: Utc::now(),
68        event_type: EventType::PreflightStarted,
69        package: "all".to_string(),
70    });
71    flush_events(&event_log, &events_path)?;
72    event_log.clear();
73
74    if !opts.allow_dirty {
75        reporter.info("checking git cleanliness...");
76        git::ensure_git_clean(workspace_root)?;
77    }
78
79    reporter.info("initializing registry client...");
80    let reg = init_registry_client(ws.plan.registry.clone(), &state_dir)?;
81
82    let token = auth::resolve_token(&ws.plan.registry.name)?;
83    let token_detected = token.as_ref().map(|s| !s.is_empty()).unwrap_or(false);
84    let auth_type = auth::detect_auth_type_from_token(token.as_deref());
85    warn_if_token_auth_overrides_oidc(&ws.plan.registry.name, &auth_type, reporter);
86
87    if effects.strict_ownership && !token_detected {
88        event_log.record(PublishEvent {
89            timestamp: Utc::now(),
90            event_type: EventType::PreflightComplete {
91                finishability: Finishability::Failed,
92            },
93            package: "all".to_string(),
94        });
95        flush_events(&event_log, &events_path)?;
96        bail!(
97            "strict ownership requested but no token found (set CARGO_REGISTRY_TOKEN or run cargo login)"
98        );
99    }
100
101    let dry_run_outcome = dry_run::execute(ws, opts, &effects, &state_dir, reporter);
102
103    event_log.record(PublishEvent {
104        timestamp: Utc::now(),
105        event_type: EventType::PreflightWorkspaceVerify {
106            passed: dry_run_outcome.workspace_passed,
107            output: dry_run_outcome.workspace_output.clone(),
108        },
109        package: "all".to_string(),
110    });
111
112    let check_outcome = package_check::check_packages(
113        ws,
114        opts,
115        &effects,
116        &reg,
117        token.as_deref(),
118        token_detected,
119        &auth_type,
120        &dry_run_outcome,
121        &mut event_log,
122        reporter,
123    )?;
124
125    let all_dry_run_passed = check_outcome.packages.iter().all(|p| p.dry_run_passed);
126    let finishability = if !all_dry_run_passed {
127        Finishability::Failed
128    } else if check_outcome.any_ownership_unverified {
129        Finishability::NotProven
130    } else {
131        Finishability::Proven
132    };
133
134    event_log.record(PublishEvent {
135        timestamp: Utc::now(),
136        event_type: EventType::PreflightComplete {
137            finishability: finishability.clone(),
138        },
139        package: "all".to_string(),
140    });
141    flush_events(&event_log, &events_path)?;
142
143    let estimated_publish_duration =
144        duration::estimate_preflight_duration(&ws.plan.registry.name, &check_outcome.packages);
145
146    Ok(PreflightReport {
147        plan_id: ws.plan.plan_id.clone(),
148        token_detected,
149        finishability,
150        packages: check_outcome.packages,
151        timestamp: Utc::now(),
152        estimated_publish_duration,
153        dry_run_output: if opts.verify_mode == crate::types::VerifyMode::Workspace {
154            Some(dry_run_outcome.workspace_output)
155        } else {
156            None
157        },
158    })
159}
160
161fn warn_if_token_auth_overrides_oidc(
162    registry_name: &str,
163    auth_type: &Option<AuthType>,
164    reporter: &mut dyn Reporter,
165) {
166    let default_registry = matches!(
167        registry_name,
168        "" | auth::CRATES_IO_REGISTRY | "crates.io" | "crates_io"
169    );
170    if !default_registry || auth_type != &Some(AuthType::Token) {
171        return;
172    }
173
174    let oidc_url_present = std::env::var_os("ACTIONS_ID_TOKEN_REQUEST_URL").is_some();
175    let oidc_token_present = std::env::var_os("ACTIONS_ID_TOKEN_REQUEST_TOKEN").is_some();
176    if oidc_url_present || oidc_token_present {
177        reporter.warn(
178            "Trusted Publishing OIDC environment is present, but Shipper is using Cargo token auth. \
179             This is allowed as fallback; prefer the short-lived token minted by rust-lang/crates-io-auth-action@v1 for release runs.",
180        );
181    }
182}
183
184/// Resolve the event sink. In `fresh_audit` mode we never touch the
185/// authoritative `events.jsonl`; events land in a session-scoped sidecar
186/// instead. See [`PreflightRunOptions::fresh_audit`].
187fn resolve_events_path(state_dir: &Path, run_opts: PreflightRunOptions) -> std::path::PathBuf {
188    if run_opts.fresh_audit {
189        let session_id = format!(
190            "{}-pid{}",
191            Utc::now().format("%Y%m%dT%H%M%S%fZ"),
192            std::process::id()
193        );
194        events::preflight_only_events_path(state_dir, &session_id)
195    } else {
196        events::events_path(state_dir)
197    }
198}
199
200fn flush_events(log: &events::EventLog, path: &Path) -> Result<()> {
201    log.write_to_file(path)
202}