shipper_core/engine/preflight/
mod.rs1use 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#[derive(Debug, Clone, Copy, Default)]
34pub struct PreflightRunOptions {
35 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 ®,
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
184fn 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}