1use std::collections::BTreeMap;
2use std::path::Path;
3use std::thread;
4use std::time::{Duration, Instant};
5
6use anyhow::{Context, Result, bail};
7use chrono::Utc;
8
9use crate::cargo;
10use crate::git;
11use crate::lock;
12use crate::ops::auth;
13use crate::plan::PlannedWorkspace;
14use crate::registry::RegistryClient;
15use crate::runtime::environment;
16use crate::runtime::execution::{
17 backoff_delay, classify_cargo_failure, pkg_key, registry_aware_backoff, resolve_state_dir,
18 short_state, update_state,
19};
20use crate::state::events;
21use crate::state::execution_state as state;
22use crate::types::{
23 AttemptEvidence, ErrorClass, EventType, ExecutionResult, ExecutionState, Finishability,
24 PackageProgress, PackageReceipt, PackageState, PreflightPackage, PreflightReport, PublishEvent,
25 ReadinessEvidence, Receipt, ReconciliationOutcome, Registry, RuntimeOptions,
26};
27use crate::webhook::{self, WebhookEvent};
28
29pub trait Reporter {
30 fn info(&mut self, msg: &str);
31 fn warn(&mut self, msg: &str);
32 fn error(&mut self, msg: &str);
33}
34
35pub(crate) fn policy_effects(opts: &RuntimeOptions) -> crate::runtime::policy::PolicyEffects {
36 crate::runtime::policy::policy_effects(opts)
37}
38
39fn init_registry_client(registry: Registry, state_dir: &Path) -> Result<RegistryClient> {
71 let cache_dir = state_dir.join("cache");
72 RegistryClient::new(registry).map(|c| c.with_cache_dir(cache_dir))
73}
74
75pub fn run_preflight(
76 ws: &PlannedWorkspace,
77 opts: &RuntimeOptions,
78 reporter: &mut dyn Reporter,
79) -> Result<PreflightReport> {
80 let workspace_root = &ws.workspace_root;
81 let effects = policy_effects(opts);
82 let state_dir = resolve_state_dir(workspace_root, &opts.state_dir);
83 let events_path = events::events_path(&state_dir);
84 let mut event_log = events::EventLog::new();
85
86 event_log.record(PublishEvent {
87 timestamp: Utc::now(),
88 event_type: EventType::PreflightStarted,
89 package: "all".to_string(),
90 });
91 event_log.write_to_file(&events_path)?;
92 event_log.clear();
93
94 if !opts.allow_dirty {
95 reporter.info("checking git cleanliness...");
96 git::ensure_git_clean(workspace_root)?;
97 }
98
99 reporter.info("initializing registry client...");
100 let reg = init_registry_client(ws.plan.registry.clone(), &state_dir)?;
101
102 let token = auth::resolve_token(&ws.plan.registry.name)?;
103 let token_detected = token.as_ref().map(|s| !s.is_empty()).unwrap_or(false);
104 let auth_type = auth::detect_auth_type_from_token(token.as_deref());
105
106 if effects.strict_ownership && !token_detected {
107 event_log.record(PublishEvent {
108 timestamp: Utc::now(),
109 event_type: EventType::PreflightComplete {
110 finishability: Finishability::Failed,
111 },
112 package: "all".to_string(),
113 });
114 event_log.write_to_file(&events_path)?;
115 bail!(
116 "strict ownership requested but no token found (set CARGO_REGISTRY_TOKEN or run cargo login)"
117 );
118 }
119
120 use crate::types::VerifyMode;
122
123 let (workspace_dry_run_passed, workspace_dry_run_output) = if effects.run_dry_run
135 && opts.verify_mode == VerifyMode::Workspace
136 {
137 reporter.info("running workspace dry-run verification...");
138 let dry_run_result = cargo::cargo_publish_dry_run_workspace(
139 workspace_root,
140 &ws.plan.registry.name,
141 opts.allow_dirty,
142 opts.output_lines,
143 );
144 match &dry_run_result {
145 Ok(output) => {
146 let passed = output.exit_code == 0;
147 let full_stripped = format!(
148 "workspace dry-run: exit_code={}\n\n--- stdout ---\n{}\n\n--- stderr ---\n{}\n",
149 output.exit_code,
150 shipper_output_sanitizer::strip_ansi(&output.stdout_tail),
151 shipper_output_sanitizer::strip_ansi(&output.stderr_tail),
152 );
153 let sidecar_path = state_dir.join("preflight_workspace_verify.txt");
154 if let Some(parent) = sidecar_path.parent() {
155 let _ = std::fs::create_dir_all(parent);
156 }
157 if let Err(e) = std::fs::write(&sidecar_path, &full_stripped) {
158 reporter.warn(&format!(
159 "failed to write preflight workspace-verify sidecar at {}: {e}",
160 sidecar_path.display()
161 ));
162 }
163 let tail_summary = shipper_output_sanitizer::tail_lines(
170 &shipper_output_sanitizer::strip_ansi(&output.stderr_tail),
171 6,
172 );
173 let summary = format!(
174 "workspace dry-run: exit_code={}; sidecar={}; stderr_tail_summary={:?}",
175 output.exit_code,
176 sidecar_path.display(),
177 tail_summary
178 );
179 (passed, summary)
180 }
181 Err(err) => (false, format!("workspace dry-run failed: {err:#}")),
182 }
183 } else if !effects.run_dry_run || opts.verify_mode == VerifyMode::None {
184 reporter.info("skipping dry-run (policy, --no-verify, or verify_mode=none)");
185 (
186 true,
187 "workspace dry-run skipped (policy, --no-verify, or verify_mode=none)".to_string(),
188 )
189 } else {
190 (
192 true,
193 "workspace dry-run skipped (verify_mode=package)".to_string(),
194 )
195 };
196
197 event_log.record(PublishEvent {
198 timestamp: Utc::now(),
199 event_type: EventType::PreflightWorkspaceVerify {
200 passed: workspace_dry_run_passed,
201 output: workspace_dry_run_output.clone(),
202 },
203 package: "all".to_string(),
204 });
205
206 let per_package_dry_run: std::collections::BTreeMap<String, (bool, Option<String>)> =
208 if effects.run_dry_run && opts.verify_mode == VerifyMode::Package {
209 reporter.info("running per-package dry-run verification...");
210 let mut results = std::collections::BTreeMap::new();
211 for p in &ws.plan.packages {
212 let result = cargo::cargo_publish_dry_run_package(
213 workspace_root,
214 &p.name,
215 &ws.plan.registry.name,
216 opts.allow_dirty,
217 opts.output_lines,
218 );
219 let (passed, output) = match &result {
220 Ok(out) => (
221 out.exit_code == 0,
222 Some(format!(
223 "exit_code={}; stdout_tail={:?}; stderr_tail={:?}",
224 out.exit_code, out.stdout_tail, out.stderr_tail
225 )),
226 ),
227 Err(e) => (false, Some(format!("dry-run failed: {e:#}"))),
228 };
229 if !passed {
230 reporter.warn(&format!("{}@{}: dry-run failed", p.name, p.version));
231 }
232 results.insert(p.name.clone(), (passed, output));
233 }
234 results
235 } else {
236 std::collections::BTreeMap::new()
237 };
238
239 reporter.info("checking packages against registry...");
241 let mut packages: Vec<PreflightPackage> = Vec::new();
242 let mut any_ownership_unverified = false;
243
244 for p in &ws.plan.packages {
245 let already_published = reg.version_exists(&p.name, &p.version)?;
246 let is_new_crate = reg.check_new_crate(&p.name)?;
247 if is_new_crate {
248 event_log.record(PublishEvent {
249 timestamp: Utc::now(),
250 event_type: EventType::PreflightNewCrateDetected {
251 crate_name: p.name.clone(),
252 },
253 package: format!("{}@{}", p.name, p.version),
254 });
255 }
256
257 let (dry_run_passed, dry_run_output) = if opts.verify_mode == VerifyMode::Package {
259 per_package_dry_run
260 .get(&p.name)
261 .cloned()
262 .unwrap_or((true, None))
263 } else {
264 (
265 workspace_dry_run_passed,
266 Some(workspace_dry_run_output.clone()),
267 )
268 };
269
270 let ownership_verified = if token_detected && effects.check_ownership {
272 if effects.strict_ownership {
273 if is_new_crate {
274 reporter.info(&format!("{}: new crate, skipping ownership check", p.name));
276 false
277 } else {
278 reg.list_owners(&p.name, token.as_deref().unwrap())?;
280 true
281 }
282 } else {
283 let result = reg
284 .verify_ownership(&p.name, token.as_deref().unwrap())
285 .unwrap_or_default();
286 if !result {
287 reporter.warn(&format!(
288 "owners preflight failed for {}; continuing (non-strict mode)",
289 p.name
290 ));
291 }
292 result
293 }
294 } else {
295 false
297 };
298
299 event_log.record(PublishEvent {
300 timestamp: Utc::now(),
301 event_type: EventType::PreflightOwnershipCheck {
302 crate_name: p.name.clone(),
303 verified: ownership_verified,
304 },
305 package: format!("{}@{}", p.name, p.version),
306 });
307
308 if !ownership_verified {
309 any_ownership_unverified = true;
310 }
311
312 packages.push(PreflightPackage {
313 name: p.name.clone(),
314 version: p.version.clone(),
315 already_published,
316 is_new_crate,
317 auth_type: auth_type.clone(),
318 ownership_verified,
319 dry_run_passed,
320 dry_run_output,
321 });
322 }
323
324 let all_dry_run_passed = packages.iter().all(|p| p.dry_run_passed);
326
327 let finishability = if !all_dry_run_passed {
329 Finishability::Failed
330 } else if any_ownership_unverified {
331 Finishability::NotProven
332 } else {
333 Finishability::Proven
334 };
335
336 event_log.record(PublishEvent {
337 timestamp: Utc::now(),
338 event_type: EventType::PreflightComplete {
339 finishability: finishability.clone(),
340 },
341 package: "all".to_string(),
342 });
343 event_log.write_to_file(&events_path)?;
344
345 Ok(PreflightReport {
346 plan_id: ws.plan.plan_id.clone(),
347 token_detected,
348 finishability,
349 packages,
350 timestamp: Utc::now(),
351 dry_run_output: if opts.verify_mode == VerifyMode::Workspace {
352 Some(workspace_dry_run_output)
353 } else {
354 None
355 },
356 })
357}
358
359fn enforce_rehearsal_gate(
385 ws: &PlannedWorkspace,
386 opts: &RuntimeOptions,
387 state_dir: &Path,
388 reporter: &mut dyn Reporter,
389) -> Result<()> {
390 let Some(rehearsal_name) = opts.rehearsal_registry.as_deref() else {
391 return Ok(());
392 };
393
394 if opts.rehearsal_skip {
395 reporter.warn(&format!(
396 "--skip-rehearsal was set; publish is proceeding without a rehearsal against '{rehearsal_name}'. \
397 This is an operator-authorized bypass; auditors reading events.jsonl will see no RehearsalComplete event for this plan_id."
398 ));
399 return Ok(());
400 }
401
402 let receipt = crate::state::rehearsal::load_rehearsal(state_dir)
403 .context("failed to read rehearsal receipt while enforcing hard gate")?;
404
405 let rehearsal_path = crate::state::rehearsal::rehearsal_path(state_dir);
406
407 let receipt = match receipt {
408 Some(r) => r,
409 None => bail!(
410 "rehearsal is required (rehearsal registry '{rehearsal_name}' is configured) but no rehearsal receipt was found at {}. \
411 Run `shipper rehearse --rehearsal-registry {rehearsal_name}` first, \
412 or pass --skip-rehearsal to override (not recommended).",
413 rehearsal_path.display()
414 ),
415 };
416
417 if receipt.plan_id != ws.plan.plan_id {
418 bail!(
419 "rehearsal receipt is stale: rehearsal ran for plan_id {} but the current plan_id is {}. \
420 The workspace changed between rehearse and publish; re-run `shipper rehearse` against the current plan.",
421 receipt.plan_id,
422 ws.plan.plan_id
423 );
424 }
425
426 if !receipt.passed {
427 bail!(
428 "rehearsal against '{}' did NOT pass for plan_id {}: {}. \
429 Fix the cause and re-run `shipper rehearse` before publishing.",
430 receipt.registry,
431 receipt.plan_id,
432 receipt.summary
433 );
434 }
435
436 reporter.info(&format!(
437 "rehearsal gate: passing receipt found ({} packages against '{}', plan_id {})",
438 receipt.packages_published, receipt.registry, receipt.plan_id
439 ));
440 Ok(())
441}
442
443pub fn run_publish(
483 ws: &PlannedWorkspace,
484 opts: &RuntimeOptions,
485 reporter: &mut dyn Reporter,
486) -> Result<Receipt> {
487 let workspace_root = &ws.workspace_root;
488 let state_dir = resolve_state_dir(workspace_root, &opts.state_dir);
489 let effects = policy_effects(opts);
490
491 if let Some(ref target) = opts.resume_from
493 && !ws.plan.packages.iter().any(|p| &p.name == target)
494 {
495 bail!("resume-from package '{}' not found in publish plan", target);
496 }
497
498 enforce_rehearsal_gate(ws, opts, &state_dir, reporter)?;
501
502 let lock_timeout = if opts.force {
504 Duration::ZERO
505 } else {
506 opts.lock_timeout
507 };
508 let _lock =
509 lock::LockFile::acquire_with_timeout(&state_dir, Some(workspace_root), lock_timeout)
510 .context("failed to acquire publish lock")?;
511 _lock.set_plan_id(&ws.plan.plan_id)?;
512
513 let git_context = git::collect_git_context();
515 let environment = environment::collect_environment_fingerprint();
516
517 if !opts.allow_dirty {
518 git::ensure_git_clean(workspace_root)?;
519 }
520
521 let reg = init_registry_client(ws.plan.registry.clone(), &state_dir)?;
522
523 let events_path = events::events_path(&state_dir);
525 let mut event_log = events::EventLog::new();
526
527 let mut st = match state::load_state(&state_dir)? {
529 Some(existing) => {
530 if existing.plan_id != ws.plan.plan_id {
531 if !opts.force_resume {
532 bail!(
533 "existing state plan_id {} does not match current plan_id {}; delete state or use --force-resume",
534 existing.plan_id,
535 ws.plan.plan_id
536 );
537 }
538 reporter.warn("forcing resume with mismatched plan_id (unsafe)");
539 }
540 existing
541 }
542 None => init_state(ws, &state_dir)?,
543 };
544
545 reporter.info(&format!("state dir: {}", state_dir.as_path().display()));
546
547 let mut receipts: Vec<PackageReceipt> = Vec::new();
548 let run_started = Utc::now();
549
550 event_log.record(PublishEvent {
552 timestamp: run_started,
553 event_type: EventType::ExecutionStarted,
554 package: "all".to_string(),
555 });
556 webhook::maybe_send_event(
558 &opts.webhook,
559 WebhookEvent::PublishStarted {
560 plan_id: ws.plan.plan_id.clone(),
561 package_count: ws.plan.packages.len(),
562 registry: ws.plan.registry.name.clone(),
563 },
564 );
565 event_log.record(PublishEvent {
567 timestamp: run_started,
568 event_type: EventType::PlanCreated {
569 plan_id: ws.plan.plan_id.clone(),
570 package_count: ws.plan.packages.len(),
571 },
572 package: "all".to_string(),
573 });
574 event_log.write_to_file(&events_path)?;
575 event_log.clear();
576
577 for p in &ws.plan.packages {
579 let key = pkg_key(&p.name, &p.version);
580 st.packages.entry(key).or_insert_with(|| PackageProgress {
581 name: p.name.clone(),
582 version: p.version.clone(),
583 attempts: 0,
584 state: PackageState::Pending,
585 last_updated_at: Utc::now(),
586 });
587 }
588 st.updated_at = Utc::now();
589 state::save_state(&state_dir, &st)?;
590
591 let mut reached_resume_point = opts.resume_from.is_none();
593
594 if opts.parallel.enabled {
596 let parallel_receipts = crate::engine::parallel::run_publish_parallel(
597 ws, opts, &mut st, &state_dir, ®, reporter,
598 )?;
599
600 match crate::state::consistency::verify_events_state_consistency(&events_path, &st) {
606 Ok(drift) if !drift.is_consistent() => {
607 reporter.warn(&crate::state::consistency::format_drift_summary(&drift));
608 event_log.record(PublishEvent {
609 timestamp: Utc::now(),
610 event_type: EventType::StateEventDriftDetected { drift },
611 package: "all".to_string(),
612 });
613 }
614 Ok(_) => {}
615 Err(e) => reporter.warn(&format!("end-of-run consistency check failed: {e}")),
616 }
617
618 let exec_result = if parallel_receipts.iter().all(|r| {
620 matches!(
621 r.state,
622 PackageState::Published | PackageState::Skipped { .. }
623 )
624 }) {
625 ExecutionResult::Success
626 } else {
627 ExecutionResult::PartialFailure
628 };
629 event_log.record(PublishEvent {
630 timestamp: Utc::now(),
631 event_type: EventType::ExecutionFinished {
632 result: exec_result,
633 },
634 package: "all".to_string(),
635 });
636 event_log.write_to_file(&events_path)?;
637
638 let receipt = Receipt {
639 receipt_version: "shipper.receipt.v2".to_string(),
640 plan_id: ws.plan.plan_id.clone(),
641 registry: ws.plan.registry.clone(),
642 started_at: run_started,
643 finished_at: Utc::now(),
644 packages: parallel_receipts,
645 event_log_path: state_dir.join("events.jsonl"),
646 git_context,
647 environment,
648 };
649
650 state::write_receipt(&state_dir, &receipt)?;
651 return Ok(receipt);
652 }
653
654 for p in &ws.plan.packages {
655 let key = pkg_key(&p.name, &p.version);
656 let pkg_label = format!("{}@{}", p.name, p.version);
657 let progress = st
658 .packages
659 .get(&key)
660 .context("missing package progress in state")?
661 .clone();
662
663 if !reached_resume_point {
665 if Some(&p.name) == opts.resume_from.as_ref() {
666 reached_resume_point = true;
667 } else {
668 if matches!(
670 progress.state,
671 PackageState::Published | PackageState::Skipped { .. }
672 ) {
673 reporter.info(&format!(
674 "{}@{}: already complete (skipping)",
675 p.name, p.version
676 ));
677 continue;
678 } else {
679 reporter.warn(&format!(
681 "{}@{}: skipping (before resume point {})",
682 p.name,
683 p.version,
684 opts.resume_from.as_ref().unwrap()
685 ));
686 continue;
687 }
688 }
689 }
690
691 let mut cargo_succeeded = false;
693
694 match progress.state.clone() {
695 PackageState::Published | PackageState::Skipped { .. } => {
696 let short = short_state(&progress.state);
697 reporter.info(&format!(
698 "{}@{}: already complete ({})",
699 p.name, p.version, short
700 ));
701 event_log.record(PublishEvent {
715 timestamp: Utc::now(),
716 event_type: EventType::PackageSkipped {
717 reason: format!("resume: state already {short}"),
718 },
719 package: pkg_label.clone(),
720 });
721 event_log.write_to_file(&events_path)?;
722 event_log.clear();
723 continue;
724 }
725 PackageState::Uploaded => {
726 reporter.info(&format!(
727 "{}@{}: resuming from uploaded (skipping cargo publish)",
728 p.name, p.version
729 ));
730 cargo_succeeded = true;
731 }
732 PackageState::Ambiguous {
733 message: prior_reason,
734 } => {
735 reporter.warn(&format!(
741 "{}@{}: resume found ambiguous state ({}); reconciling against registry",
742 p.name, p.version, prior_reason
743 ));
744
745 let readiness_config = crate::types::ReadinessConfig {
746 enabled: effects.readiness_enabled,
747 ..opts.readiness.clone()
748 };
749
750 event_log.record(PublishEvent {
751 timestamp: Utc::now(),
752 event_type: EventType::PublishReconciling {
753 method: readiness_config.method,
754 },
755 package: pkg_label.clone(),
756 });
757
758 let (outcome, _evidence) =
759 sequential_reconcile(®, &p.name, &p.version, &readiness_config);
760
761 event_log.record(PublishEvent {
762 timestamp: Utc::now(),
763 event_type: EventType::PublishReconciled {
764 outcome: outcome.clone(),
765 },
766 package: pkg_label.clone(),
767 });
768 event_log.write_to_file(&events_path)?;
769 event_log.clear();
770
771 match outcome {
772 ReconciliationOutcome::Published { .. } => {
773 update_state(&mut st, &state_dir, &key, PackageState::Published)?;
774 reporter.info(&format!(
775 "{}@{}: reconciled as published on resume (no republish)",
776 p.name, p.version
777 ));
778 continue;
779 }
780 ReconciliationOutcome::NotPublished { .. } => {
781 update_state(&mut st, &state_dir, &key, PackageState::Pending)?;
785 reporter.info(&format!(
786 "{}@{}: reconciled as not published; proceeding with publish",
787 p.name, p.version
788 ));
789 }
791 ReconciliationOutcome::StillUnknown { reason, .. } => {
792 reporter.error(&format!(
793 "{}@{}: resume reconciliation still inconclusive: {}",
794 p.name, p.version, reason
795 ));
796 webhook::maybe_send_event(
797 &opts.webhook,
798 WebhookEvent::PublishFailed {
799 plan_id: ws.plan.plan_id.clone(),
800 package_name: p.name.clone(),
801 package_version: p.version.clone(),
802 error_class: format!("{:?}", ErrorClass::Ambiguous),
803 message: format!(
804 "resume reconciliation still inconclusive: {reason}"
805 ),
806 },
807 );
808 bail!(
809 "{}@{}: resume reconciliation still inconclusive; operator action required. Prior reason: {}",
810 p.name,
811 p.version,
812 reason
813 );
814 }
815 }
816 }
817 _ => {}
818 }
819
820 event_log.record(PublishEvent {
822 timestamp: Utc::now(),
823 event_type: EventType::PackageStarted {
824 name: p.name.clone(),
825 version: p.version.clone(),
826 },
827 package: pkg_label.clone(),
828 });
829
830 let started_at = Utc::now();
831 let start_instant = Instant::now();
832
833 if reg.version_exists(&p.name, &p.version)? {
835 reporter.info(&format!(
836 "{}@{}: already published (skipping)",
837 p.name, p.version
838 ));
839 let skipped = PackageState::Skipped {
840 reason: "already published".into(),
841 };
842 update_state(&mut st, &state_dir, &key, skipped)?;
843
844 event_log.record(PublishEvent {
846 timestamp: Utc::now(),
847 event_type: EventType::PackageSkipped {
848 reason: "already published".to_string(),
849 },
850 package: pkg_label.clone(),
851 });
852 event_log.write_to_file(&events_path)?;
853 event_log.clear();
854
855 let progress = st
856 .packages
857 .get(&key)
858 .context("missing package progress in state for skipped package")?;
859 receipts.push(PackageReceipt {
860 name: p.name.clone(),
861 version: p.version.clone(),
862 attempts: progress.attempts,
863 state: progress.state.clone(),
864 started_at,
865 finished_at: Utc::now(),
866 duration_ms: start_instant.elapsed().as_millis(),
867 evidence: crate::types::PackageEvidence {
868 attempts: vec![],
869 readiness_checks: vec![],
870 },
871 compromised_at: None,
872 compromised_by: None,
873 superseded_by: None,
874 });
875 continue;
876 }
877
878 reporter.info(&format!("{}@{}: publishing...", p.name, p.version));
879
880 let mut is_new_crate_cached: Option<bool> = None;
884
885 let mut attempt = st
886 .packages
887 .get(&key)
888 .context("missing package progress in state for publish")?
889 .attempts;
890 let mut last_err: Option<(ErrorClass, String)> = None;
891 let mut attempt_evidence: Vec<AttemptEvidence> = Vec::new();
892 let mut readiness_evidence: Vec<ReadinessEvidence> = Vec::new();
893
894 while attempt < opts.max_attempts {
895 attempt += 1;
896 {
897 let pr = st
898 .packages
899 .get_mut(&key)
900 .context("missing package progress in state during attempt")?;
901 pr.attempts = attempt;
902 pr.last_updated_at = Utc::now();
903 state::save_state(&state_dir, &st)?;
904 }
905
906 let command = format!(
907 "cargo publish -p {} --registry {}",
908 p.name, ws.plan.registry.name
909 );
910
911 reporter.info(&format!(
912 "{}@{}: attempt {}/{}",
913 p.name, p.version, attempt, opts.max_attempts
914 ));
915
916 if !cargo_succeeded {
917 event_log.record(PublishEvent {
919 timestamp: Utc::now(),
920 event_type: EventType::PackageAttempted {
921 attempt,
922 command: command.clone(),
923 },
924 package: pkg_label.clone(),
925 });
926
927 let out = cargo::cargo_publish(
928 workspace_root,
929 &p.name,
930 &ws.plan.registry.name,
931 opts.allow_dirty,
932 opts.no_verify,
933 opts.output_lines,
934 None, )?;
936
937 attempt_evidence.push(AttemptEvidence {
939 attempt_number: attempt,
940 command: command.clone(),
941 exit_code: out.exit_code,
942 stdout_tail: out.stdout_tail.clone(),
943 stderr_tail: out.stderr_tail.clone(),
944 timestamp: Utc::now(),
945 duration: out.duration,
946 });
947
948 event_log.record(PublishEvent {
950 timestamp: Utc::now(),
951 event_type: EventType::PackageOutput {
952 stdout_tail: out.stdout_tail.clone(),
953 stderr_tail: out.stderr_tail.clone(),
954 },
955 package: pkg_label.clone(),
956 });
957
958 if out.exit_code == 0 {
959 cargo_succeeded = true;
960 update_state(&mut st, &state_dir, &key, PackageState::Uploaded)?;
962 } else {
963 reporter.warn(&format!(
966 "{}@{}: cargo publish failed (exit={:?}); checking registry...",
967 p.name, p.version, out.exit_code
968 ));
969
970 if reg.version_exists(&p.name, &p.version)? {
971 reporter.info(&format!(
972 "{}@{}: version is present on registry; treating as published",
973 p.name, p.version
974 ));
975 update_state(&mut st, &state_dir, &key, PackageState::Published)?;
976 last_err = None;
977 break;
978 }
979
980 let (class, msg) = classify_cargo_failure(&out.stderr_tail, &out.stdout_tail);
981 last_err = Some((class.clone(), msg.clone()));
982
983 match class {
984 ErrorClass::Permanent => {
985 let failed = PackageState::Failed {
986 class: class.clone(),
987 message: msg.clone(),
988 };
989 update_state(&mut st, &state_dir, &key, failed)?;
990
991 event_log.record(PublishEvent {
993 timestamp: Utc::now(),
994 event_type: EventType::PackageFailed {
995 class,
996 message: msg,
997 },
998 package: pkg_label.clone(),
999 });
1000 event_log.write_to_file(&events_path)?;
1001 event_log.clear();
1002
1003 return Err(anyhow::anyhow!(
1004 "{}@{}: permanent failure: {}",
1005 p.name,
1006 p.version,
1007 last_err.unwrap().1
1008 ));
1009 }
1010 ErrorClass::Retryable | ErrorClass::Ambiguous => {
1011 let is_new_crate =
1012 if crate::runtime::execution::looks_like_rate_limit(&msg) {
1013 *is_new_crate_cached.get_or_insert_with(|| {
1014 reg.check_new_crate(&p.name).unwrap_or(false)
1015 })
1016 } else {
1017 false
1018 };
1019 let delay = registry_aware_backoff(
1020 opts.base_delay,
1021 opts.max_delay,
1022 attempt,
1023 opts.retry_strategy,
1024 opts.retry_jitter,
1025 is_new_crate,
1026 &msg,
1027 );
1028 emit_retry_backoff_event(
1029 &mut event_log,
1030 &events_path,
1031 reporter,
1032 &pkg_label,
1033 &p.name,
1034 &p.version,
1035 attempt,
1036 opts.max_attempts,
1037 delay,
1038 class.clone(),
1039 &msg,
1040 )?;
1041 }
1042 }
1043 continue;
1044 }
1045 }
1046
1047 reporter.info(&format!(
1049 "{}@{}: cargo publish exited successfully; verifying...",
1050 p.name, p.version
1051 ));
1052 let readiness_config = crate::types::ReadinessConfig {
1053 enabled: effects.readiness_enabled,
1054 ..opts.readiness.clone()
1055 };
1056 let (visible, checks) =
1057 verify_published(®, &p.name, &p.version, &readiness_config, reporter)?;
1058 readiness_evidence = checks;
1059 if visible {
1060 update_state(&mut st, &state_dir, &key, PackageState::Published)?;
1061 last_err = None;
1062
1063 event_log.record(PublishEvent {
1065 timestamp: Utc::now(),
1066 event_type: EventType::PackagePublished {
1067 duration_ms: start_instant.elapsed().as_millis() as u64,
1068 },
1069 package: pkg_label.clone(),
1070 });
1071 event_log.write_to_file(&events_path)?;
1072 event_log.clear();
1073
1074 webhook::maybe_send_event(
1076 &opts.webhook,
1077 WebhookEvent::PublishSucceeded {
1078 plan_id: ws.plan.plan_id.clone(),
1079 package_name: p.name.clone(),
1080 package_version: p.version.clone(),
1081 duration_ms: start_instant.elapsed().as_millis() as u64,
1082 },
1083 );
1084
1085 break;
1086 } else {
1087 let message =
1088 "published locally, but version not observed on registry within timeout";
1089 last_err = Some((ErrorClass::Ambiguous, message.to_string()));
1090 let delay = backoff_delay(
1091 opts.base_delay,
1092 opts.max_delay,
1093 attempt,
1094 opts.retry_strategy,
1095 opts.retry_jitter,
1096 );
1097 emit_retry_backoff_event(
1098 &mut event_log,
1099 &events_path,
1100 reporter,
1101 &pkg_label,
1102 &p.name,
1103 &p.version,
1104 attempt,
1105 opts.max_attempts,
1106 delay,
1107 ErrorClass::Ambiguous,
1108 message,
1109 )?;
1110 }
1111 }
1112
1113 if last_err.is_none() {
1115 let current_state = st.packages.get(&key).map(|p| &p.state);
1116 if matches!(current_state, Some(PackageState::Uploaded)) {
1117 if reg.version_exists(&p.name, &p.version)? {
1118 update_state(&mut st, &state_dir, &key, PackageState::Published)?;
1119 } else {
1120 last_err = Some((
1121 ErrorClass::Ambiguous,
1122 "package was uploaded but not confirmed visible on registry".into(),
1123 ));
1124 }
1125 }
1126 }
1127
1128 let finished_at = Utc::now();
1129 let duration_ms = start_instant.elapsed().as_millis();
1130
1131 if let Some((class, msg)) = last_err {
1132 if reg.version_exists(&p.name, &p.version)? {
1134 update_state(&mut st, &state_dir, &key, PackageState::Published)?;
1135 } else {
1136 let failed = PackageState::Failed {
1137 class: class.clone(),
1138 message: msg.clone(),
1139 };
1140 update_state(&mut st, &state_dir, &key, failed)?;
1141
1142 event_log.record(PublishEvent {
1144 timestamp: Utc::now(),
1145 event_type: EventType::PackageFailed {
1146 class: class.clone(),
1147 message: msg.clone(),
1148 },
1149 package: pkg_label.clone(),
1150 });
1151 event_log.write_to_file(&events_path)?;
1152 event_log.clear();
1153
1154 webhook::maybe_send_event(
1156 &opts.webhook,
1157 WebhookEvent::PublishFailed {
1158 plan_id: ws.plan.plan_id.clone(),
1159 package_name: p.name.clone(),
1160 package_version: p.version.clone(),
1161 error_class: format!("{:?}", class.clone()),
1162 message: msg.clone(),
1163 },
1164 );
1165
1166 let progress = st
1167 .packages
1168 .get(&key)
1169 .context("missing package progress in state for failed package")?;
1170 receipts.push(PackageReceipt {
1171 name: p.name.clone(),
1172 version: p.version.clone(),
1173 attempts: progress.attempts,
1174 state: progress.state.clone(),
1175 started_at,
1176 finished_at,
1177 duration_ms,
1178 evidence: crate::types::PackageEvidence {
1179 attempts: attempt_evidence,
1180 readiness_checks: readiness_evidence,
1181 },
1182 compromised_at: None,
1183 compromised_by: None,
1184 superseded_by: None,
1185 });
1186 return Err(anyhow::anyhow!("{}@{}: failed: {}", p.name, p.version, msg));
1187 }
1188 }
1189
1190 let progress = st
1191 .packages
1192 .get(&key)
1193 .context("missing package progress in state for completed package")?;
1194 receipts.push(PackageReceipt {
1195 name: p.name.clone(),
1196 version: p.version.clone(),
1197 attempts: progress.attempts,
1198 state: progress.state.clone(),
1199 started_at,
1200 finished_at,
1201 duration_ms,
1202 evidence: crate::types::PackageEvidence {
1203 attempts: attempt_evidence,
1204 readiness_checks: readiness_evidence,
1205 },
1206 compromised_at: None,
1207 compromised_by: None,
1208 superseded_by: None,
1209 });
1210 }
1211
1212 match crate::state::consistency::verify_events_state_consistency(&events_path, &st) {
1217 Ok(drift) if !drift.is_consistent() => {
1218 reporter.warn(&crate::state::consistency::format_drift_summary(&drift));
1219 event_log.record(PublishEvent {
1220 timestamp: Utc::now(),
1221 event_type: EventType::StateEventDriftDetected { drift },
1222 package: "all".to_string(),
1223 });
1224 }
1225 Ok(_) => {}
1226 Err(e) => reporter.warn(&format!("end-of-run consistency check failed: {e}")),
1227 }
1228
1229 let exec_result = if receipts.iter().all(|r| {
1231 matches!(
1232 r.state,
1233 PackageState::Published | PackageState::Uploaded | PackageState::Skipped { .. }
1234 )
1235 }) {
1236 ExecutionResult::Success
1237 } else {
1238 ExecutionResult::PartialFailure
1239 };
1240 event_log.record(PublishEvent {
1241 timestamp: Utc::now(),
1242 event_type: EventType::ExecutionFinished {
1243 result: exec_result.clone(),
1244 },
1245 package: "all".to_string(),
1246 });
1247 event_log.write_to_file(&events_path)?;
1248
1249 let total_packages = receipts.len();
1251 let success_count = receipts
1252 .iter()
1253 .filter(|r| matches!(r.state, PackageState::Published))
1254 .count();
1255 let failure_count = receipts
1256 .iter()
1257 .filter(|r| matches!(r.state, PackageState::Failed { .. }))
1258 .count();
1259 let skipped_count = receipts
1260 .iter()
1261 .filter(|r| matches!(r.state, PackageState::Skipped { .. }))
1262 .count();
1263
1264 webhook::maybe_send_event(
1266 &opts.webhook,
1267 WebhookEvent::PublishCompleted {
1268 plan_id: ws.plan.plan_id.clone(),
1269 total_packages,
1270 success_count,
1271 failure_count,
1272 skipped_count,
1273 result: match exec_result {
1274 ExecutionResult::Success => "success".to_string(),
1275 ExecutionResult::PartialFailure => "partial_failure".to_string(),
1276 ExecutionResult::CompleteFailure => "complete_failure".to_string(),
1277 },
1278 },
1279 );
1280
1281 let receipt = Receipt {
1282 receipt_version: "shipper.receipt.v2".to_string(),
1283 plan_id: ws.plan.plan_id.clone(),
1284 registry: ws.plan.registry.clone(),
1285 started_at: run_started,
1286 finished_at: Utc::now(),
1287 packages: receipts,
1288 event_log_path: state_dir.join("events.jsonl"),
1289 git_context,
1290 environment,
1291 };
1292
1293 state::write_receipt(&state_dir, &receipt)?;
1294
1295 Ok(receipt)
1296}
1297
1298pub fn run_resume(
1334 ws: &PlannedWorkspace,
1335 opts: &RuntimeOptions,
1336 reporter: &mut dyn Reporter,
1337) -> Result<Receipt> {
1338 let workspace_root = &ws.workspace_root;
1339 let state_dir = resolve_state_dir(workspace_root, &opts.state_dir);
1340 if state::load_state(&state_dir)?.is_none() {
1341 bail!(
1342 "no existing state found in {}; run shipper publish first",
1343 state_dir.display()
1344 );
1345 }
1346 run_publish(ws, opts, reporter)
1347}
1348
1349#[derive(Debug, Clone)]
1355pub struct RehearsalOutcome {
1356 pub passed: bool,
1357 pub registry_name: String,
1358 pub packages_attempted: usize,
1359 pub packages_published: usize,
1360 pub summary: String,
1361}
1362
1363pub fn run_rehearsal(
1389 ws: &PlannedWorkspace,
1390 opts: &RuntimeOptions,
1391 reporter: &mut dyn Reporter,
1392) -> Result<RehearsalOutcome> {
1393 let rehearsal_name = opts
1394 .rehearsal_registry
1395 .as_ref()
1396 .ok_or_else(|| {
1397 anyhow::anyhow!(
1398 "no rehearsal registry configured; set --rehearsal-registry <name> \
1399 or enable [rehearsal] in .shipper.toml"
1400 )
1401 })?
1402 .clone();
1403
1404 if opts.rehearsal_skip {
1405 reporter.warn(&format!(
1406 "--skip-rehearsal set; rehearsal against '{rehearsal_name}' was requested but will not run. \
1407 Once #97 PR 3 lands, live dispatch will refuse without a prior passing rehearsal."
1408 ));
1409 return Ok(RehearsalOutcome {
1410 passed: false,
1411 registry_name: rehearsal_name,
1412 packages_attempted: 0,
1413 packages_published: 0,
1414 summary: "skipped by --skip-rehearsal".to_string(),
1415 });
1416 }
1417
1418 let rehearsal_reg = opts
1419 .registries
1420 .iter()
1421 .find(|r| r.name == rehearsal_name)
1422 .cloned()
1423 .ok_or_else(|| {
1424 anyhow::anyhow!(
1425 "rehearsal registry '{rehearsal_name}' is not configured. \
1426 Add it to [[registries]] in .shipper.toml or pass --registries."
1427 )
1428 })?;
1429
1430 if rehearsal_reg.name == ws.plan.registry.name {
1431 bail!(
1432 "rehearsal registry '{}' must differ from the live target; \
1433 pick a sandbox registry (e.g. kellnr, a fresh crates-io test account, \
1434 or a throwaway alternate-registry entry)",
1435 rehearsal_reg.name
1436 );
1437 }
1438
1439 let workspace_root = &ws.workspace_root;
1440 let state_dir = resolve_state_dir(workspace_root, &opts.state_dir);
1441 std::fs::create_dir_all(&state_dir)
1442 .with_context(|| format!("failed to create state dir {}", state_dir.display()))?;
1443 let events_path = events::events_path(&state_dir);
1444 let mut event_log = events::EventLog::new();
1445 let started_at = Utc::now();
1446
1447 reporter.info(&format!(
1448 "rehearsal starting — {} packages against '{}'",
1449 ws.plan.packages.len(),
1450 rehearsal_name
1451 ));
1452
1453 event_log.record(PublishEvent {
1454 timestamp: Utc::now(),
1455 event_type: EventType::RehearsalStarted {
1456 registry: rehearsal_name.clone(),
1457 plan_id: ws.plan.plan_id.clone(),
1458 package_count: ws.plan.packages.len(),
1459 },
1460 package: "all".to_string(),
1461 });
1462 event_log.write_to_file(&events_path)?;
1463 event_log.clear();
1464
1465 let rehearsal_client = init_registry_client(rehearsal_reg.clone(), &state_dir)?;
1466
1467 let mut packages_published: usize = 0;
1468 let mut first_failure: Option<String> = None;
1469
1470 for p in &ws.plan.packages {
1471 let pkg_label = format!("{}@{}", p.name, p.version);
1472 reporter.info(&format!("rehearsing {pkg_label} → {rehearsal_name}"));
1473 let start = Instant::now();
1474
1475 let out = cargo::cargo_publish(
1476 workspace_root,
1477 &p.name,
1478 &rehearsal_reg.name,
1479 opts.allow_dirty,
1480 opts.no_verify,
1481 opts.output_lines,
1482 None,
1483 )?;
1484
1485 if out.exit_code != 0 {
1486 let (class, msg) = classify_cargo_failure(&out.stderr_tail, &out.stdout_tail);
1487 reporter.error(&format!(
1488 "rehearsal failed for {pkg_label}: {msg}\nstderr tail:\n{}",
1489 out.stderr_tail
1490 ));
1491 event_log.record(PublishEvent {
1492 timestamp: Utc::now(),
1493 event_type: EventType::RehearsalPackageFailed {
1494 name: p.name.clone(),
1495 version: p.version.clone(),
1496 class,
1497 message: msg.clone(),
1498 },
1499 package: pkg_label.clone(),
1500 });
1501 event_log.write_to_file(&events_path)?;
1502 event_log.clear();
1503 first_failure = Some(format!("{pkg_label}: {msg}"));
1504 break;
1505 }
1506
1507 if !rehearsal_client.version_exists(&p.name, &p.version)? {
1510 let msg = format!(
1511 "rehearsal: cargo publish succeeded but {pkg_label} is not visible on '{rehearsal_name}'"
1512 );
1513 reporter.error(&msg);
1514 event_log.record(PublishEvent {
1515 timestamp: Utc::now(),
1516 event_type: EventType::RehearsalPackageFailed {
1517 name: p.name.clone(),
1518 version: p.version.clone(),
1519 class: ErrorClass::Ambiguous,
1520 message: msg.clone(),
1521 },
1522 package: pkg_label.clone(),
1523 });
1524 event_log.write_to_file(&events_path)?;
1525 event_log.clear();
1526 first_failure = Some(msg);
1527 break;
1528 }
1529
1530 let duration_ms = start.elapsed().as_millis();
1531 event_log.record(PublishEvent {
1532 timestamp: Utc::now(),
1533 event_type: EventType::RehearsalPackagePublished {
1534 name: p.name.clone(),
1535 version: p.version.clone(),
1536 duration_ms,
1537 },
1538 package: pkg_label.clone(),
1539 });
1540 event_log.write_to_file(&events_path)?;
1541 event_log.clear();
1542 packages_published += 1;
1543 }
1544
1545 if first_failure.is_none()
1551 && let Some(ref smoke_name) = opts.rehearsal_smoke_install
1552 {
1553 match ws.plan.packages.iter().find(|p| &p.name == smoke_name) {
1554 Some(smoke_pkg) => {
1555 reporter.info(&format!(
1556 "smoke-install: {smoke_name}@{} from '{rehearsal_name}'",
1557 smoke_pkg.version
1558 ));
1559
1560 event_log.record(PublishEvent {
1561 timestamp: Utc::now(),
1562 event_type: EventType::RehearsalSmokeCheckStarted {
1563 name: smoke_pkg.name.clone(),
1564 version: smoke_pkg.version.clone(),
1565 registry: rehearsal_name.clone(),
1566 },
1567 package: format!("{smoke_name}@{}", smoke_pkg.version),
1568 });
1569 event_log.write_to_file(&events_path)?;
1570 event_log.clear();
1571
1572 let install_root = state_dir.join("smoke-install");
1573 let _ = std::fs::remove_dir_all(&install_root);
1574 let smoke_start = Instant::now();
1575 let out = cargo::cargo_install_smoke(
1576 workspace_root,
1577 &smoke_pkg.name,
1578 &smoke_pkg.version,
1579 &rehearsal_reg.name,
1580 &install_root,
1581 opts.output_lines,
1582 None,
1583 )?;
1584
1585 if out.exit_code == 0 {
1586 let duration_ms = smoke_start.elapsed().as_millis();
1587 event_log.record(PublishEvent {
1588 timestamp: Utc::now(),
1589 event_type: EventType::RehearsalSmokeCheckSucceeded {
1590 name: smoke_pkg.name.clone(),
1591 version: smoke_pkg.version.clone(),
1592 duration_ms,
1593 },
1594 package: format!("{smoke_name}@{}", smoke_pkg.version),
1595 });
1596 reporter.info(&format!(
1597 "smoke-install OK for {smoke_name}@{}",
1598 smoke_pkg.version
1599 ));
1600 } else {
1601 let msg = format!(
1602 "cargo install exited {} for {smoke_name}@{}. stderr tail:\n{}",
1603 out.exit_code, smoke_pkg.version, out.stderr_tail
1604 );
1605 reporter.error(&msg);
1606 event_log.record(PublishEvent {
1607 timestamp: Utc::now(),
1608 event_type: EventType::RehearsalSmokeCheckFailed {
1609 name: smoke_pkg.name.clone(),
1610 version: smoke_pkg.version.clone(),
1611 message: msg.clone(),
1612 },
1613 package: format!("{smoke_name}@{}", smoke_pkg.version),
1614 });
1615 first_failure = Some(format!(
1616 "smoke-install of {smoke_name}@{} failed: cargo exit {}",
1617 smoke_pkg.version, out.exit_code
1618 ));
1619 }
1620 event_log.write_to_file(&events_path)?;
1621 event_log.clear();
1622 }
1623 None => {
1624 reporter.warn(&format!(
1629 "smoke-install target '{smoke_name}' is not in the rehearsal plan; skipping. \
1630 Available crates: {}",
1631 ws.plan
1632 .packages
1633 .iter()
1634 .map(|p| p.name.as_str())
1635 .collect::<Vec<_>>()
1636 .join(", ")
1637 ));
1638 }
1639 }
1640 }
1641
1642 let passed = first_failure.is_none();
1643 let summary = if passed {
1644 format!("rehearsed {packages_published} packages against '{rehearsal_name}' successfully")
1645 } else {
1646 format!(
1647 "rehearsal stopped at {}/{}: {}",
1648 packages_published + 1,
1649 ws.plan.packages.len(),
1650 first_failure.as_deref().unwrap_or("")
1651 )
1652 };
1653
1654 let completed_at = Utc::now();
1655 event_log.record(PublishEvent {
1656 timestamp: completed_at,
1657 event_type: EventType::RehearsalComplete {
1658 passed,
1659 registry: rehearsal_name.clone(),
1660 plan_id: ws.plan.plan_id.clone(),
1661 summary: summary.clone(),
1662 },
1663 package: "all".to_string(),
1664 });
1665 event_log.write_to_file(&events_path)?;
1666
1667 let packages_attempted = packages_published + if passed { 0 } else { 1 };
1673 if let Err(err) = crate::state::rehearsal::save_rehearsal(
1674 &state_dir,
1675 &crate::state::rehearsal::RehearsalReceipt {
1676 schema_version: crate::state::rehearsal::CURRENT_REHEARSAL_VERSION.to_string(),
1677 plan_id: ws.plan.plan_id.clone(),
1678 registry: rehearsal_name.clone(),
1679 passed,
1680 packages_attempted,
1681 packages_published,
1682 summary: summary.clone(),
1683 started_at,
1684 completed_at,
1685 },
1686 ) {
1687 reporter.warn(&format!(
1688 "rehearsal outcome event was written, but sidecar receipt could not be persisted: {err:#}. \
1689 The hard gate may not recognize this rehearsal — check {}.",
1690 crate::state::rehearsal::rehearsal_path(&state_dir).display()
1691 ));
1692 }
1693
1694 if passed {
1695 reporter.info(&summary);
1696 } else {
1697 reporter.error(&summary);
1698 }
1699
1700 Ok(RehearsalOutcome {
1701 passed,
1702 registry_name: rehearsal_name,
1703 packages_attempted,
1704 packages_published,
1705 summary,
1706 })
1707}
1708
1709pub(crate) fn init_state(ws: &PlannedWorkspace, state_dir: &Path) -> Result<ExecutionState> {
1710 let mut packages: BTreeMap<String, PackageProgress> = BTreeMap::new();
1711 for p in &ws.plan.packages {
1712 packages.insert(
1713 pkg_key(&p.name, &p.version),
1714 PackageProgress {
1715 name: p.name.clone(),
1716 version: p.version.clone(),
1717 attempts: 0,
1718 state: PackageState::Pending,
1719 last_updated_at: Utc::now(),
1720 },
1721 );
1722 }
1723
1724 let st = ExecutionState {
1725 state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
1726 plan_id: ws.plan.plan_id.clone(),
1727 registry: ws.plan.registry.clone(),
1728 created_at: Utc::now(),
1729 updated_at: Utc::now(),
1730 packages,
1731 };
1732
1733 state::save_state(state_dir, &st)?;
1734 Ok(st)
1735}
1736
1737fn sequential_reconcile(
1746 reg: &RegistryClient,
1747 crate_name: &str,
1748 version: &str,
1749 config: &crate::types::ReadinessConfig,
1750) -> (
1751 crate::types::ReconciliationOutcome,
1752 Vec<crate::types::ReadinessEvidence>,
1753) {
1754 let start = Instant::now();
1755 match reg.is_version_visible_with_backoff(crate_name, version, config) {
1756 Ok((true, evidence)) => (
1757 crate::types::ReconciliationOutcome::Published {
1758 attempts: evidence.len() as u32,
1759 elapsed_ms: start.elapsed().as_millis() as u64,
1760 },
1761 evidence,
1762 ),
1763 Ok((false, evidence)) => (
1764 crate::types::ReconciliationOutcome::NotPublished {
1765 attempts: evidence.len() as u32,
1766 elapsed_ms: start.elapsed().as_millis() as u64,
1767 },
1768 evidence,
1769 ),
1770 Err(e) => (
1771 crate::types::ReconciliationOutcome::StillUnknown {
1772 attempts: 0,
1773 elapsed_ms: start.elapsed().as_millis() as u64,
1774 reason: format!("reconciliation query failed: {e}"),
1775 },
1776 Vec::new(),
1777 ),
1778 }
1779}
1780
1781#[allow(clippy::too_many_arguments)]
1788fn emit_retry_backoff_event(
1789 event_log: &mut events::EventLog,
1790 events_path: &Path,
1791 reporter: &mut dyn Reporter,
1792 pkg_label: &str,
1793 pkg_name: &str,
1794 pkg_version: &str,
1795 attempt: u32,
1796 max_attempts: u32,
1797 delay: std::time::Duration,
1798 reason: ErrorClass,
1799 message: &str,
1800) -> Result<()> {
1801 let next_attempt_at =
1802 Utc::now() + chrono::Duration::from_std(delay).unwrap_or_else(|_| chrono::Duration::zero());
1803
1804 event_log.record(PublishEvent {
1805 timestamp: Utc::now(),
1806 event_type: EventType::RetryBackoffStarted {
1807 attempt,
1808 max_attempts,
1809 delay_ms: delay.as_millis() as u64,
1810 next_attempt_at,
1811 reason: reason.clone(),
1812 message: message.to_string(),
1813 },
1814 package: pkg_label.to_string(),
1815 });
1816 event_log.write_to_file(events_path)?;
1817 event_log.clear();
1818
1819 reporter.warn(&format!(
1820 "{}@{}: {} ({:?}); next attempt in {} (attempt {}/{})",
1821 pkg_name,
1822 pkg_version,
1823 message,
1824 reason,
1825 humantime::format_duration(delay),
1826 attempt.saturating_add(1),
1827 max_attempts,
1828 ));
1829
1830 thread::sleep(delay);
1831 Ok(())
1832}
1833
1834fn verify_published(
1835 reg: &RegistryClient,
1836 crate_name: &str,
1837 version: &str,
1838 config: &crate::types::ReadinessConfig,
1839 reporter: &mut dyn Reporter,
1840) -> Result<(bool, Vec<ReadinessEvidence>)> {
1841 reporter.info(&format!(
1842 "{}@{}: readiness check ({:?})...",
1843 crate_name, version, config.method
1844 ));
1845 let (visible, evidence) = reg.is_version_visible_with_backoff(crate_name, version, config)?;
1846 if visible {
1847 reporter.info(&format!(
1848 "{}@{}: visible after {} checks",
1849 crate_name,
1850 version,
1851 evidence.len()
1852 ));
1853 } else {
1854 reporter.warn(&format!(
1855 "{}@{}: not visible after {} checks",
1856 crate_name,
1857 version,
1858 evidence.len()
1859 ));
1860 }
1861 Ok((visible, evidence))
1862}
1863
1864#[cfg(test)]
1865mod tests {
1866 use std::fs;
1867 use std::path::{Path, PathBuf};
1868 use std::sync::{Arc, Mutex};
1869 use std::thread;
1870 use std::time::Duration;
1871
1872 use chrono::Utc;
1873 use serial_test::serial;
1874 use tempfile::tempdir;
1875 use tiny_http::{Header, Response, Server, StatusCode};
1876
1877 use super::*;
1878 use crate::plan::PlannedWorkspace;
1879 use crate::types::{AuthType, PlannedPackage, Registry, ReleasePlan};
1880
1881 #[derive(Default)]
1882 struct CollectingReporter {
1883 infos: Vec<String>,
1884 warns: Vec<String>,
1885 errors: Vec<String>,
1886 }
1887
1888 impl Reporter for CollectingReporter {
1889 fn info(&mut self, msg: &str) {
1890 self.infos.push(msg.to_string());
1891 }
1892
1893 fn warn(&mut self, msg: &str) {
1894 self.warns.push(msg.to_string());
1895 }
1896
1897 fn error(&mut self, msg: &str) {
1898 self.errors.push(msg.to_string());
1899 }
1900 }
1901
1902 #[cfg(windows)]
1903 fn fake_cargo_path(bin_dir: &Path) -> PathBuf {
1904 bin_dir.join("cargo.cmd")
1905 }
1906
1907 #[cfg(not(windows))]
1908 fn fake_cargo_path(bin_dir: &Path) -> PathBuf {
1909 bin_dir.join("cargo")
1910 }
1911
1912 #[cfg(windows)]
1913 fn fake_git_path(bin_dir: &Path) -> PathBuf {
1914 bin_dir.join("git.cmd")
1915 }
1916
1917 #[cfg(not(windows))]
1918 fn fake_git_path(bin_dir: &Path) -> PathBuf {
1919 bin_dir.join("git")
1920 }
1921
1922 fn fake_program_env_vars(bin_dir: &Path) -> Vec<(&'static str, Option<String>)> {
1923 vec![
1924 (
1925 "SHIPPER_CARGO_BIN",
1926 Some(fake_cargo_path(bin_dir).to_str().expect("utf8").to_string()),
1927 ),
1928 (
1929 "SHIPPER_GIT_BIN",
1930 Some(fake_git_path(bin_dir).to_str().expect("utf8").to_string()),
1931 ),
1932 ]
1933 }
1934
1935 fn with_test_env<F, R>(bin_dir: &Path, extra: Vec<(&'static str, Option<String>)>, f: F) -> R
1937 where
1938 F: FnOnce() -> R,
1939 {
1940 let mut vars = fake_program_env_vars(bin_dir);
1941 vars.extend(extra);
1942 temp_env::with_vars(vars, f)
1943 }
1944
1945 fn write_fake_cargo(bin_dir: &Path) {
1946 #[cfg(windows)]
1947 {
1948 fs::write(
1949 bin_dir.join("cargo.cmd"),
1950 "@echo off\r\nif not \"%SHIPPER_CARGO_ARGS_LOG%\"==\"\" echo %*>>\"%SHIPPER_CARGO_ARGS_LOG%\"\r\nif not \"%SHIPPER_CARGO_STDOUT%\"==\"\" echo %SHIPPER_CARGO_STDOUT%\r\nif not \"%SHIPPER_CARGO_STDERR%\"==\"\" echo %SHIPPER_CARGO_STDERR% 1>&2\r\nif \"%SHIPPER_CARGO_EXIT%\"==\"\" (exit /b 0) else (exit /b %SHIPPER_CARGO_EXIT%)\r\n",
1951 )
1952 .expect("write fake cargo");
1953 }
1954
1955 #[cfg(not(windows))]
1956 {
1957 use std::os::unix::fs::PermissionsExt;
1958
1959 let path = bin_dir.join("cargo");
1960 fs::write(
1961 &path,
1962 "#!/usr/bin/env sh\nif [ -n \"$SHIPPER_CARGO_ARGS_LOG\" ]; then\n echo \"$*\" >>\"$SHIPPER_CARGO_ARGS_LOG\"\nfi\nif [ -n \"$SHIPPER_CARGO_STDOUT\" ]; then\n echo \"$SHIPPER_CARGO_STDOUT\"\nfi\nif [ -n \"$SHIPPER_CARGO_STDERR\" ]; then\n echo \"$SHIPPER_CARGO_STDERR\" >&2\nfi\nexit \"${SHIPPER_CARGO_EXIT:-0}\"\n",
1963 )
1964 .expect("write fake cargo");
1965 let mut perms = fs::metadata(&path).expect("meta").permissions();
1966 perms.set_mode(0o755);
1967 fs::set_permissions(&path, perms).expect("chmod");
1968 }
1969 }
1970
1971 fn write_fake_git(bin_dir: &Path) {
1972 #[cfg(windows)]
1973 {
1974 fs::write(
1975 bin_dir.join("git.cmd"),
1976 "@echo off\r\nif \"%SHIPPER_GIT_FAIL%\"==\"1\" (\r\n echo fatal: git failed 1>&2\r\n exit /b 1\r\n)\r\nif \"%SHIPPER_GIT_CLEAN%\"==\"0\" (\r\n echo M src/lib.rs\r\n exit /b 0\r\n)\r\nexit /b 0\r\n",
1977 )
1978 .expect("write fake git");
1979 }
1980
1981 #[cfg(not(windows))]
1982 {
1983 use std::os::unix::fs::PermissionsExt;
1984
1985 let path = bin_dir.join("git");
1986 fs::write(
1987 &path,
1988 "#!/usr/bin/env sh\nif [ \"${SHIPPER_GIT_FAIL:-0}\" = \"1\" ]; then\n echo 'fatal: git failed' >&2\n exit 1\nfi\nif [ \"${SHIPPER_GIT_CLEAN:-1}\" = \"0\" ]; then\n echo 'M src/lib.rs'\nfi\nexit 0\n",
1989 )
1990 .expect("write fake git");
1991 let mut perms = fs::metadata(&path).expect("meta").permissions();
1992 perms.set_mode(0o755);
1993 fs::set_permissions(&path, perms).expect("chmod");
1994 }
1995 }
1996
1997 fn write_fake_tools(bin_dir: &Path) {
1998 fs::create_dir_all(bin_dir).expect("mkdir");
1999 write_fake_cargo(bin_dir);
2000 write_fake_git(bin_dir);
2001 }
2002
2003 struct TestRegistryServer {
2004 base_url: String,
2005 #[allow(clippy::type_complexity)]
2006 seen: Arc<Mutex<Vec<(String, Option<String>)>>>,
2007 handle: thread::JoinHandle<()>,
2008 }
2009
2010 impl TestRegistryServer {
2011 fn join(self) {
2012 self.handle.join().expect("join server");
2013 }
2014 }
2015
2016 fn spawn_registry_server(
2017 mut routes: std::collections::BTreeMap<String, Vec<(u16, String)>>,
2018 expected_requests: usize,
2019 ) -> TestRegistryServer {
2020 let server = Server::http("127.0.0.1:0").expect("server");
2021 let base_url = format!("http://{}", server.server_addr());
2022 let seen = Arc::new(Mutex::new(Vec::<(String, Option<String>)>::new()));
2023 let seen_thread = Arc::clone(&seen);
2024
2025 let handle = thread::spawn(move || {
2026 for _ in 0..expected_requests {
2027 let req = match server.recv_timeout(Duration::from_secs(30)) {
2028 Ok(Some(r)) => r,
2029 _ => break,
2030 };
2031 let path = req.url().to_string();
2032 let auth = req
2033 .headers()
2034 .iter()
2035 .find(|h| h.field.equiv("Authorization"))
2036 .map(|h| h.value.as_str().to_string());
2037 seen_thread.lock().expect("lock").push((path.clone(), auth));
2038
2039 let response = if let Some(list) = routes.get_mut(&path) {
2040 if list.is_empty() {
2041 (404, "{}".to_string())
2042 } else if list.len() == 1 {
2043 list[0].clone()
2044 } else {
2045 list.remove(0)
2046 }
2047 } else {
2048 (404, "{}".to_string())
2049 };
2050
2051 let resp = Response::from_string(response.1)
2052 .with_status_code(StatusCode(response.0))
2053 .with_header(
2054 Header::from_bytes("Content-Type", "application/json").expect("header"),
2055 );
2056 req.respond(resp).expect("respond");
2057 }
2058 });
2059
2060 TestRegistryServer {
2061 base_url,
2062 seen,
2063 handle,
2064 }
2065 }
2066
2067 fn planned_workspace(workspace_root: &Path, api_base: String) -> PlannedWorkspace {
2068 PlannedWorkspace {
2069 workspace_root: workspace_root.to_path_buf(),
2070 plan: ReleasePlan {
2071 plan_version: "1".to_string(),
2072 plan_id: "plan-demo".to_string(),
2073 created_at: Utc::now(),
2074 registry: Registry {
2075 name: "crates-io".to_string(),
2076 api_base,
2077 index_base: None,
2078 },
2079 packages: vec![PlannedPackage {
2080 name: "demo".to_string(),
2081 version: "0.1.0".to_string(),
2082 manifest_path: workspace_root.join("demo").join("Cargo.toml"),
2083 }],
2084 dependencies: std::collections::BTreeMap::new(),
2085 },
2086 skipped: vec![],
2087 }
2088 }
2089
2090 fn default_opts(state_dir: PathBuf) -> RuntimeOptions {
2091 RuntimeOptions {
2092 allow_dirty: true,
2093 skip_ownership_check: true,
2094 strict_ownership: false,
2095 no_verify: false,
2096 max_attempts: 2,
2097 base_delay: Duration::from_millis(1),
2098 max_delay: Duration::from_millis(2),
2099 verify_timeout: Duration::from_millis(20),
2100 verify_poll_interval: Duration::from_millis(1),
2101 state_dir,
2102 force_resume: false,
2103 policy: crate::types::PublishPolicy::default(),
2104 verify_mode: crate::types::VerifyMode::default(),
2105 readiness: crate::types::ReadinessConfig {
2106 enabled: true,
2107 method: crate::types::ReadinessMethod::Api,
2108 initial_delay: Duration::from_millis(0),
2109 max_delay: Duration::from_millis(20),
2110 max_total_wait: Duration::from_millis(200),
2111 poll_interval: Duration::from_millis(1),
2112 jitter_factor: 0.0,
2113 index_path: None,
2114 prefer_index: false,
2115 },
2116 output_lines: 100,
2117 force: false,
2118 lock_timeout: Duration::from_secs(3600),
2119 parallel: crate::types::ParallelConfig::default(),
2120 webhook: crate::webhook::WebhookConfig::default(),
2121 retry_strategy: crate::retry::RetryStrategyType::Exponential,
2122 retry_jitter: 0.0,
2123 retry_per_error: crate::retry::PerErrorConfig::default(),
2124 encryption: crate::encryption::EncryptionConfig::default(),
2125 registries: vec![],
2126 resume_from: None,
2127 rehearsal_registry: None,
2128 rehearsal_skip: false,
2129 rehearsal_smoke_install: None,
2130 }
2131 }
2132
2133 #[test]
2134 fn classify_cargo_failure_covers_retryable_permanent_and_ambiguous() {
2135 let retryable = classify_cargo_failure("HTTP 429 too many requests", "");
2136 assert_eq!(retryable.0, ErrorClass::Retryable);
2137
2138 let permanent = classify_cargo_failure("permission denied", "");
2139 assert_eq!(permanent.0, ErrorClass::Permanent);
2140
2141 let ambiguous = classify_cargo_failure("strange output", "");
2142 assert_eq!(ambiguous.0, ErrorClass::Ambiguous);
2143 }
2144
2145 #[test]
2146 fn collecting_reporter_error_method_records_message() {
2147 let mut reporter = CollectingReporter::default();
2148 reporter.error("boom");
2149 assert_eq!(reporter.errors, vec!["boom".to_string()]);
2150 }
2151
2152 #[test]
2153 fn helper_functions_return_expected_values() {
2154 let root = PathBuf::from("root");
2155 let rel = resolve_state_dir(&root, &PathBuf::from(".shipper"));
2156 assert_eq!(rel, root.join(".shipper"));
2157
2158 #[cfg(windows)]
2159 {
2160 let abs = PathBuf::from(r"C:\x\state");
2161 assert_eq!(resolve_state_dir(&root, &abs), abs);
2162 }
2163 #[cfg(not(windows))]
2164 {
2165 let abs = PathBuf::from("/x/state");
2166 assert_eq!(resolve_state_dir(&root, &abs), abs);
2167 }
2168
2169 assert_eq!(pkg_key("a", "1.2.3"), "a@1.2.3");
2170 assert_eq!(short_state(&PackageState::Pending), "pending");
2171 assert_eq!(short_state(&PackageState::Uploaded), "uploaded");
2172 assert_eq!(short_state(&PackageState::Published), "published");
2173 assert_eq!(
2174 short_state(&PackageState::Skipped {
2175 reason: "r".to_string()
2176 }),
2177 "skipped"
2178 );
2179 assert_eq!(
2180 short_state(&PackageState::Failed {
2181 class: ErrorClass::Permanent,
2182 message: "m".to_string()
2183 }),
2184 "failed"
2185 );
2186 assert_eq!(
2187 short_state(&PackageState::Ambiguous {
2188 message: "m".to_string()
2189 }),
2190 "ambiguous"
2191 );
2192 }
2193
2194 #[test]
2195 fn backoff_delay_is_bounded_with_jitter() {
2196 let base = Duration::from_millis(100);
2197 let max = Duration::from_millis(500);
2198 let d1 = backoff_delay(
2199 base,
2200 max,
2201 1,
2202 crate::retry::RetryStrategyType::Exponential,
2203 0.5,
2204 );
2205 let d20 = backoff_delay(
2206 base,
2207 max,
2208 20,
2209 crate::retry::RetryStrategyType::Exponential,
2210 0.5,
2211 );
2212
2213 assert!(d1 >= Duration::from_millis(50));
2214 assert!(d1 <= Duration::from_millis(150));
2215
2216 assert!(d20 >= Duration::from_millis(250));
2217 assert!(d20 <= Duration::from_millis(750));
2218 }
2219
2220 #[test]
2221 fn verify_published_returns_true_when_registry_visibility_appears() {
2222 let server = spawn_registry_server(
2223 std::collections::BTreeMap::from([(
2224 "/api/v1/crates/demo/0.1.0".to_string(),
2225 vec![(404, "{}".to_string()), (200, "{}".to_string())],
2226 )]),
2227 2,
2228 );
2229
2230 let reg = RegistryClient::new(Registry {
2231 name: "crates-io".to_string(),
2232 api_base: server.base_url.clone(),
2233 index_base: None,
2234 })
2235 .expect("client");
2236
2237 let config = crate::types::ReadinessConfig {
2238 enabled: true,
2239 method: crate::types::ReadinessMethod::Api,
2240 initial_delay: Duration::from_millis(0),
2241 max_delay: Duration::from_millis(50),
2242 max_total_wait: Duration::from_secs(2),
2244 poll_interval: Duration::from_millis(1),
2245 jitter_factor: 0.0,
2246 index_path: None,
2247 prefer_index: false,
2248 };
2249
2250 let mut reporter = CollectingReporter::default();
2251 let (ok, evidence) =
2252 verify_published(®, "demo", "0.1.0", &config, &mut reporter).expect("verify");
2253 assert!(ok);
2254 assert!(!reporter.infos.is_empty());
2255 assert!(!evidence.is_empty());
2256 server.join();
2257 }
2258
2259 #[test]
2260 fn verify_published_returns_false_on_timeout() {
2261 let reg = RegistryClient::new(Registry {
2262 name: "crates-io".to_string(),
2263 api_base: "http://127.0.0.1:9".to_string(),
2264 index_base: None,
2265 })
2266 .expect("client");
2267
2268 let config = crate::types::ReadinessConfig {
2269 enabled: true,
2270 method: crate::types::ReadinessMethod::Api,
2271 initial_delay: Duration::from_millis(0),
2272 max_delay: Duration::from_millis(10),
2273 max_total_wait: Duration::from_millis(0),
2274 poll_interval: Duration::from_millis(1),
2275 jitter_factor: 0.0,
2276 index_path: None,
2277 prefer_index: false,
2278 };
2279
2280 let mut reporter = CollectingReporter::default();
2281 let (ok, _evidence) =
2282 verify_published(®, "demo", "0.1.0", &config, &mut reporter).expect("verify");
2283 assert!(!ok);
2284 }
2285
2286 #[test]
2287 fn registry_server_helper_returns_404_for_unknown_or_empty_routes() {
2288 let server_unknown = spawn_registry_server(std::collections::BTreeMap::new(), 1);
2289 let reg_unknown = RegistryClient::new(Registry {
2290 name: "crates-io".to_string(),
2291 api_base: server_unknown.base_url.clone(),
2292 index_base: None,
2293 })
2294 .expect("client");
2295 let exists_unknown = reg_unknown
2296 .version_exists("demo", "0.1.0")
2297 .expect("version exists");
2298 assert!(!exists_unknown);
2299 server_unknown.join();
2300
2301 let server_empty = spawn_registry_server(
2302 std::collections::BTreeMap::from([("/api/v1/crates/demo/0.1.0".to_string(), vec![])]),
2303 1,
2304 );
2305 let reg_empty = RegistryClient::new(Registry {
2306 name: "crates-io".to_string(),
2307 api_base: server_empty.base_url.clone(),
2308 index_base: None,
2309 })
2310 .expect("client");
2311 let exists_empty = reg_empty
2312 .version_exists("demo", "0.1.0")
2313 .expect("version exists");
2314 assert!(!exists_empty);
2315 server_empty.join();
2316 }
2317
2318 #[test]
2319 #[serial]
2320 fn run_preflight_errors_in_strict_mode_without_token() {
2321 let td = tempdir().expect("tempdir");
2322 let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
2323 let mut opts = default_opts(PathBuf::from(".shipper"));
2324 opts.strict_ownership = true;
2325 opts.skip_ownership_check = false;
2326 temp_env::with_vars(
2327 [
2328 (
2329 "CARGO_HOME",
2330 Some(td.path().to_str().expect("utf8").to_string()),
2331 ),
2332 ("CARGO_REGISTRY_TOKEN", None::<String>),
2333 ("CARGO_REGISTRIES_CRATES_IO_TOKEN", None::<String>),
2334 ],
2335 || {
2336 let mut reporter = CollectingReporter::default();
2337 let err = run_preflight(&ws, &opts, &mut reporter).expect_err("must fail");
2338 assert!(
2339 format!("{err:#}").contains("strict ownership requested but no token found")
2340 );
2341 },
2342 );
2343 }
2344
2345 #[test]
2346 #[serial]
2347 fn run_preflight_warns_on_owners_failure_when_not_strict() {
2348 let td = tempdir().expect("tempdir");
2349 let bin = td.path().join("bin");
2350 write_fake_tools(&bin);
2351 let mut env_vars = fake_program_env_vars(&bin);
2352 env_vars.extend([
2353 ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
2354 (
2355 "CARGO_HOME",
2356 Some(td.path().to_str().expect("utf8").to_string()),
2357 ),
2358 ("CARGO_REGISTRY_TOKEN", Some("token-abc".to_string())),
2359 ]);
2360 temp_env::with_vars(env_vars, || {
2361 let server = spawn_registry_server(
2362 std::collections::BTreeMap::from([
2363 (
2364 "/api/v1/crates/demo/0.1.0".to_string(),
2365 vec![(404, "{}".to_string())],
2366 ),
2367 (
2368 "/api/v1/crates/demo".to_string(),
2369 vec![(404, "{}".to_string())],
2370 ),
2371 (
2372 "/api/v1/crates/demo/owners".to_string(),
2373 vec![(403, "{}".to_string())],
2374 ),
2375 ]),
2376 3,
2377 );
2378
2379 let ws = planned_workspace(td.path(), server.base_url.clone());
2380 let mut opts = default_opts(PathBuf::from(".shipper"));
2381 opts.skip_ownership_check = false;
2382 opts.strict_ownership = false;
2383
2384 let mut reporter = CollectingReporter::default();
2385 let rep = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
2386 assert!(rep.token_detected);
2387 assert_eq!(rep.packages.len(), 1);
2388 assert!(!rep.packages[0].already_published);
2389 assert!(!rep.packages[0].ownership_verified);
2390 assert!(rep.packages[0].dry_run_passed);
2391 assert_eq!(rep.finishability, Finishability::NotProven);
2392 assert!(
2393 reporter
2394 .warns
2395 .iter()
2396 .any(|w| w.contains("owners preflight failed"))
2397 );
2398
2399 let seen = server.seen.lock().expect("lock");
2400 assert_eq!(seen.len(), 3);
2401 drop(seen);
2402 server.join();
2403 });
2404 }
2405
2406 #[test]
2407 #[serial]
2408 fn run_preflight_owners_success_path() {
2409 let td = tempdir().expect("tempdir");
2410 let bin = td.path().join("bin");
2411 write_fake_tools(&bin);
2412 let mut env_vars = fake_program_env_vars(&bin);
2413 env_vars.extend([
2414 ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
2415 (
2416 "CARGO_HOME",
2417 Some(td.path().to_str().expect("utf8").to_string()),
2418 ),
2419 ("CARGO_REGISTRY_TOKEN", Some("token-abc".to_string())),
2420 ]);
2421 temp_env::with_vars(env_vars, || {
2422 let server = spawn_registry_server(
2423 std::collections::BTreeMap::from([
2424 (
2425 "/api/v1/crates/demo/0.1.0".to_string(),
2426 vec![(404, "{}".to_string())],
2427 ),
2428 (
2429 "/api/v1/crates/demo".to_string(),
2430 vec![(200, "{}".to_string())],
2431 ),
2432 (
2433 "/api/v1/crates/demo/owners".to_string(),
2434 vec![(
2435 200,
2436 r#"{"users":[{"id":1,"login":"alice","name":"Alice"}]}"#.to_string(),
2437 )],
2438 ),
2439 ]),
2440 3,
2441 );
2442
2443 let ws = planned_workspace(td.path(), server.base_url.clone());
2444 let mut opts = default_opts(PathBuf::from(".shipper"));
2445 opts.skip_ownership_check = false;
2446 opts.strict_ownership = false;
2447
2448 let mut reporter = CollectingReporter::default();
2449 let rep = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
2450 assert_eq!(rep.packages.len(), 1);
2451 assert!(reporter.warns.is_empty());
2452 server.join();
2453 });
2454 }
2455
2456 #[test]
2457 #[serial]
2458 fn run_preflight_returns_error_when_strict_ownership_check_fails() {
2459 let td = tempdir().expect("tempdir");
2460 let bin = td.path().join("bin");
2461 write_fake_tools(&bin);
2462 let mut env_vars = fake_program_env_vars(&bin);
2463 env_vars.extend([
2464 ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
2465 (
2466 "CARGO_HOME",
2467 Some(td.path().to_str().expect("utf8").to_string()),
2468 ),
2469 ("CARGO_REGISTRY_TOKEN", Some("token-abc".to_string())),
2470 ]);
2471 temp_env::with_vars(env_vars, || {
2472 let server = spawn_registry_server(
2475 std::collections::BTreeMap::from([
2476 (
2477 "/api/v1/crates/demo/0.1.0".to_string(),
2478 vec![(404, "{}".to_string())],
2479 ),
2480 (
2481 "/api/v1/crates/demo".to_string(),
2482 vec![(200, "{}".to_string())],
2483 ),
2484 (
2485 "/api/v1/crates/demo/owners".to_string(),
2486 vec![(403, "{}".to_string())],
2487 ),
2488 ]),
2489 3,
2490 );
2491
2492 let ws = planned_workspace(td.path(), server.base_url.clone());
2493 let mut opts = default_opts(PathBuf::from(".shipper"));
2494 opts.skip_ownership_check = false;
2495 opts.strict_ownership = true;
2496
2497 let mut reporter = CollectingReporter::default();
2498 let err = run_preflight(&ws, &opts, &mut reporter).expect_err("must fail");
2499 assert!(format!("{err:#}").contains("forbidden when querying owners"));
2500 server.join();
2501 });
2502 }
2503
2504 #[test]
2505 #[serial]
2506 fn run_preflight_strict_skips_ownership_for_new_crate() {
2507 let td = tempdir().expect("tempdir");
2508 let bin = td.path().join("bin");
2509 write_fake_tools(&bin);
2510 let mut env_vars = fake_program_env_vars(&bin);
2511 env_vars.extend([
2512 ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
2513 (
2514 "CARGO_HOME",
2515 Some(td.path().to_str().expect("utf8").to_string()),
2516 ),
2517 ("CARGO_REGISTRY_TOKEN", Some("token-abc".to_string())),
2518 ]);
2519 temp_env::with_vars(env_vars, || {
2520 let server = spawn_registry_server(
2523 std::collections::BTreeMap::from([
2524 (
2525 "/api/v1/crates/demo/0.1.0".to_string(),
2526 vec![(404, "{}".to_string())],
2527 ),
2528 (
2529 "/api/v1/crates/demo".to_string(),
2530 vec![(404, "{}".to_string())],
2531 ),
2532 ]),
2533 2,
2534 );
2535
2536 let ws = planned_workspace(td.path(), server.base_url.clone());
2537 let mut opts = default_opts(PathBuf::from(".shipper"));
2538 opts.skip_ownership_check = false;
2539 opts.strict_ownership = true;
2540
2541 let mut reporter = CollectingReporter::default();
2542 let rep = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
2543 assert_eq!(rep.packages.len(), 1);
2544 assert!(!rep.packages[0].ownership_verified);
2545 assert!(rep.packages[0].is_new_crate);
2546 assert!(
2547 reporter
2548 .infos
2549 .iter()
2550 .any(|i| i.contains("new crate, skipping ownership check"))
2551 );
2552 server.join();
2553 });
2554 }
2555
2556 #[test]
2557 #[serial]
2558 fn run_preflight_writes_preflight_events() {
2559 let td = tempdir().expect("tempdir");
2560 let bin = td.path().join("bin");
2561 write_fake_tools(&bin);
2562 let mut env_vars = fake_program_env_vars(&bin);
2563 env_vars.extend([("SHIPPER_CARGO_EXIT", Some("0".to_string()))]);
2564 temp_env::with_vars(env_vars, || {
2565 let server = spawn_registry_server(
2566 std::collections::BTreeMap::from([
2567 (
2568 "/api/v1/crates/demo/0.1.0".to_string(),
2569 vec![(404, "{}".to_string())],
2570 ),
2571 (
2572 "/api/v1/crates/demo".to_string(),
2573 vec![(404, "{}".to_string())],
2574 ),
2575 ]),
2576 2,
2577 );
2578
2579 let ws = planned_workspace(td.path(), server.base_url.clone());
2580 let mut opts = default_opts(PathBuf::from(".shipper"));
2581 opts.allow_dirty = true;
2582 opts.skip_ownership_check = true;
2583
2584 let mut reporter = CollectingReporter::default();
2585 let _ = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
2586
2587 let events_path = td.path().join(".shipper").join("events.jsonl");
2588 let log =
2589 crate::state::events::EventLog::read_from_file(&events_path).expect("read events");
2590 let events = log.all_events();
2591
2592 assert!(
2593 events
2594 .iter()
2595 .any(|e| matches!(e.event_type, EventType::PreflightStarted))
2596 );
2597 assert!(
2598 events
2599 .iter()
2600 .any(|e| matches!(e.event_type, EventType::PreflightWorkspaceVerify { .. }))
2601 );
2602 assert!(
2603 events.iter().any(|e| {
2604 matches!(e.event_type, EventType::PreflightNewCrateDetected { .. })
2605 })
2606 );
2607 assert!(
2608 events
2609 .iter()
2610 .any(|e| matches!(e.event_type, EventType::PreflightOwnershipCheck { .. }))
2611 );
2612 assert!(
2613 events
2614 .iter()
2615 .any(|e| matches!(e.event_type, EventType::PreflightComplete { .. }))
2616 );
2617 server.join();
2618 });
2619 }
2620
2621 #[test]
2622 #[serial]
2623 fn run_preflight_detects_trusted_publishing_auth_type() {
2624 let td = tempdir().expect("tempdir");
2625 let bin = td.path().join("bin");
2626 write_fake_tools(&bin);
2627 let mut env_vars = fake_program_env_vars(&bin);
2628 env_vars.extend([
2629 ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
2630 (
2631 "CARGO_HOME",
2632 Some(td.path().to_str().expect("utf8").to_string()),
2633 ),
2634 ("CARGO_REGISTRY_TOKEN", None::<String>),
2635 ("CARGO_REGISTRIES_CRATES_IO_TOKEN", None::<String>),
2636 (
2637 "ACTIONS_ID_TOKEN_REQUEST_URL",
2638 Some("https://example.invalid/oidc".to_string()),
2639 ),
2640 (
2641 "ACTIONS_ID_TOKEN_REQUEST_TOKEN",
2642 Some("oidc-token".to_string()),
2643 ),
2644 ]);
2645 temp_env::with_vars(env_vars, || {
2646 let server = spawn_registry_server(
2647 std::collections::BTreeMap::from([
2648 (
2649 "/api/v1/crates/demo/0.1.0".to_string(),
2650 vec![(404, "{}".to_string())],
2651 ),
2652 (
2653 "/api/v1/crates/demo".to_string(),
2654 vec![(404, "{}".to_string())],
2655 ),
2656 ]),
2657 2,
2658 );
2659
2660 let ws = planned_workspace(td.path(), server.base_url.clone());
2661 let mut opts = default_opts(PathBuf::from(".shipper"));
2662 opts.allow_dirty = true;
2663 opts.skip_ownership_check = true;
2664
2665 let mut reporter = CollectingReporter::default();
2666 let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
2667
2668 assert!(!report.token_detected);
2669 assert_eq!(
2670 report.packages[0].auth_type,
2671 Some(crate::types::AuthType::TrustedPublishing)
2672 );
2673 server.join();
2674 });
2675 }
2676
2677 #[test]
2678 #[serial]
2679 fn run_preflight_checks_git_when_allow_dirty_is_false() {
2680 let td = tempdir().expect("tempdir");
2681 let bin = td.path().join("bin");
2682 write_fake_tools(&bin);
2683 let mut env_vars = fake_program_env_vars(&bin);
2684 env_vars.extend([("SHIPPER_GIT_CLEAN", Some("1".to_string()))]);
2685 temp_env::with_vars(env_vars, || {
2686 let server = spawn_registry_server(
2687 std::collections::BTreeMap::from([
2688 (
2689 "/api/v1/crates/demo/0.1.0".to_string(),
2690 vec![(404, "{}".to_string())],
2691 ),
2692 (
2693 "/api/v1/crates/demo".to_string(),
2694 vec![(404, "{}".to_string())],
2695 ),
2696 ]),
2697 2,
2698 );
2699
2700 let ws = planned_workspace(td.path(), server.base_url.clone());
2701 let mut opts = default_opts(PathBuf::from(".shipper"));
2702 opts.allow_dirty = false;
2703 opts.skip_ownership_check = true;
2704
2705 let mut reporter = CollectingReporter::default();
2706 let rep = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
2707 assert_eq!(rep.packages.len(), 1);
2708 server.join();
2709 });
2710 }
2711
2712 #[test]
2713 #[serial]
2714 fn run_publish_skips_when_version_already_exists() {
2715 let td = tempdir().expect("tempdir");
2716 let bin = td.path().join("bin");
2717 write_fake_tools(&bin);
2718 let env_vars = fake_program_env_vars(&bin);
2719 temp_env::with_vars(env_vars, || {
2720 let server = spawn_registry_server(
2721 std::collections::BTreeMap::from([(
2722 "/api/v1/crates/demo/0.1.0".to_string(),
2723 vec![(200, "{}".to_string())],
2724 )]),
2725 1,
2726 );
2727 let ws = planned_workspace(td.path(), server.base_url.clone());
2728 let opts = default_opts(PathBuf::from(".shipper"));
2729
2730 let mut reporter = CollectingReporter::default();
2731 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
2732 assert_eq!(receipt.packages.len(), 1);
2733 assert!(matches!(
2734 receipt.packages[0].state,
2735 PackageState::Skipped { .. }
2736 ));
2737
2738 let state_dir = td.path().join(".shipper");
2739 assert!(state::state_path(&state_dir).exists());
2740 assert!(state::receipt_path(&state_dir).exists());
2741 server.join();
2742 });
2743 }
2744
2745 #[test]
2751 #[serial]
2752 fn resume_emits_package_skipped_event_for_already_published_state() {
2753 let td = tempdir().expect("tempdir");
2754 let bin = td.path().join("bin");
2755 write_fake_tools(&bin);
2756 let env_vars = fake_program_env_vars(&bin);
2757 temp_env::with_vars(env_vars, || {
2758 let server = spawn_registry_server(
2759 std::collections::BTreeMap::from([(
2760 "/api/v1/crates/demo/0.1.0".to_string(),
2761 vec![(404, "{}".to_string())],
2762 )]),
2763 1,
2764 );
2765 let ws = planned_workspace(td.path(), server.base_url.clone());
2766 let state_dir = td.path().join(".shipper");
2767
2768 let mut packages = BTreeMap::new();
2771 packages.insert(
2772 "demo@0.1.0".to_string(),
2773 PackageProgress {
2774 name: "demo".to_string(),
2775 version: "0.1.0".to_string(),
2776 attempts: 1,
2777 state: PackageState::Published,
2778 last_updated_at: Utc::now(),
2779 },
2780 );
2781 let seeded = ExecutionState {
2782 state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
2783 plan_id: ws.plan.plan_id.clone(),
2784 registry: ws.plan.registry.clone(),
2785 created_at: Utc::now(),
2786 updated_at: Utc::now(),
2787 packages,
2788 };
2789 state::save_state(&state_dir, &seeded).expect("seed state");
2790
2791 let opts = default_opts(PathBuf::from(".shipper"));
2792 let mut reporter = CollectingReporter::default();
2793 let _receipt = run_publish(&ws, &opts, &mut reporter).expect("publish resumes");
2794
2795 let events_path = events::events_path(&state_dir);
2797 let raw = std::fs::read_to_string(&events_path).expect("events.jsonl");
2798 let skipped_count = raw
2799 .lines()
2800 .filter(|l| !l.trim().is_empty())
2801 .filter(|l| l.contains(r#""type":"package_skipped""#))
2802 .count();
2803 assert!(
2804 skipped_count >= 1,
2805 "expected at least one PackageSkipped event for the already-Published package; \
2806 events.jsonl was:\n{raw}"
2807 );
2808 });
2809 }
2810
2811 #[test]
2817 #[serial]
2818 fn resume_from_failed_ambiguous_updates_state_to_skipped_when_registry_visible() {
2819 let td = tempdir().expect("tempdir");
2820 let bin = td.path().join("bin");
2821 write_fake_tools(&bin);
2822 let env_vars = fake_program_env_vars(&bin);
2823 temp_env::with_vars(env_vars, || {
2824 let server = spawn_registry_server(
2827 std::collections::BTreeMap::from([(
2828 "/api/v1/crates/demo/0.1.0".to_string(),
2829 vec![(200, "{}".to_string())],
2830 )]),
2831 1,
2832 );
2833 let ws = planned_workspace(td.path(), server.base_url.clone());
2834 let state_dir = td.path().join(".shipper");
2835
2836 let mut packages = BTreeMap::new();
2837 packages.insert(
2838 "demo@0.1.0".to_string(),
2839 PackageProgress {
2840 name: "demo".to_string(),
2841 version: "0.1.0".to_string(),
2842 attempts: 1,
2843 state: PackageState::Failed {
2844 class: ErrorClass::Ambiguous,
2845 message: "prior run: publish outcome ambiguous".to_string(),
2846 },
2847 last_updated_at: Utc::now(),
2848 },
2849 );
2850 let seeded = ExecutionState {
2851 state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
2852 plan_id: ws.plan.plan_id.clone(),
2853 registry: ws.plan.registry.clone(),
2854 created_at: Utc::now(),
2855 updated_at: Utc::now(),
2856 packages,
2857 };
2858 state::save_state(&state_dir, &seeded).expect("seed state");
2859
2860 let opts = default_opts(PathBuf::from(".shipper"));
2861 let mut reporter = CollectingReporter::default();
2862 let _receipt = run_publish(&ws, &opts, &mut reporter).expect("publish resumes");
2863
2864 let reloaded = state::load_state(&state_dir)
2866 .expect("load")
2867 .expect("state exists");
2868 let pkg_state = &reloaded
2869 .packages
2870 .get("demo@0.1.0")
2871 .expect("package in state")
2872 .state;
2873 assert!(
2874 matches!(pkg_state, PackageState::Skipped { .. }),
2875 "expected state.json to show Skipped after resume reconciled against registry; got {pkg_state:?}"
2876 );
2877 server.join();
2878 });
2879 }
2880
2881 #[test]
2882 #[serial]
2883 fn run_publish_checks_git_when_allow_dirty_is_false() {
2884 let td = tempdir().expect("tempdir");
2885 let bin = td.path().join("bin");
2886 write_fake_tools(&bin);
2887 let mut env_vars = fake_program_env_vars(&bin);
2888 env_vars.extend([("SHIPPER_GIT_CLEAN", Some("1".to_string()))]);
2889 temp_env::with_vars(env_vars, || {
2890 let server = spawn_registry_server(
2891 std::collections::BTreeMap::from([(
2892 "/api/v1/crates/demo/0.1.0".to_string(),
2893 vec![(200, "{}".to_string())],
2894 )]),
2895 1,
2896 );
2897 let ws = planned_workspace(td.path(), server.base_url.clone());
2898 let mut opts = default_opts(PathBuf::from(".shipper"));
2899 opts.allow_dirty = false;
2900
2901 let mut reporter = CollectingReporter::default();
2902 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
2903 assert_eq!(receipt.packages.len(), 1);
2904 server.join();
2905 });
2906 }
2907
2908 #[test]
2909 #[serial]
2910 fn run_publish_adds_missing_package_entries_to_existing_state() {
2911 let td = tempdir().expect("tempdir");
2912 let bin = td.path().join("bin");
2913 write_fake_tools(&bin);
2914 let env_vars = fake_program_env_vars(&bin);
2915 temp_env::with_vars(env_vars, || {
2916 let server = spawn_registry_server(
2917 std::collections::BTreeMap::from([(
2918 "/api/v1/crates/demo/0.1.0".to_string(),
2919 vec![(200, "{}".to_string())],
2920 )]),
2921 1,
2922 );
2923
2924 let ws = planned_workspace(td.path(), server.base_url.clone());
2925 let state_dir = td.path().join(".shipper");
2926 let existing = ExecutionState {
2927 state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
2928 plan_id: ws.plan.plan_id.clone(),
2929 registry: ws.plan.registry.clone(),
2930 created_at: Utc::now(),
2931 updated_at: Utc::now(),
2932 packages: BTreeMap::new(),
2933 };
2934 state::save_state(&state_dir, &existing).expect("save");
2935
2936 let opts = default_opts(PathBuf::from(".shipper"));
2937 let mut reporter = CollectingReporter::default();
2938 let _ = run_publish(&ws, &opts, &mut reporter).expect("publish");
2939
2940 let st = state::load_state(&state_dir)
2941 .expect("load")
2942 .expect("exists");
2943 assert!(st.packages.contains_key("demo@0.1.0"));
2944 server.join();
2945 });
2946 }
2947
2948 #[test]
2949 #[serial]
2950 fn run_publish_marks_published_after_successful_verify() {
2951 let td = tempdir().expect("tempdir");
2952 let bin = td.path().join("bin");
2953 write_fake_tools(&bin);
2954 let mut env_vars = fake_program_env_vars(&bin);
2955 env_vars.extend([("SHIPPER_CARGO_EXIT", Some("0".to_string()))]);
2956 temp_env::with_vars(env_vars, || {
2957 let server = spawn_registry_server(
2958 std::collections::BTreeMap::from([(
2959 "/api/v1/crates/demo/0.1.0".to_string(),
2960 vec![(404, "{}".to_string()), (200, "{}".to_string())],
2961 )]),
2962 2,
2963 );
2964 let ws = planned_workspace(td.path(), server.base_url.clone());
2965 let mut opts = default_opts(PathBuf::from(".shipper"));
2966 opts.verify_timeout = Duration::from_millis(200);
2967 opts.verify_poll_interval = Duration::from_millis(1);
2968
2969 let mut reporter = CollectingReporter::default();
2970 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
2971 assert!(matches!(receipt.packages[0].state, PackageState::Published));
2972 server.join();
2973 });
2974 }
2975
2976 #[test]
2977 #[serial]
2978 fn run_publish_treats_500_as_not_visible_during_readiness() {
2979 let td = tempdir().expect("tempdir");
2983 let bin = td.path().join("bin");
2984 write_fake_tools(&bin);
2985 let mut env_vars = fake_program_env_vars(&bin);
2986 env_vars.extend([("SHIPPER_CARGO_EXIT", Some("0".to_string()))]);
2987 temp_env::with_vars(env_vars, || {
2988 let server = spawn_registry_server(
2989 std::collections::BTreeMap::from([(
2990 "/api/v1/crates/demo/0.1.0".to_string(),
2991 vec![
2992 (404, "{}".to_string()),
2993 (500, "{}".to_string()),
2994 (404, "{}".to_string()),
2995 ],
2996 )]),
2997 3,
2998 );
2999 let ws = planned_workspace(td.path(), server.base_url.clone());
3000 let mut opts = default_opts(PathBuf::from(".shipper"));
3001 opts.max_attempts = 1;
3002 opts.readiness.max_total_wait = Duration::from_millis(0);
3003
3004 let mut reporter = CollectingReporter::default();
3005 let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
3006 assert!(format!("{err:#}").contains("failed"));
3007 server.join();
3008 });
3009 }
3010
3011 #[test]
3012 #[serial]
3013 fn run_publish_treats_failed_cargo_as_published_if_registry_shows_version() {
3014 let td = tempdir().expect("tempdir");
3015 let bin = td.path().join("bin");
3016 write_fake_tools(&bin);
3017 with_test_env(
3018 &bin,
3019 vec![
3020 ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
3021 (
3022 "SHIPPER_CARGO_STDERR",
3023 Some("timeout while uploading".to_string()),
3024 ),
3025 ],
3026 || {
3027 let server = spawn_registry_server(
3028 std::collections::BTreeMap::from([(
3029 "/api/v1/crates/demo/0.1.0".to_string(),
3030 vec![(404, "{}".to_string()), (200, "{}".to_string())],
3031 )]),
3032 2,
3033 );
3034 let ws = planned_workspace(td.path(), server.base_url.clone());
3035 let mut opts = default_opts(PathBuf::from(".shipper"));
3036 opts.base_delay = Duration::from_millis(0);
3037 opts.max_delay = Duration::from_millis(0);
3038
3039 let mut reporter = CollectingReporter::default();
3040 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
3041 assert_eq!(receipt.packages.len(), 1);
3042 assert!(matches!(receipt.packages[0].state, PackageState::Published));
3043 assert_eq!(receipt.packages[0].attempts, 1);
3044 server.join();
3045 },
3046 );
3047 }
3048
3049 #[test]
3050 #[serial]
3051 fn run_publish_retries_on_retryable_failures() {
3052 let td = tempdir().expect("tempdir");
3053 let bin = td.path().join("bin");
3054 write_fake_tools(&bin);
3055 with_test_env(
3056 &bin,
3057 vec![
3058 ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
3059 (
3060 "SHIPPER_CARGO_STDERR",
3061 Some("timeout talking to server".to_string()),
3062 ),
3063 ],
3064 || {
3065 let server = spawn_registry_server(
3066 std::collections::BTreeMap::from([(
3067 "/api/v1/crates/demo/0.1.0".to_string(),
3068 vec![
3069 (404, "{}".to_string()),
3070 (404, "{}".to_string()),
3071 (200, "{}".to_string()),
3072 ],
3073 )]),
3074 3,
3075 );
3076 let ws = planned_workspace(td.path(), server.base_url.clone());
3077 let mut opts = default_opts(PathBuf::from(".shipper"));
3078 opts.max_attempts = 2;
3079 opts.base_delay = Duration::from_millis(0);
3080 opts.max_delay = Duration::from_millis(0);
3081
3082 let mut reporter = CollectingReporter::default();
3083 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
3084 assert!(matches!(receipt.packages[0].state, PackageState::Published));
3085 assert_eq!(receipt.packages[0].attempts, 2);
3086 assert!(reporter.warns.iter().any(|w| w.contains("next attempt in")));
3087 server.join();
3088 },
3089 );
3090 }
3091
3092 #[test]
3093 #[serial]
3094 fn run_publish_errors_when_cargo_command_cannot_start() {
3095 let td = tempdir().expect("tempdir");
3096 let server = spawn_registry_server(
3097 std::collections::BTreeMap::from([(
3098 "/api/v1/crates/demo/0.1.0".to_string(),
3099 vec![(404, "{}".to_string())],
3100 )]),
3101 1,
3102 );
3103
3104 let ws = planned_workspace(td.path(), server.base_url.clone());
3105 let missing = td.path().join("no-cargo-here");
3106 temp_env::with_vars(
3107 vec![(
3108 "SHIPPER_CARGO_BIN",
3109 Some(missing.to_str().expect("utf8").to_string()),
3110 )],
3111 || {
3112 let opts = default_opts(PathBuf::from(".shipper"));
3113 let mut reporter = CollectingReporter::default();
3114 let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
3115 assert!(format!("{err:#}").contains("failed to execute cargo publish"));
3116 },
3117 );
3118 server.join();
3119 }
3120
3121 #[test]
3122 #[serial]
3123 fn run_publish_returns_error_on_permanent_failure() {
3124 let td = tempdir().expect("tempdir");
3125 let bin = td.path().join("bin");
3126 write_fake_tools(&bin);
3127 with_test_env(
3128 &bin,
3129 vec![
3130 ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
3131 (
3132 "SHIPPER_CARGO_STDERR",
3133 Some("permission denied".to_string()),
3134 ),
3135 ],
3136 || {
3137 let server = spawn_registry_server(
3138 std::collections::BTreeMap::from([(
3139 "/api/v1/crates/demo/0.1.0".to_string(),
3140 vec![(404, "{}".to_string()), (404, "{}".to_string())],
3141 )]),
3142 2,
3143 );
3144 let ws = planned_workspace(td.path(), server.base_url.clone());
3145 let mut opts = default_opts(PathBuf::from(".shipper"));
3146 opts.base_delay = Duration::from_millis(0);
3147 opts.max_delay = Duration::from_millis(0);
3148
3149 let mut reporter = CollectingReporter::default();
3150 let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
3151 assert!(format!("{err:#}").contains("permanent failure"));
3152
3153 let st = state::load_state(&td.path().join(".shipper"))
3154 .expect("load")
3155 .expect("exists");
3156 let pkg = st.packages.get("demo@0.1.0").expect("pkg");
3157 assert!(matches!(
3158 pkg.state,
3159 PackageState::Failed {
3160 class: ErrorClass::Permanent,
3161 ..
3162 }
3163 ));
3164 server.join();
3165 },
3166 );
3167 }
3168
3169 #[test]
3170 #[serial]
3171 fn run_publish_marks_ambiguous_failure_after_success_without_registry_visibility() {
3172 let td = tempdir().expect("tempdir");
3173 let bin = td.path().join("bin");
3174 write_fake_tools(&bin);
3175 with_test_env(
3176 &bin,
3177 vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
3178 || {
3179 let server = spawn_registry_server(
3181 std::collections::BTreeMap::from([(
3182 "/api/v1/crates/demo/0.1.0".to_string(),
3183 vec![(404, "{}".to_string()), (404, "{}".to_string())],
3184 )]),
3185 3,
3186 );
3187 let ws = planned_workspace(td.path(), server.base_url.clone());
3188 let mut opts = default_opts(PathBuf::from(".shipper"));
3189 opts.max_attempts = 1;
3190 opts.readiness.max_total_wait = Duration::from_millis(0);
3191
3192 let mut reporter = CollectingReporter::default();
3193 let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
3194 assert!(format!("{err:#}").contains("failed"));
3195
3196 let st = state::load_state(&td.path().join(".shipper"))
3197 .expect("load")
3198 .expect("exists");
3199 let pkg = st.packages.get("demo@0.1.0").expect("pkg");
3200 assert!(matches!(
3201 pkg.state,
3202 PackageState::Failed {
3203 class: ErrorClass::Ambiguous,
3204 ..
3205 }
3206 ));
3207 server.join();
3208 },
3209 );
3210 }
3211
3212 #[test]
3213 #[serial]
3214 fn run_publish_recovers_on_final_registry_check_after_ambiguous_verify() {
3215 let td = tempdir().expect("tempdir");
3216 let bin = td.path().join("bin");
3217 write_fake_tools(&bin);
3218 with_test_env(
3219 &bin,
3220 vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
3221 || {
3222 let server = spawn_registry_server(
3223 std::collections::BTreeMap::from([(
3224 "/api/v1/crates/demo/0.1.0".to_string(),
3225 vec![(404, "{}".to_string()), (200, "{}".to_string())],
3226 )]),
3227 2,
3228 );
3229 let ws = planned_workspace(td.path(), server.base_url.clone());
3230 let mut opts = default_opts(PathBuf::from(".shipper"));
3231 opts.max_attempts = 1;
3232 opts.readiness.max_total_wait = Duration::from_millis(0);
3233
3234 let mut reporter = CollectingReporter::default();
3235 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
3236 assert!(matches!(receipt.packages[0].state, PackageState::Published));
3237 server.join();
3238 },
3239 );
3240 }
3241
3242 #[test]
3243 fn run_publish_errors_on_plan_mismatch_without_force_resume() {
3244 let td = tempdir().expect("tempdir");
3245 let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
3246 let state_dir = td.path().join(".shipper");
3247
3248 let mut packages = std::collections::BTreeMap::new();
3249 packages.insert(
3250 "demo@0.1.0".to_string(),
3251 PackageProgress {
3252 name: "demo".to_string(),
3253 version: "0.1.0".to_string(),
3254 attempts: 0,
3255 state: PackageState::Pending,
3256 last_updated_at: Utc::now(),
3257 },
3258 );
3259 let st = ExecutionState {
3260 state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
3261 plan_id: "different-plan".to_string(),
3262 registry: ws.plan.registry.clone(),
3263 created_at: Utc::now(),
3264 updated_at: Utc::now(),
3265 packages,
3266 };
3267 state::save_state(&state_dir, &st).expect("save");
3268
3269 let opts = default_opts(PathBuf::from(".shipper"));
3270 let mut reporter = CollectingReporter::default();
3271 let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
3272 assert!(format!("{err:#}").contains("does not match current plan_id"));
3273 }
3274
3275 #[test]
3276 fn run_publish_allows_forced_resume_with_plan_mismatch() {
3277 let td = tempdir().expect("tempdir");
3278 let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
3279 let state_dir = td.path().join(".shipper");
3280
3281 let mut packages = std::collections::BTreeMap::new();
3282 packages.insert(
3283 "demo@0.1.0".to_string(),
3284 PackageProgress {
3285 name: "demo".to_string(),
3286 version: "0.1.0".to_string(),
3287 attempts: 1,
3288 state: PackageState::Published,
3289 last_updated_at: Utc::now(),
3290 },
3291 );
3292 let st = ExecutionState {
3293 state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
3294 plan_id: "different-plan".to_string(),
3295 registry: ws.plan.registry.clone(),
3296 created_at: Utc::now(),
3297 updated_at: Utc::now(),
3298 packages,
3299 };
3300 state::save_state(&state_dir, &st).expect("save");
3301
3302 let mut opts = default_opts(PathBuf::from(".shipper"));
3303 opts.force_resume = true;
3304
3305 let mut reporter = CollectingReporter::default();
3306 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
3307 assert!(receipt.packages.is_empty());
3308 assert!(
3309 reporter
3310 .warns
3311 .iter()
3312 .any(|w| w.contains("forcing resume with mismatched plan_id"))
3313 );
3314 }
3315
3316 #[test]
3317 fn run_resume_errors_when_state_is_missing() {
3318 let td = tempdir().expect("tempdir");
3319 let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
3320 let opts = default_opts(PathBuf::from(".shipper"));
3321
3322 let mut reporter = CollectingReporter::default();
3323 let err = run_resume(&ws, &opts, &mut reporter).expect_err("must fail");
3324 assert!(format!("{err:#}").contains("no existing state found"));
3325 }
3326
3327 #[test]
3328 fn run_resume_runs_publish_when_state_exists() {
3329 let td = tempdir().expect("tempdir");
3330 let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
3331 let state_dir = td.path().join(".shipper");
3332
3333 let mut packages = std::collections::BTreeMap::new();
3334 packages.insert(
3335 "demo@0.1.0".to_string(),
3336 PackageProgress {
3337 name: "demo".to_string(),
3338 version: "0.1.0".to_string(),
3339 attempts: 1,
3340 state: PackageState::Published,
3341 last_updated_at: Utc::now(),
3342 },
3343 );
3344 let st = ExecutionState {
3345 state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
3346 plan_id: ws.plan.plan_id.clone(),
3347 registry: ws.plan.registry.clone(),
3348 created_at: Utc::now(),
3349 updated_at: Utc::now(),
3350 packages,
3351 };
3352 state::save_state(&state_dir, &st).expect("save");
3353
3354 let opts = default_opts(PathBuf::from(".shipper"));
3355 let mut reporter = CollectingReporter::default();
3356 let receipt = run_resume(&ws, &opts, &mut reporter).expect("resume");
3357 assert!(receipt.packages.is_empty());
3358 }
3359
3360 #[test]
3363 fn preflight_report_serializes_correctly() {
3364 let report = PreflightReport {
3365 plan_id: "test-plan".to_string(),
3366 token_detected: true,
3367 finishability: Finishability::Proven,
3368 packages: vec![PreflightPackage {
3369 name: "demo".to_string(),
3370 version: "0.1.0".to_string(),
3371 already_published: false,
3372 is_new_crate: false,
3373 auth_type: Some(AuthType::Token),
3374 ownership_verified: true,
3375 dry_run_passed: true,
3376 dry_run_output: None,
3377 }],
3378 timestamp: Utc::now(),
3379 dry_run_output: None,
3380 };
3381
3382 let json = serde_json::to_string(&report).expect("serialize");
3383 let parsed: PreflightReport = serde_json::from_str(&json).expect("deserialize");
3384 assert_eq!(parsed.plan_id, report.plan_id);
3385 assert_eq!(parsed.token_detected, report.token_detected);
3386 assert_eq!(parsed.finishability, report.finishability);
3387 assert_eq!(parsed.packages.len(), 1);
3388 }
3389
3390 #[test]
3391 fn finishability_proven_when_all_checks_pass() {
3392 let report = PreflightReport {
3393 plan_id: "test-plan".to_string(),
3394 token_detected: true,
3395 finishability: Finishability::Proven,
3396 packages: vec![PreflightPackage {
3397 name: "demo".to_string(),
3398 version: "0.1.0".to_string(),
3399 already_published: false,
3400 is_new_crate: false,
3401 auth_type: Some(AuthType::Token),
3402 ownership_verified: true,
3403 dry_run_passed: true,
3404 dry_run_output: None,
3405 }],
3406 timestamp: Utc::now(),
3407 dry_run_output: None,
3408 };
3409
3410 assert_eq!(report.finishability, Finishability::Proven);
3411 }
3412
3413 #[test]
3414 fn finishability_not_proven_when_ownership_unverified() {
3415 let report = PreflightReport {
3416 plan_id: "test-plan".to_string(),
3417 token_detected: true,
3418 finishability: Finishability::NotProven,
3419 packages: vec![PreflightPackage {
3420 name: "demo".to_string(),
3421 version: "0.1.0".to_string(),
3422 already_published: false,
3423 is_new_crate: true,
3424 auth_type: Some(AuthType::Token),
3425 ownership_verified: false,
3426 dry_run_passed: true,
3427 dry_run_output: None,
3428 }],
3429 timestamp: Utc::now(),
3430 dry_run_output: None,
3431 };
3432
3433 assert_eq!(report.finishability, Finishability::NotProven);
3434 }
3435
3436 #[test]
3437 fn finishability_failed_when_dry_run_fails() {
3438 let report = PreflightReport {
3439 plan_id: "test-plan".to_string(),
3440 token_detected: true,
3441 finishability: Finishability::Failed,
3442 packages: vec![PreflightPackage {
3443 name: "demo".to_string(),
3444 version: "0.1.0".to_string(),
3445 already_published: false,
3446 is_new_crate: false,
3447 auth_type: Some(AuthType::Token),
3448 ownership_verified: true,
3449 dry_run_passed: false,
3450 dry_run_output: None,
3451 }],
3452 timestamp: Utc::now(),
3453 dry_run_output: None,
3454 };
3455
3456 assert_eq!(report.finishability, Finishability::Failed);
3457 }
3458
3459 #[test]
3460 fn preflight_package_serializes_correctly() {
3461 let pkg = PreflightPackage {
3462 name: "demo".to_string(),
3463 version: "0.1.0".to_string(),
3464 already_published: false,
3465 is_new_crate: true,
3466 auth_type: Some(AuthType::Token),
3467 ownership_verified: true,
3468 dry_run_passed: true,
3469 dry_run_output: None,
3470 };
3471
3472 let json = serde_json::to_string(&pkg).expect("serialize");
3473 let parsed: PreflightPackage = serde_json::from_str(&json).expect("deserialize");
3474 assert_eq!(parsed.name, pkg.name);
3475 assert_eq!(parsed.version, pkg.version);
3476 assert_eq!(parsed.already_published, pkg.already_published);
3477 assert_eq!(parsed.is_new_crate, pkg.is_new_crate);
3478 assert_eq!(parsed.auth_type, pkg.auth_type);
3479 assert_eq!(parsed.ownership_verified, pkg.ownership_verified);
3480 assert_eq!(parsed.dry_run_passed, pkg.dry_run_passed);
3481 }
3482
3483 #[test]
3484 fn auth_type_serializes_correctly() {
3485 let token_auth = AuthType::Token;
3486 let tp_auth = AuthType::TrustedPublishing;
3487 let unknown_auth = AuthType::Unknown;
3488
3489 let json_token = serde_json::to_string(&token_auth).expect("serialize");
3490 let parsed_token: AuthType = serde_json::from_str(&json_token).expect("deserialize");
3491 assert_eq!(parsed_token, AuthType::Token);
3492
3493 let json_tp = serde_json::to_string(&tp_auth).expect("serialize");
3494 let parsed_tp: AuthType = serde_json::from_str(&json_tp).expect("deserialize");
3495 assert_eq!(parsed_tp, AuthType::TrustedPublishing);
3496
3497 let json_unknown = serde_json::to_string(&unknown_auth).expect("serialize");
3498 let parsed_unknown: AuthType = serde_json::from_str(&json_unknown).expect("deserialize");
3499 assert_eq!(parsed_unknown, AuthType::Unknown);
3500 }
3501
3502 #[test]
3505 #[serial]
3506 fn preflight_with_all_packages_already_published() {
3507 let td = tempdir().expect("tempdir");
3508 let bin = td.path().join("bin");
3509 write_fake_tools(&bin);
3510 with_test_env(
3511 &bin,
3512 vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
3513 || {
3514 let server = spawn_registry_server(
3516 std::collections::BTreeMap::from([
3517 (
3518 "/api/v1/crates/demo/0.1.0".to_string(),
3519 vec![(200, "{}".to_string())],
3520 ),
3521 (
3522 "/api/v1/crates/demo".to_string(),
3523 vec![(200, "{}".to_string())],
3524 ),
3525 ]),
3526 2,
3527 );
3528 let ws = planned_workspace(td.path(), server.base_url.clone());
3529 let mut opts = default_opts(PathBuf::from(".shipper"));
3530 opts.allow_dirty = true;
3531 opts.skip_ownership_check = true;
3532
3533 let mut reporter = CollectingReporter::default();
3534 let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
3535
3536 assert_eq!(report.packages.len(), 1);
3537 assert!(report.packages[0].already_published);
3538 assert!(!report.packages[0].is_new_crate);
3539 assert!(report.packages[0].dry_run_passed);
3540 server.join();
3541 },
3542 );
3543 }
3544
3545 #[test]
3546 #[serial]
3547 fn preflight_with_new_crates() {
3548 let td = tempdir().expect("tempdir");
3549 let bin = td.path().join("bin");
3550 write_fake_tools(&bin);
3551 with_test_env(
3552 &bin,
3553 vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
3554 || {
3555 let server = spawn_registry_server(
3557 std::collections::BTreeMap::from([
3558 (
3559 "/api/v1/crates/demo".to_string(),
3560 vec![(404, "{}".to_string())],
3561 ),
3562 (
3563 "/api/v1/crates/demo/0.1.0".to_string(),
3564 vec![(404, "{}".to_string())],
3565 ),
3566 ]),
3567 2,
3568 );
3569 let ws = planned_workspace(td.path(), server.base_url.clone());
3570 let mut opts = default_opts(PathBuf::from(".shipper"));
3571 opts.allow_dirty = true;
3572 opts.skip_ownership_check = true;
3573
3574 let mut reporter = CollectingReporter::default();
3575 let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
3576
3577 assert_eq!(report.packages.len(), 1);
3578 assert!(!report.packages[0].already_published);
3579 assert!(report.packages[0].is_new_crate);
3580 assert!(report.packages[0].dry_run_passed);
3581 server.join();
3582 },
3583 );
3584 }
3585
3586 #[test]
3587 #[serial]
3588 fn preflight_with_ownership_verification_failure() {
3589 let td = tempdir().expect("tempdir");
3590 let bin = td.path().join("bin");
3591 write_fake_tools(&bin);
3592 with_test_env(
3593 &bin,
3594 vec![
3595 ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
3596 ("CARGO_REGISTRY_TOKEN", Some("fake-token".to_string())),
3597 ],
3598 || {
3599 let server = spawn_registry_server(
3601 std::collections::BTreeMap::from([
3602 (
3603 "/api/v1/crates/demo".to_string(),
3604 vec![(200, "{}".to_string())],
3605 ),
3606 (
3607 "/api/v1/crates/demo/0.1.0".to_string(),
3608 vec![(404, "{}".to_string())],
3609 ),
3610 (
3611 "/api/v1/crates/demo/owners".to_string(),
3612 vec![(403, "{}".to_string())],
3613 ),
3614 ]),
3615 3,
3616 );
3617 let ws = planned_workspace(td.path(), server.base_url.clone());
3618 let mut opts = default_opts(PathBuf::from(".shipper"));
3619 opts.allow_dirty = true;
3620 opts.skip_ownership_check = false;
3621
3622 let mut reporter = CollectingReporter::default();
3623 let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
3624
3625 assert_eq!(report.packages.len(), 1);
3626 assert!(!report.packages[0].ownership_verified);
3627 assert_eq!(report.finishability, Finishability::NotProven);
3629 server.join();
3630 },
3631 );
3632 }
3633
3634 #[test]
3635 #[serial]
3636 fn preflight_with_dry_run_failure() {
3637 let td = tempdir().expect("tempdir");
3638 let bin = td.path().join("bin");
3639 write_fake_tools(&bin);
3640 with_test_env(
3641 &bin,
3642 vec![
3643 ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
3644 ("SHIPPER_CARGO_STDERR", Some("dry-run failed".to_string())),
3645 ],
3646 || {
3647 let server = spawn_registry_server(
3648 std::collections::BTreeMap::from([
3649 (
3650 "/api/v1/crates/demo/0.1.0".to_string(),
3651 vec![(404, "{}".to_string())],
3652 ),
3653 (
3654 "/api/v1/crates/demo".to_string(),
3655 vec![(404, "{}".to_string())],
3656 ),
3657 ]),
3658 2,
3659 );
3660 let ws = planned_workspace(td.path(), server.base_url.clone());
3661 let mut opts = default_opts(PathBuf::from(".shipper"));
3662 opts.allow_dirty = true;
3663 opts.skip_ownership_check = true;
3664
3665 let mut reporter = CollectingReporter::default();
3666 let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
3667
3668 assert_eq!(report.packages.len(), 1);
3669 assert!(!report.packages[0].dry_run_passed);
3670 assert_eq!(report.finishability, Finishability::Failed);
3672 server.join();
3673 },
3674 );
3675 }
3676
3677 #[test]
3678 #[serial]
3679 fn preflight_strict_ownership_requires_token() {
3680 let td = tempdir().expect("tempdir");
3681 let bin = td.path().join("bin");
3682 write_fake_tools(&bin);
3683 with_test_env(
3684 &bin,
3685 vec![
3686 ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
3687 (
3688 "CARGO_HOME",
3689 Some(td.path().to_str().expect("utf8").to_string()),
3690 ),
3691 ("CARGO_REGISTRY_TOKEN", None),
3692 ("CARGO_REGISTRIES_CRATES_IO_TOKEN", None),
3693 ],
3694 || {
3695 let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
3696 let mut opts = default_opts(PathBuf::from(".shipper"));
3697 opts.allow_dirty = true;
3698 opts.strict_ownership = true;
3699 let mut reporter = CollectingReporter::default();
3702 let err = run_preflight(&ws, &opts, &mut reporter).expect_err("must fail");
3703 assert!(
3704 format!("{err:#}").contains("strict ownership requested but no token found")
3705 );
3706 },
3707 );
3708 }
3709
3710 #[test]
3711 #[serial]
3712 fn preflight_finishability_proven_with_all_checks_pass() {
3713 let td = tempdir().expect("tempdir");
3714 let bin = td.path().join("bin");
3715 write_fake_tools(&bin);
3716 with_test_env(
3717 &bin,
3718 vec![
3719 ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
3720 ("CARGO_REGISTRY_TOKEN", Some("fake-token".to_string())),
3721 ],
3722 || {
3723 let server = spawn_registry_server(
3725 std::collections::BTreeMap::from([
3726 (
3727 "/api/v1/crates/demo".to_string(),
3728 vec![(200, "{}".to_string())],
3729 ),
3730 (
3731 "/api/v1/crates/demo/0.1.0".to_string(),
3732 vec![(404, "{}".to_string())],
3733 ),
3734 (
3735 "/api/v1/crates/demo/owners".to_string(),
3736 vec![(200, r#"{"users":[]}"#.to_string())],
3737 ),
3738 ]),
3739 3,
3740 );
3741 let ws = planned_workspace(td.path(), server.base_url.clone());
3742 let mut opts = default_opts(PathBuf::from(".shipper"));
3743 opts.allow_dirty = true;
3744 opts.skip_ownership_check = false;
3745
3746 let mut reporter = CollectingReporter::default();
3747 let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
3748
3749 assert_eq!(report.packages.len(), 1);
3750 assert!(report.packages[0].ownership_verified);
3751 assert!(report.packages[0].dry_run_passed);
3752 assert_eq!(report.finishability, Finishability::Proven);
3753 server.join();
3754 },
3755 );
3756 }
3757
3758 #[test]
3759 #[serial]
3760 fn test_fast_policy_skips_dry_run() {
3761 let td = tempdir().expect("tempdir");
3762 let bin = td.path().join("bin");
3763 write_fake_tools(&bin);
3764 with_test_env(
3766 &bin,
3767 vec![("SHIPPER_CARGO_EXIT", Some("1".to_string()))],
3768 || {
3769 let server = spawn_registry_server(
3771 std::collections::BTreeMap::from([
3772 (
3773 "/api/v1/crates/demo/0.1.0".to_string(),
3774 vec![(404, "{}".to_string())],
3775 ),
3776 (
3777 "/api/v1/crates/demo".to_string(),
3778 vec![(404, "{}".to_string())],
3779 ),
3780 ]),
3781 2,
3782 );
3783 let ws = planned_workspace(td.path(), server.base_url.clone());
3784 let mut opts = default_opts(PathBuf::from(".shipper"));
3785 opts.policy = crate::types::PublishPolicy::Fast;
3786
3787 let mut reporter = CollectingReporter::default();
3788 let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
3789
3790 assert!(report.packages[0].dry_run_passed);
3792 assert!(!report.packages[0].ownership_verified);
3794 assert_eq!(report.finishability, Finishability::NotProven);
3796 assert!(
3797 reporter
3798 .infos
3799 .iter()
3800 .any(|i| i.contains("skipping dry-run"))
3801 );
3802 server.join();
3803 },
3804 );
3805 }
3806
3807 #[test]
3808 #[serial]
3809 fn test_balanced_policy_skips_ownership() {
3810 let td = tempdir().expect("tempdir");
3811 let bin = td.path().join("bin");
3812 write_fake_tools(&bin);
3813 with_test_env(
3814 &bin,
3815 vec![
3816 ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
3817 ("CARGO_REGISTRY_TOKEN", Some("fake-token".to_string())),
3818 ],
3819 || {
3820 let server = spawn_registry_server(
3822 std::collections::BTreeMap::from([
3823 (
3824 "/api/v1/crates/demo/0.1.0".to_string(),
3825 vec![(404, "{}".to_string())],
3826 ),
3827 (
3828 "/api/v1/crates/demo".to_string(),
3829 vec![(404, "{}".to_string())],
3830 ),
3831 ]),
3832 2,
3833 );
3834 let ws = planned_workspace(td.path(), server.base_url.clone());
3835 let mut opts = default_opts(PathBuf::from(".shipper"));
3836 opts.policy = crate::types::PublishPolicy::Balanced;
3837 opts.skip_ownership_check = false; let mut reporter = CollectingReporter::default();
3840 let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
3841
3842 assert!(!report.packages[0].ownership_verified);
3844 assert!(report.packages[0].dry_run_passed);
3846 server.join();
3847 },
3848 );
3849 }
3850
3851 #[test]
3852 #[serial]
3853 fn test_safe_policy_runs_all_checks() {
3854 let td = tempdir().expect("tempdir");
3855 let bin = td.path().join("bin");
3856 write_fake_tools(&bin);
3857 with_test_env(
3858 &bin,
3859 vec![
3860 ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
3861 ("CARGO_REGISTRY_TOKEN", Some("fake-token".to_string())),
3862 ],
3863 || {
3864 let server = spawn_registry_server(
3866 std::collections::BTreeMap::from([
3867 (
3868 "/api/v1/crates/demo/0.1.0".to_string(),
3869 vec![(404, "{}".to_string())],
3870 ),
3871 (
3872 "/api/v1/crates/demo".to_string(),
3873 vec![(200, "{}".to_string())],
3874 ),
3875 (
3876 "/api/v1/crates/demo/owners".to_string(),
3877 vec![(200, r#"{"users":[]}"#.to_string())],
3878 ),
3879 ]),
3880 3,
3881 );
3882 let ws = planned_workspace(td.path(), server.base_url.clone());
3883 let mut opts = default_opts(PathBuf::from(".shipper"));
3884 opts.policy = crate::types::PublishPolicy::Safe;
3885 opts.skip_ownership_check = false;
3886
3887 let mut reporter = CollectingReporter::default();
3888 let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
3889
3890 assert!(report.packages[0].dry_run_passed);
3892 assert!(report.packages[0].ownership_verified);
3893 assert_eq!(report.finishability, Finishability::Proven);
3894 server.join();
3895 },
3896 );
3897 }
3898
3899 #[test]
3900 #[serial]
3901 fn test_verify_mode_none_skips_dry_run() {
3902 let td = tempdir().expect("tempdir");
3903 let bin = td.path().join("bin");
3904 write_fake_tools(&bin);
3905 with_test_env(
3907 &bin,
3908 vec![("SHIPPER_CARGO_EXIT", Some("1".to_string()))],
3909 || {
3910 let server = spawn_registry_server(
3911 std::collections::BTreeMap::from([
3912 (
3913 "/api/v1/crates/demo/0.1.0".to_string(),
3914 vec![(404, "{}".to_string())],
3915 ),
3916 (
3917 "/api/v1/crates/demo".to_string(),
3918 vec![(404, "{}".to_string())],
3919 ),
3920 ]),
3921 2,
3922 );
3923 let ws = planned_workspace(td.path(), server.base_url.clone());
3924 let mut opts = default_opts(PathBuf::from(".shipper"));
3925 opts.verify_mode = crate::types::VerifyMode::None;
3926
3927 let mut reporter = CollectingReporter::default();
3928 let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
3929
3930 assert!(report.packages[0].dry_run_passed);
3932 assert!(
3933 reporter
3934 .infos
3935 .iter()
3936 .any(|i| i.contains("skipping dry-run"))
3937 );
3938 server.join();
3939 },
3940 );
3941 }
3942
3943 #[test]
3944 #[serial]
3945 fn test_verify_mode_package_runs_per_package() {
3946 let td = tempdir().expect("tempdir");
3947 let bin = td.path().join("bin");
3948 write_fake_tools(&bin);
3949 with_test_env(
3950 &bin,
3951 vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
3952 || {
3953 let server = spawn_registry_server(
3954 std::collections::BTreeMap::from([
3955 (
3956 "/api/v1/crates/demo/0.1.0".to_string(),
3957 vec![(404, "{}".to_string())],
3958 ),
3959 (
3960 "/api/v1/crates/demo".to_string(),
3961 vec![(404, "{}".to_string())],
3962 ),
3963 ]),
3964 2,
3965 );
3966 let ws = planned_workspace(td.path(), server.base_url.clone());
3967 let mut opts = default_opts(PathBuf::from(".shipper"));
3968 opts.verify_mode = crate::types::VerifyMode::Package;
3969
3970 let mut reporter = CollectingReporter::default();
3971 let report = run_preflight(&ws, &opts, &mut reporter).expect("preflight");
3972
3973 assert!(report.packages[0].dry_run_passed);
3974 assert!(
3975 reporter
3976 .infos
3977 .iter()
3978 .any(|i| i.contains("per-package dry-run"))
3979 );
3980 server.join();
3981 },
3982 );
3983 }
3984
3985 #[test]
3986 #[serial]
3987 fn resume_from_uploaded_skips_cargo_publish_and_reaches_published() {
3988 let td = tempdir().expect("tempdir");
3989 let bin = td.path().join("bin");
3990 write_fake_tools(&bin);
3991 let args_log = td.path().join("cargo_args.txt");
3992 let mut env_vars = fake_program_env_vars(&bin);
3993 env_vars.extend([
3994 ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
3995 (
3996 "SHIPPER_CARGO_ARGS_LOG",
3997 Some(args_log.to_str().expect("utf8").to_string()),
3998 ),
3999 ]);
4000 temp_env::with_vars(env_vars, || {
4001 let server = spawn_registry_server(
4003 std::collections::BTreeMap::from([(
4004 "/api/v1/crates/demo/0.1.0".to_string(),
4005 vec![(404, "{}".to_string()), (200, "{}".to_string())],
4006 )]),
4007 2,
4008 );
4009
4010 let ws = planned_workspace(td.path(), server.base_url.clone());
4011 let state_dir = td.path().join(".shipper");
4012
4013 let mut packages = std::collections::BTreeMap::new();
4015 packages.insert(
4016 "demo@0.1.0".to_string(),
4017 PackageProgress {
4018 name: "demo".to_string(),
4019 version: "0.1.0".to_string(),
4020 attempts: 1,
4021 state: PackageState::Uploaded,
4022 last_updated_at: Utc::now(),
4023 },
4024 );
4025 let st = ExecutionState {
4026 state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
4027 plan_id: ws.plan.plan_id.clone(),
4028 registry: ws.plan.registry.clone(),
4029 created_at: Utc::now(),
4030 updated_at: Utc::now(),
4031 packages,
4032 };
4033 state::save_state(&state_dir, &st).expect("save");
4034
4035 let opts = default_opts(PathBuf::from(".shipper"));
4036 let mut reporter = CollectingReporter::default();
4037 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
4038
4039 assert_eq!(receipt.packages.len(), 1);
4041 assert!(
4042 matches!(receipt.packages[0].state, PackageState::Published),
4043 "expected Published, got {:?}",
4044 receipt.packages[0].state
4045 );
4046
4047 let cargo_invoked = args_log.exists()
4050 && fs::read_to_string(&args_log)
4051 .unwrap_or_default()
4052 .contains("publish");
4053 assert!(
4054 !cargo_invoked,
4055 "cargo publish should not have been invoked on resume from Uploaded"
4056 );
4057
4058 assert!(
4060 reporter
4061 .infos
4062 .iter()
4063 .any(|i| i.contains("resuming from uploaded")
4064 || i.contains("already published")
4065 || i.contains("already complete"))
4066 );
4067
4068 assert!(
4070 reporter.infos.iter().any(|i| i.contains("verifying")
4071 || i.contains("visible")
4072 || i.contains("readiness")),
4073 "expected readiness verification to be exercised, reporter infos: {:?}",
4074 reporter.infos
4075 );
4076
4077 server.join();
4078 });
4079 }
4080
4081 #[test]
4082 #[serial]
4083 fn test_resume_from_skips_initial_packages() {
4084 let td = tempdir().expect("tempdir");
4085 let bin = td.path().join("bin");
4086 write_fake_tools(&bin);
4087 let args_log = td.path().join("cargo_args.txt");
4088 let mut env_vars = fake_program_env_vars(&bin);
4089 env_vars.extend([
4090 ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
4091 (
4092 "SHIPPER_CARGO_ARGS_LOG",
4093 Some(args_log.to_str().expect("utf8").to_string()),
4094 ),
4095 ]);
4096 temp_env::with_vars(env_vars, || {
4097 let server = spawn_registry_server(
4101 std::collections::BTreeMap::from([
4102 (
4103 "/api/v1/crates/pkg1/0.1.0".to_string(),
4104 vec![(404, "{}".to_string()), (200, "{}".to_string())],
4105 ),
4106 (
4107 "/api/v1/crates/pkg2/0.1.0".to_string(),
4108 vec![(404, "{}".to_string()), (200, "{}".to_string())],
4109 ),
4110 ]),
4111 2,
4112 );
4113
4114 let mut ws = planned_workspace(td.path(), server.base_url.clone());
4115 ws.plan.packages = vec![
4117 PlannedPackage {
4118 name: "pkg1".to_string(),
4119 version: "0.1.0".to_string(),
4120 manifest_path: td.path().join("pkg1/Cargo.toml"),
4121 },
4122 PlannedPackage {
4123 name: "pkg2".to_string(),
4124 version: "0.1.0".to_string(),
4125 manifest_path: td.path().join("pkg2/Cargo.toml"),
4126 },
4127 ];
4128
4129 let mut opts = default_opts(PathBuf::from(".shipper"));
4130 opts.resume_from = Some("pkg2".to_string());
4132
4133 let mut reporter = CollectingReporter::default();
4134 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
4135
4136 assert_eq!(receipt.packages.len(), 1);
4138 assert_eq!(receipt.packages[0].name, "pkg2");
4139
4140 let log = std::fs::read_to_string(&args_log).expect("read log");
4142 assert!(!log.contains("pkg1"));
4143 assert!(log.contains("pkg2"));
4144
4145 server.join();
4146 });
4147 }
4148
4149 #[test]
4152 fn init_state_creates_pending_entries_for_all_packages() {
4153 let td = tempdir().expect("tempdir");
4154 let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
4155 let state_dir = td.path().join(".shipper");
4156
4157 let st = init_state(&ws, &state_dir).expect("init");
4158 assert_eq!(st.plan_id, "plan-demo");
4159 assert_eq!(st.packages.len(), 1);
4160 let progress = st.packages.get("demo@0.1.0").expect("pkg");
4161 assert_eq!(progress.name, "demo");
4162 assert_eq!(progress.version, "0.1.0");
4163 assert_eq!(progress.attempts, 0);
4164 assert!(matches!(progress.state, PackageState::Pending));
4165 }
4166
4167 #[test]
4168 fn init_state_persists_state_to_disk() {
4169 let td = tempdir().expect("tempdir");
4170 let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
4171 let state_dir = td.path().join(".shipper");
4172
4173 let _ = init_state(&ws, &state_dir).expect("init");
4174 let loaded = state::load_state(&state_dir)
4175 .expect("load")
4176 .expect("exists");
4177 assert_eq!(loaded.plan_id, "plan-demo");
4178 assert!(loaded.packages.contains_key("demo@0.1.0"));
4179 }
4180
4181 #[test]
4182 fn init_state_with_multi_package_plan() {
4183 let td = tempdir().expect("tempdir");
4184 let mut ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
4185 ws.plan.packages = vec![
4186 PlannedPackage {
4187 name: "alpha".to_string(),
4188 version: "1.0.0".to_string(),
4189 manifest_path: td.path().join("alpha/Cargo.toml"),
4190 },
4191 PlannedPackage {
4192 name: "beta".to_string(),
4193 version: "2.0.0".to_string(),
4194 manifest_path: td.path().join("beta/Cargo.toml"),
4195 },
4196 PlannedPackage {
4197 name: "gamma".to_string(),
4198 version: "0.3.0".to_string(),
4199 manifest_path: td.path().join("gamma/Cargo.toml"),
4200 },
4201 ];
4202 let state_dir = td.path().join(".shipper");
4203
4204 let st = init_state(&ws, &state_dir).expect("init");
4205 assert_eq!(st.packages.len(), 3);
4206 assert!(st.packages.contains_key("alpha@1.0.0"));
4207 assert!(st.packages.contains_key("beta@2.0.0"));
4208 assert!(st.packages.contains_key("gamma@0.3.0"));
4209 for progress in st.packages.values() {
4210 assert_eq!(progress.attempts, 0);
4211 assert!(matches!(progress.state, PackageState::Pending));
4212 }
4213 }
4214
4215 #[test]
4216 fn run_publish_errors_on_invalid_resume_from_target() {
4217 let td = tempdir().expect("tempdir");
4218 let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
4219 let mut opts = default_opts(PathBuf::from(".shipper"));
4220 opts.resume_from = Some("nonexistent-package".to_string());
4221
4222 let mut reporter = CollectingReporter::default();
4223 let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
4224 assert!(format!("{err:#}").contains("resume-from package"));
4225 assert!(format!("{err:#}").contains("not found in publish plan"));
4226 }
4227
4228 #[test]
4229 #[serial]
4230 fn run_publish_writes_execution_events() {
4231 let td = tempdir().expect("tempdir");
4232 let bin = td.path().join("bin");
4233 write_fake_tools(&bin);
4234 let env_vars = fake_program_env_vars(&bin);
4235 temp_env::with_vars(env_vars, || {
4236 let server = spawn_registry_server(
4237 std::collections::BTreeMap::from([(
4238 "/api/v1/crates/demo/0.1.0".to_string(),
4239 vec![(200, "{}".to_string())],
4240 )]),
4241 1,
4242 );
4243 let ws = planned_workspace(td.path(), server.base_url.clone());
4244 let opts = default_opts(PathBuf::from(".shipper"));
4245
4246 let mut reporter = CollectingReporter::default();
4247 let _ = run_publish(&ws, &opts, &mut reporter).expect("publish");
4248
4249 let events_path = td.path().join(".shipper").join("events.jsonl");
4250 let log =
4251 crate::state::events::EventLog::read_from_file(&events_path).expect("read events");
4252 let events = log.all_events();
4253
4254 assert!(
4255 events
4256 .iter()
4257 .any(|e| matches!(e.event_type, EventType::ExecutionStarted))
4258 );
4259 assert!(
4260 events
4261 .iter()
4262 .any(|e| matches!(e.event_type, EventType::PlanCreated { .. }))
4263 );
4264 assert!(
4265 events
4266 .iter()
4267 .any(|e| matches!(e.event_type, EventType::PackageSkipped { .. }))
4268 );
4269 assert!(
4270 events
4271 .iter()
4272 .any(|e| matches!(e.event_type, EventType::ExecutionFinished { .. }))
4273 );
4274 server.join();
4275 });
4276 }
4277
4278 #[test]
4279 #[serial]
4280 fn run_publish_receipt_contains_evidence_after_success() {
4281 let td = tempdir().expect("tempdir");
4282 let bin = td.path().join("bin");
4283 write_fake_tools(&bin);
4284 let mut env_vars = fake_program_env_vars(&bin);
4285 env_vars.extend([("SHIPPER_CARGO_EXIT", Some("0".to_string()))]);
4286 temp_env::with_vars(env_vars, || {
4287 let server = spawn_registry_server(
4288 std::collections::BTreeMap::from([(
4289 "/api/v1/crates/demo/0.1.0".to_string(),
4290 vec![(404, "{}".to_string()), (200, "{}".to_string())],
4291 )]),
4292 2,
4293 );
4294 let ws = planned_workspace(td.path(), server.base_url.clone());
4295 let opts = default_opts(PathBuf::from(".shipper"));
4296
4297 let mut reporter = CollectingReporter::default();
4298 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
4299
4300 assert_eq!(receipt.receipt_version, "shipper.receipt.v2");
4301 assert_eq!(receipt.plan_id, "plan-demo");
4302 assert_eq!(receipt.registry.name, "crates-io");
4303 assert_eq!(receipt.packages.len(), 1);
4304 assert!(matches!(receipt.packages[0].state, PackageState::Published));
4305 assert_eq!(receipt.packages[0].attempts, 1);
4306 assert!(!receipt.packages[0].evidence.attempts.is_empty());
4307 assert_eq!(receipt.packages[0].evidence.attempts[0].attempt_number, 1);
4308 assert_eq!(receipt.packages[0].evidence.attempts[0].exit_code, 0);
4309 server.join();
4310 });
4311 }
4312
4313 #[test]
4314 #[serial]
4315 fn run_publish_receipt_persisted_to_disk() {
4316 let td = tempdir().expect("tempdir");
4317 let bin = td.path().join("bin");
4318 write_fake_tools(&bin);
4319 let env_vars = fake_program_env_vars(&bin);
4320 temp_env::with_vars(env_vars, || {
4321 let server = spawn_registry_server(
4322 std::collections::BTreeMap::from([(
4323 "/api/v1/crates/demo/0.1.0".to_string(),
4324 vec![(200, "{}".to_string())],
4325 )]),
4326 1,
4327 );
4328 let ws = planned_workspace(td.path(), server.base_url.clone());
4329 let opts = default_opts(PathBuf::from(".shipper"));
4330
4331 let mut reporter = CollectingReporter::default();
4332 let _ = run_publish(&ws, &opts, &mut reporter).expect("publish");
4333
4334 let state_dir = td.path().join(".shipper");
4335 let receipt_path = state::receipt_path(&state_dir);
4336 assert!(receipt_path.exists());
4337
4338 let receipt_json = fs::read_to_string(&receipt_path).expect("read receipt");
4339 let parsed: Receipt = serde_json::from_str(&receipt_json).expect("parse receipt");
4340 assert_eq!(parsed.plan_id, "plan-demo");
4341 assert_eq!(parsed.receipt_version, "shipper.receipt.v2");
4342 server.join();
4343 });
4344 }
4345
4346 #[test]
4347 #[serial]
4348 fn run_publish_dirty_git_fails_when_not_allowed() {
4349 let td = tempdir().expect("tempdir");
4350 let bin = td.path().join("bin");
4351 write_fake_tools(&bin);
4352 let mut env_vars = fake_program_env_vars(&bin);
4353 env_vars.extend([("SHIPPER_GIT_CLEAN", Some("0".to_string()))]);
4354 temp_env::with_vars(env_vars, || {
4355 let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
4356 let mut opts = default_opts(PathBuf::from(".shipper"));
4357 opts.allow_dirty = false;
4358
4359 let mut reporter = CollectingReporter::default();
4360 let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
4361 let msg = format!("{err:#}");
4362 assert!(
4363 msg.contains("dirty") || msg.contains("uncommitted") || msg.contains("git"),
4364 "unexpected error: {msg}"
4365 );
4366 });
4367 }
4368
4369 #[test]
4370 #[serial]
4371 fn run_publish_state_attempts_counter_increments_on_retry() {
4372 let td = tempdir().expect("tempdir");
4373 let bin = td.path().join("bin");
4374 write_fake_tools(&bin);
4375 with_test_env(
4376 &bin,
4377 vec![
4378 ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
4379 (
4380 "SHIPPER_CARGO_STDERR",
4381 Some("HTTP 503 service unavailable".to_string()),
4382 ),
4383 ],
4384 || {
4385 let server = spawn_registry_server(
4387 std::collections::BTreeMap::from([(
4388 "/api/v1/crates/demo/0.1.0".to_string(),
4389 vec![
4390 (404, "{}".to_string()),
4391 (404, "{}".to_string()),
4392 (404, "{}".to_string()),
4393 (404, "{}".to_string()),
4394 ],
4395 )]),
4396 4,
4397 );
4398 let ws = planned_workspace(td.path(), server.base_url.clone());
4399 let mut opts = default_opts(PathBuf::from(".shipper"));
4400 opts.max_attempts = 2;
4401 opts.base_delay = Duration::from_millis(0);
4402 opts.max_delay = Duration::from_millis(0);
4403
4404 let mut reporter = CollectingReporter::default();
4405 let _ = run_publish(&ws, &opts, &mut reporter);
4406
4407 let st = state::load_state(&td.path().join(".shipper"))
4408 .expect("load")
4409 .expect("exists");
4410 let pkg = st.packages.get("demo@0.1.0").expect("pkg");
4411 assert_eq!(pkg.attempts, 2, "expected 2 attempts");
4412 server.join();
4413 },
4414 );
4415 }
4416
4417 #[test]
4418 #[serial]
4419 fn run_publish_permanent_failure_emits_failed_event() {
4420 let td = tempdir().expect("tempdir");
4421 let bin = td.path().join("bin");
4422 write_fake_tools(&bin);
4423 with_test_env(
4424 &bin,
4425 vec![
4426 ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
4427 (
4428 "SHIPPER_CARGO_STDERR",
4429 Some("permission denied".to_string()),
4430 ),
4431 ],
4432 || {
4433 let server = spawn_registry_server(
4434 std::collections::BTreeMap::from([(
4435 "/api/v1/crates/demo/0.1.0".to_string(),
4436 vec![(404, "{}".to_string()), (404, "{}".to_string())],
4437 )]),
4438 2,
4439 );
4440 let ws = planned_workspace(td.path(), server.base_url.clone());
4441 let mut opts = default_opts(PathBuf::from(".shipper"));
4442 opts.base_delay = Duration::from_millis(0);
4443 opts.max_delay = Duration::from_millis(0);
4444
4445 let mut reporter = CollectingReporter::default();
4446 let _ = run_publish(&ws, &opts, &mut reporter);
4447
4448 let events_path = td.path().join(".shipper").join("events.jsonl");
4449 let log = crate::state::events::EventLog::read_from_file(&events_path)
4450 .expect("read events");
4451 let events = log.all_events();
4452
4453 assert!(
4454 events
4455 .iter()
4456 .any(|e| matches!(e.event_type, EventType::PackageFailed { .. }))
4457 );
4458 assert!(
4459 events
4460 .iter()
4461 .any(|e| matches!(e.event_type, EventType::PackageAttempted { .. }))
4462 );
4463 server.join();
4464 },
4465 );
4466 }
4467
4468 #[test]
4469 #[serial]
4470 fn run_publish_multi_package_first_published_second_skipped() {
4471 let td = tempdir().expect("tempdir");
4472 let bin = td.path().join("bin");
4473 write_fake_tools(&bin);
4474 let mut env_vars = fake_program_env_vars(&bin);
4475 env_vars.extend([("SHIPPER_CARGO_EXIT", Some("0".to_string()))]);
4476 temp_env::with_vars(env_vars, || {
4477 let server = spawn_registry_server(
4478 std::collections::BTreeMap::from([
4479 (
4480 "/api/v1/crates/alpha/1.0.0".to_string(),
4481 vec![(404, "{}".to_string()), (200, "{}".to_string())],
4482 ),
4483 (
4484 "/api/v1/crates/beta/2.0.0".to_string(),
4485 vec![(200, "{}".to_string())],
4486 ),
4487 ]),
4488 3,
4489 );
4490 let mut ws = planned_workspace(td.path(), server.base_url.clone());
4491 ws.plan.packages = vec![
4492 PlannedPackage {
4493 name: "alpha".to_string(),
4494 version: "1.0.0".to_string(),
4495 manifest_path: td.path().join("alpha/Cargo.toml"),
4496 },
4497 PlannedPackage {
4498 name: "beta".to_string(),
4499 version: "2.0.0".to_string(),
4500 manifest_path: td.path().join("beta/Cargo.toml"),
4501 },
4502 ];
4503 let opts = default_opts(PathBuf::from(".shipper"));
4504
4505 let mut reporter = CollectingReporter::default();
4506 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
4507
4508 assert_eq!(receipt.packages.len(), 2);
4509 assert!(matches!(receipt.packages[0].state, PackageState::Published));
4510 assert!(matches!(
4511 receipt.packages[1].state,
4512 PackageState::Skipped { .. }
4513 ));
4514 assert_eq!(receipt.packages[0].name, "alpha");
4515 assert_eq!(receipt.packages[1].name, "beta");
4516 server.join();
4517 });
4518 }
4519
4520 #[test]
4521 fn backoff_delay_linear_strategy() {
4522 let base = Duration::from_millis(100);
4523 let max = Duration::from_millis(500);
4524 let d1 = backoff_delay(base, max, 1, crate::retry::RetryStrategyType::Linear, 0.0);
4525 let d3 = backoff_delay(base, max, 3, crate::retry::RetryStrategyType::Linear, 0.0);
4526 let d20 = backoff_delay(base, max, 20, crate::retry::RetryStrategyType::Linear, 0.0);
4527
4528 assert_eq!(d1, Duration::from_millis(100));
4529 assert!(d3 > d1);
4530 assert!(d20 <= max, "linear delay should be capped at max");
4531 }
4532
4533 #[test]
4534 fn backoff_delay_constant_strategy() {
4535 let base = Duration::from_millis(200);
4536 let max = Duration::from_millis(1000);
4537 let d1 = backoff_delay(base, max, 1, crate::retry::RetryStrategyType::Constant, 0.0);
4538 let d5 = backoff_delay(base, max, 5, crate::retry::RetryStrategyType::Constant, 0.0);
4539 let d10 = backoff_delay(
4540 base,
4541 max,
4542 10,
4543 crate::retry::RetryStrategyType::Constant,
4544 0.0,
4545 );
4546
4547 assert_eq!(d1, base);
4548 assert_eq!(d5, base);
4549 assert_eq!(d10, base);
4550 }
4551
4552 #[test]
4553 fn backoff_delay_immediate_strategy() {
4554 let base = Duration::from_millis(200);
4555 let max = Duration::from_millis(1000);
4556 let d1 = backoff_delay(
4557 base,
4558 max,
4559 1,
4560 crate::retry::RetryStrategyType::Immediate,
4561 0.0,
4562 );
4563 let d5 = backoff_delay(
4564 base,
4565 max,
4566 5,
4567 crate::retry::RetryStrategyType::Immediate,
4568 0.0,
4569 );
4570
4571 assert_eq!(d1, Duration::ZERO);
4572 assert_eq!(d5, Duration::ZERO);
4573 }
4574
4575 #[test]
4576 fn backoff_delay_exponential_zero_jitter_is_deterministic() {
4577 let base = Duration::from_millis(100);
4578 let max = Duration::from_secs(10);
4579 let d1a = backoff_delay(
4580 base,
4581 max,
4582 1,
4583 crate::retry::RetryStrategyType::Exponential,
4584 0.0,
4585 );
4586 let d1b = backoff_delay(
4587 base,
4588 max,
4589 1,
4590 crate::retry::RetryStrategyType::Exponential,
4591 0.0,
4592 );
4593 assert_eq!(d1a, d1b);
4594 assert_eq!(d1a, base);
4595 }
4596
4597 #[test]
4598 fn classify_cargo_failure_rate_limit() {
4599 let (class, _msg) = classify_cargo_failure("HTTP 429 too many requests", "");
4600 assert_eq!(class, ErrorClass::Retryable);
4601 }
4602
4603 #[test]
4604 fn classify_cargo_failure_timeout() {
4605 let (class, _msg) = classify_cargo_failure("timeout talking to server", "");
4606 assert_eq!(class, ErrorClass::Retryable);
4607 }
4608
4609 #[test]
4610 fn classify_cargo_failure_service_unavailable() {
4611 let (class, _msg) = classify_cargo_failure("HTTP 503 service unavailable", "");
4612 assert_eq!(class, ErrorClass::Retryable);
4613 }
4614
4615 #[test]
4616 fn classify_cargo_failure_auth_failure() {
4617 let (class, _msg) = classify_cargo_failure("permission denied", "");
4618 assert_eq!(class, ErrorClass::Permanent);
4619 }
4620
4621 #[test]
4622 fn classify_cargo_failure_unknown_error_is_ambiguous() {
4623 let (class, _msg) = classify_cargo_failure("something totally unexpected", "");
4624 assert_eq!(class, ErrorClass::Ambiguous);
4625 }
4626
4627 #[test]
4628 fn short_state_covers_all_variants() {
4629 assert_eq!(short_state(&PackageState::Pending), "pending");
4630 assert_eq!(short_state(&PackageState::Uploaded), "uploaded");
4631 assert_eq!(short_state(&PackageState::Published), "published");
4632 assert_eq!(
4633 short_state(&PackageState::Skipped {
4634 reason: "already published".to_string()
4635 }),
4636 "skipped"
4637 );
4638 assert_eq!(
4639 short_state(&PackageState::Failed {
4640 class: ErrorClass::Retryable,
4641 message: "timeout".to_string()
4642 }),
4643 "failed"
4644 );
4645 assert_eq!(
4646 short_state(&PackageState::Failed {
4647 class: ErrorClass::Ambiguous,
4648 message: "unknown".to_string()
4649 }),
4650 "failed"
4651 );
4652 assert_eq!(
4653 short_state(&PackageState::Ambiguous {
4654 message: "not sure".to_string()
4655 }),
4656 "ambiguous"
4657 );
4658 }
4659
4660 #[test]
4661 fn pkg_key_formats_correctly() {
4662 assert_eq!(pkg_key("my-crate", "1.2.3"), "my-crate@1.2.3");
4663 assert_eq!(pkg_key("a", "0.0.1"), "a@0.0.1");
4664 assert_eq!(pkg_key("foo_bar-baz", "10.20.30"), "foo_bar-baz@10.20.30");
4665 }
4666
4667 #[test]
4668 #[serial]
4669 fn run_publish_skipped_package_receipt_has_empty_evidence() {
4670 let td = tempdir().expect("tempdir");
4671 let bin = td.path().join("bin");
4672 write_fake_tools(&bin);
4673 let env_vars = fake_program_env_vars(&bin);
4674 temp_env::with_vars(env_vars, || {
4675 let server = spawn_registry_server(
4676 std::collections::BTreeMap::from([(
4677 "/api/v1/crates/demo/0.1.0".to_string(),
4678 vec![(200, "{}".to_string())],
4679 )]),
4680 1,
4681 );
4682 let ws = planned_workspace(td.path(), server.base_url.clone());
4683 let opts = default_opts(PathBuf::from(".shipper"));
4684
4685 let mut reporter = CollectingReporter::default();
4686 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
4687
4688 assert_eq!(receipt.packages.len(), 1);
4689 assert!(matches!(
4690 receipt.packages[0].state,
4691 PackageState::Skipped { .. }
4692 ));
4693 assert!(
4694 receipt.packages[0].evidence.attempts.is_empty(),
4695 "skipped packages should have no attempt evidence"
4696 );
4697 assert!(
4698 receipt.packages[0].evidence.readiness_checks.is_empty(),
4699 "skipped packages should have no readiness evidence"
4700 );
4701 server.join();
4702 });
4703 }
4704
4705 #[test]
4706 #[serial]
4707 fn run_publish_execution_result_is_success_when_all_published() {
4708 let td = tempdir().expect("tempdir");
4709 let bin = td.path().join("bin");
4710 write_fake_tools(&bin);
4711 let mut env_vars = fake_program_env_vars(&bin);
4712 env_vars.extend([("SHIPPER_CARGO_EXIT", Some("0".to_string()))]);
4713 temp_env::with_vars(env_vars, || {
4714 let server = spawn_registry_server(
4715 std::collections::BTreeMap::from([(
4716 "/api/v1/crates/demo/0.1.0".to_string(),
4717 vec![(404, "{}".to_string()), (200, "{}".to_string())],
4718 )]),
4719 2,
4720 );
4721 let ws = planned_workspace(td.path(), server.base_url.clone());
4722 let opts = default_opts(PathBuf::from(".shipper"));
4723
4724 let mut reporter = CollectingReporter::default();
4725 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
4726
4727 assert!(receipt.event_log_path.exists());
4729 let log = crate::state::events::EventLog::read_from_file(&receipt.event_log_path)
4730 .expect("events");
4731 let finish_events: Vec<_> = log
4732 .all_events()
4733 .iter()
4734 .filter(|e| matches!(e.event_type, EventType::ExecutionFinished { .. }))
4735 .collect();
4736 assert_eq!(finish_events.len(), 1);
4737 if let EventType::ExecutionFinished { result } = &finish_events[0].event_type {
4738 assert!(
4739 matches!(result, ExecutionResult::Success),
4740 "expected Success, got {result:?}"
4741 );
4742 }
4743 server.join();
4744 });
4745 }
4746
4747 #[test]
4748 fn run_publish_force_skips_lock_timeout() {
4749 let td = tempdir().expect("tempdir");
4752 let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
4753 let state_dir = td.path().join(".shipper");
4754
4755 let mut packages = std::collections::BTreeMap::new();
4757 packages.insert(
4758 "demo@0.1.0".to_string(),
4759 PackageProgress {
4760 name: "demo".to_string(),
4761 version: "0.1.0".to_string(),
4762 attempts: 1,
4763 state: PackageState::Published,
4764 last_updated_at: Utc::now(),
4765 },
4766 );
4767 let st = ExecutionState {
4768 state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
4769 plan_id: ws.plan.plan_id.clone(),
4770 registry: ws.plan.registry.clone(),
4771 created_at: Utc::now(),
4772 updated_at: Utc::now(),
4773 packages,
4774 };
4775 state::save_state(&state_dir, &st).expect("save");
4776
4777 let mut opts = default_opts(PathBuf::from(".shipper"));
4778 opts.force = true;
4779
4780 let mut reporter = CollectingReporter::default();
4781 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
4782 assert!(receipt.packages.is_empty());
4783 }
4784
4785 #[test]
4786 #[serial]
4787 fn run_publish_resume_from_skips_before_and_warns() {
4788 let td = tempdir().expect("tempdir");
4789 let bin = td.path().join("bin");
4790 write_fake_tools(&bin);
4791 let mut env_vars = fake_program_env_vars(&bin);
4792 env_vars.extend([("SHIPPER_CARGO_EXIT", Some("0".to_string()))]);
4793 temp_env::with_vars(env_vars, || {
4794 let server = spawn_registry_server(
4795 std::collections::BTreeMap::from([
4796 (
4797 "/api/v1/crates/alpha/1.0.0".to_string(),
4798 vec![(404, "{}".to_string())],
4799 ),
4800 (
4801 "/api/v1/crates/beta/2.0.0".to_string(),
4802 vec![(404, "{}".to_string()), (200, "{}".to_string())],
4803 ),
4804 ]),
4805 2,
4806 );
4807
4808 let mut ws = planned_workspace(td.path(), server.base_url.clone());
4809 ws.plan.packages = vec![
4810 PlannedPackage {
4811 name: "alpha".to_string(),
4812 version: "1.0.0".to_string(),
4813 manifest_path: td.path().join("alpha/Cargo.toml"),
4814 },
4815 PlannedPackage {
4816 name: "beta".to_string(),
4817 version: "2.0.0".to_string(),
4818 manifest_path: td.path().join("beta/Cargo.toml"),
4819 },
4820 ];
4821 let mut opts = default_opts(PathBuf::from(".shipper"));
4822 opts.resume_from = Some("beta".to_string());
4823
4824 let mut reporter = CollectingReporter::default();
4825 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
4826
4827 assert_eq!(receipt.packages.len(), 1);
4829 assert_eq!(receipt.packages[0].name, "beta");
4830
4831 assert!(
4833 reporter
4834 .warns
4835 .iter()
4836 .any(|w| w.contains("skipping") && w.contains("before resume point")),
4837 "expected warning about skipping alpha, got: {:?}",
4838 reporter.warns
4839 );
4840 server.join();
4841 });
4842 }
4843
4844 #[test]
4845 #[serial]
4846 fn run_publish_resume_from_already_done_skipped_silently() {
4847 let td = tempdir().expect("tempdir");
4848 let bin = td.path().join("bin");
4849 write_fake_tools(&bin);
4850 let mut env_vars = fake_program_env_vars(&bin);
4851 env_vars.extend([("SHIPPER_CARGO_EXIT", Some("0".to_string()))]);
4852 temp_env::with_vars(env_vars, || {
4853 let server = spawn_registry_server(
4854 std::collections::BTreeMap::from([(
4855 "/api/v1/crates/beta/2.0.0".to_string(),
4856 vec![(404, "{}".to_string()), (200, "{}".to_string())],
4857 )]),
4858 2,
4859 );
4860
4861 let mut ws = planned_workspace(td.path(), server.base_url.clone());
4862 ws.plan.packages = vec![
4863 PlannedPackage {
4864 name: "alpha".to_string(),
4865 version: "1.0.0".to_string(),
4866 manifest_path: td.path().join("alpha/Cargo.toml"),
4867 },
4868 PlannedPackage {
4869 name: "beta".to_string(),
4870 version: "2.0.0".to_string(),
4871 manifest_path: td.path().join("beta/Cargo.toml"),
4872 },
4873 ];
4874
4875 let state_dir = td.path().join(".shipper");
4877 let mut packages = std::collections::BTreeMap::new();
4878 packages.insert(
4879 "alpha@1.0.0".to_string(),
4880 PackageProgress {
4881 name: "alpha".to_string(),
4882 version: "1.0.0".to_string(),
4883 attempts: 1,
4884 state: PackageState::Published,
4885 last_updated_at: Utc::now(),
4886 },
4887 );
4888 let st = ExecutionState {
4889 state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
4890 plan_id: ws.plan.plan_id.clone(),
4891 registry: ws.plan.registry.clone(),
4892 created_at: Utc::now(),
4893 updated_at: Utc::now(),
4894 packages,
4895 };
4896 state::save_state(&state_dir, &st).expect("save");
4897
4898 let mut opts = default_opts(PathBuf::from(".shipper"));
4899 opts.resume_from = Some("beta".to_string());
4900
4901 let mut reporter = CollectingReporter::default();
4902 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
4903
4904 assert_eq!(receipt.packages.len(), 1);
4906 assert_eq!(receipt.packages[0].name, "beta");
4907
4908 assert!(
4910 reporter
4911 .infos
4912 .iter()
4913 .any(|i| i.contains("already complete") && i.contains("alpha")),
4914 "expected info about alpha being already complete, got: {:?}",
4915 reporter.infos
4916 );
4917 server.join();
4918 });
4919 }
4920
4921 #[test]
4922 fn update_state_transitions_correctly() {
4923 let td = tempdir().expect("tempdir");
4924 let state_dir = td.path().join(".shipper");
4925 let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
4926
4927 let mut st = init_state(&ws, &state_dir).expect("init");
4928 let key = "demo@0.1.0";
4929
4930 update_state(&mut st, &state_dir, key, PackageState::Uploaded).expect("update");
4932 assert!(matches!(
4933 st.packages.get(key).unwrap().state,
4934 PackageState::Uploaded
4935 ));
4936
4937 update_state(&mut st, &state_dir, key, PackageState::Published).expect("update");
4939 assert!(matches!(
4940 st.packages.get(key).unwrap().state,
4941 PackageState::Published
4942 ));
4943
4944 let loaded = state::load_state(&state_dir)
4946 .expect("load")
4947 .expect("exists");
4948 assert!(matches!(
4949 loaded.packages.get(key).unwrap().state,
4950 PackageState::Published
4951 ));
4952 }
4953
4954 #[test]
4955 fn update_state_to_failed() {
4956 let td = tempdir().expect("tempdir");
4957 let state_dir = td.path().join(".shipper");
4958 let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
4959
4960 let mut st = init_state(&ws, &state_dir).expect("init");
4961 let key = "demo@0.1.0";
4962
4963 let failed = PackageState::Failed {
4964 class: ErrorClass::Permanent,
4965 message: "auth failure".to_string(),
4966 };
4967 update_state(&mut st, &state_dir, key, failed).expect("update");
4968
4969 let pkg = st.packages.get(key).unwrap();
4970 match &pkg.state {
4971 PackageState::Failed { class, message } => {
4972 assert_eq!(*class, ErrorClass::Permanent);
4973 assert_eq!(message, "auth failure");
4974 }
4975 other => panic!("expected Failed, got {other:?}"),
4976 }
4977 }
4978
4979 #[test]
4980 fn update_state_to_skipped() {
4981 let td = tempdir().expect("tempdir");
4982 let state_dir = td.path().join(".shipper");
4983 let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
4984
4985 let mut st = init_state(&ws, &state_dir).expect("init");
4986 let key = "demo@0.1.0";
4987
4988 let skipped = PackageState::Skipped {
4989 reason: "already published".to_string(),
4990 };
4991 update_state(&mut st, &state_dir, key, skipped).expect("update");
4992
4993 match &st.packages.get(key).unwrap().state {
4994 PackageState::Skipped { reason } => {
4995 assert_eq!(reason, "already published");
4996 }
4997 other => panic!("expected Skipped, got {other:?}"),
4998 }
4999 }
5000
5001 #[test]
5002 fn receipt_serialization_roundtrip() {
5003 let receipt = Receipt {
5004 receipt_version: "shipper.receipt.v2".to_string(),
5005 plan_id: "plan-test-123".to_string(),
5006 registry: Registry {
5007 name: "crates-io".to_string(),
5008 api_base: "https://crates.io".to_string(),
5009 index_base: None,
5010 },
5011 started_at: Utc::now(),
5012 finished_at: Utc::now(),
5013 packages: vec![
5014 PackageReceipt {
5015 name: "alpha".to_string(),
5016 version: "1.0.0".to_string(),
5017 attempts: 1,
5018 state: PackageState::Published,
5019 started_at: Utc::now(),
5020 finished_at: Utc::now(),
5021 duration_ms: 1234,
5022 evidence: crate::types::PackageEvidence {
5023 attempts: vec![AttemptEvidence {
5024 attempt_number: 1,
5025 command: "cargo publish -p alpha".to_string(),
5026 exit_code: 0,
5027 stdout_tail: "Uploading alpha".to_string(),
5028 stderr_tail: String::new(),
5029 timestamp: Utc::now(),
5030 duration: Duration::from_millis(500),
5031 }],
5032 readiness_checks: vec![ReadinessEvidence {
5033 attempt: 1,
5034 visible: true,
5035 timestamp: Utc::now(),
5036 delay_before: Duration::from_millis(100),
5037 }],
5038 },
5039 compromised_at: None,
5040 compromised_by: None,
5041 superseded_by: None,
5042 },
5043 PackageReceipt {
5044 name: "beta".to_string(),
5045 version: "2.0.0".to_string(),
5046 attempts: 0,
5047 state: PackageState::Skipped {
5048 reason: "already published".to_string(),
5049 },
5050 started_at: Utc::now(),
5051 finished_at: Utc::now(),
5052 duration_ms: 10,
5053 evidence: crate::types::PackageEvidence {
5054 attempts: vec![],
5055 readiness_checks: vec![],
5056 },
5057 compromised_at: None,
5058 compromised_by: None,
5059 superseded_by: None,
5060 },
5061 ],
5062 event_log_path: PathBuf::from(".shipper/events.jsonl"),
5063 git_context: None,
5064 environment: environment::collect_environment_fingerprint(),
5065 };
5066
5067 let json = serde_json::to_string_pretty(&receipt).expect("serialize");
5068 let parsed: Receipt = serde_json::from_str(&json).expect("deserialize");
5069 assert_eq!(parsed.plan_id, receipt.plan_id);
5070 assert_eq!(parsed.receipt_version, receipt.receipt_version);
5071 assert_eq!(parsed.packages.len(), 2);
5072 assert!(matches!(parsed.packages[0].state, PackageState::Published));
5073 assert!(matches!(
5074 parsed.packages[1].state,
5075 PackageState::Skipped { .. }
5076 ));
5077 assert_eq!(parsed.packages[0].evidence.attempts.len(), 1);
5078 assert_eq!(parsed.packages[0].evidence.readiness_checks.len(), 1);
5079 }
5080
5081 #[test]
5082 fn execution_state_serialization_roundtrip() {
5083 let mut packages = BTreeMap::new();
5084 packages.insert(
5085 "demo@0.1.0".to_string(),
5086 PackageProgress {
5087 name: "demo".to_string(),
5088 version: "0.1.0".to_string(),
5089 attempts: 3,
5090 state: PackageState::Failed {
5091 class: ErrorClass::Retryable,
5092 message: "timeout".to_string(),
5093 },
5094 last_updated_at: Utc::now(),
5095 },
5096 );
5097 let st = ExecutionState {
5098 state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
5099 plan_id: "plan-serde-test".to_string(),
5100 registry: Registry {
5101 name: "crates-io".to_string(),
5102 api_base: "https://crates.io".to_string(),
5103 index_base: None,
5104 },
5105 created_at: Utc::now(),
5106 updated_at: Utc::now(),
5107 packages,
5108 };
5109
5110 let json = serde_json::to_string(&st).expect("serialize");
5111 let parsed: ExecutionState = serde_json::from_str(&json).expect("deserialize");
5112 assert_eq!(parsed.plan_id, st.plan_id);
5113 assert_eq!(parsed.packages.len(), 1);
5114 let pkg = parsed.packages.get("demo@0.1.0").expect("pkg");
5115 assert_eq!(pkg.attempts, 3);
5116 match &pkg.state {
5117 PackageState::Failed { class, message } => {
5118 assert_eq!(*class, ErrorClass::Retryable);
5119 assert_eq!(message, "timeout");
5120 }
5121 other => panic!("expected Failed, got {other:?}"),
5122 }
5123 }
5124
5125 #[test]
5126 fn collecting_reporter_tracks_all_message_types() {
5127 let mut reporter = CollectingReporter::default();
5128 reporter.info("info-1");
5129 reporter.info("info-2");
5130 reporter.warn("warn-1");
5131 reporter.error("error-1");
5132 reporter.error("error-2");
5133 reporter.error("error-3");
5134
5135 assert_eq!(reporter.infos.len(), 2);
5136 assert_eq!(reporter.warns.len(), 1);
5137 assert_eq!(reporter.errors.len(), 3);
5138 assert_eq!(reporter.infos[0], "info-1");
5139 assert_eq!(reporter.warns[0], "warn-1");
5140 assert_eq!(reporter.errors[2], "error-3");
5141 }
5142
5143 #[test]
5144 fn verify_published_disabled_does_single_check() {
5145 let server = spawn_registry_server(
5147 std::collections::BTreeMap::from([(
5148 "/api/v1/crates/demo/0.1.0".to_string(),
5149 vec![(200, "{}".to_string())],
5150 )]),
5151 1,
5152 );
5153 let reg = RegistryClient::new(Registry {
5154 name: "crates-io".to_string(),
5155 api_base: server.base_url.clone(),
5156 index_base: None,
5157 })
5158 .expect("client");
5159
5160 let config = crate::types::ReadinessConfig {
5161 enabled: false,
5162 method: crate::types::ReadinessMethod::Api,
5163 initial_delay: Duration::from_millis(0),
5164 max_delay: Duration::from_millis(10),
5165 max_total_wait: Duration::from_millis(0),
5166 poll_interval: Duration::from_millis(1),
5167 jitter_factor: 0.0,
5168 index_path: None,
5169 prefer_index: false,
5170 };
5171
5172 let mut reporter = CollectingReporter::default();
5173 let (ok, evidence) =
5174 verify_published(®, "demo", "0.1.0", &config, &mut reporter).expect("verify");
5175 assert!(
5176 ok,
5177 "disabled readiness with 200 response should return true"
5178 );
5179 assert_eq!(
5180 evidence.len(),
5181 1,
5182 "disabled readiness does exactly one check"
5183 );
5184 assert!(evidence[0].visible);
5185 server.join();
5186 }
5187
5188 #[test]
5189 #[serial]
5190 fn run_publish_already_published_packages_skipped_in_state() {
5191 let td = tempdir().expect("tempdir");
5192 let bin = td.path().join("bin");
5193 write_fake_tools(&bin);
5194 let env_vars = fake_program_env_vars(&bin);
5195 temp_env::with_vars(env_vars, || {
5196 let server = spawn_registry_server(
5197 std::collections::BTreeMap::from([(
5198 "/api/v1/crates/demo/0.1.0".to_string(),
5199 vec![(200, "{}".to_string())],
5200 )]),
5201 1,
5202 );
5203 let ws = planned_workspace(td.path(), server.base_url.clone());
5204 let opts = default_opts(PathBuf::from(".shipper"));
5205
5206 let mut reporter = CollectingReporter::default();
5207 let _ = run_publish(&ws, &opts, &mut reporter).expect("publish");
5208
5209 let st = state::load_state(&td.path().join(".shipper"))
5211 .expect("load")
5212 .expect("exists");
5213 let pkg = st.packages.get("demo@0.1.0").expect("pkg");
5214 assert!(
5215 matches!(pkg.state, PackageState::Skipped { .. }),
5216 "expected Skipped in state, got {:?}",
5217 pkg.state
5218 );
5219 server.join();
5220 });
5221 }
5222
5223 #[test]
5224 fn policy_effects_default_options() {
5225 let opts = default_opts(PathBuf::from(".shipper"));
5226 let effects = policy_effects(&opts);
5227 assert!(effects.run_dry_run);
5230 }
5231
5232 #[test]
5235 #[serial]
5236 fn run_publish_zero_max_attempts_skips_publish_loop() {
5237 let td = tempdir().expect("tempdir");
5238 let bin = td.path().join("bin");
5239 write_fake_tools(&bin);
5240 with_test_env(
5241 &bin,
5242 vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
5243 || {
5244 let server = spawn_registry_server(
5247 std::collections::BTreeMap::from([(
5248 "/api/v1/crates/demo/0.1.0".to_string(),
5249 vec![(404, "{}".to_string())],
5250 )]),
5251 1,
5252 );
5253 let ws = planned_workspace(td.path(), server.base_url.clone());
5254 let mut opts = default_opts(PathBuf::from(".shipper"));
5255 opts.max_attempts = 0;
5256
5257 let mut reporter = CollectingReporter::default();
5258 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
5259
5260 assert_eq!(receipt.packages.len(), 1);
5262 assert!(
5263 matches!(receipt.packages[0].state, PackageState::Pending),
5264 "expected Pending with 0 max_attempts, got {:?}",
5265 receipt.packages[0].state
5266 );
5267
5268 let st = state::load_state(&td.path().join(".shipper"))
5269 .expect("load")
5270 .expect("exists");
5271 let pkg = st.packages.get("demo@0.1.0").expect("pkg");
5272 assert_eq!(pkg.attempts, 0, "no attempts should have been made");
5273 server.join();
5274 },
5275 );
5276 }
5277
5278 #[test]
5279 #[serial]
5280 fn run_publish_max_retries_exceeded_marks_failed() {
5281 let td = tempdir().expect("tempdir");
5282 let bin = td.path().join("bin");
5283 write_fake_tools(&bin);
5284 with_test_env(
5285 &bin,
5286 vec![
5287 ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
5288 (
5289 "SHIPPER_CARGO_STDERR",
5290 Some("HTTP 503 service unavailable".to_string()),
5291 ),
5292 ],
5293 || {
5294 let server = spawn_registry_server(
5296 std::collections::BTreeMap::from([(
5297 "/api/v1/crates/demo/0.1.0".to_string(),
5298 vec![
5299 (404, "{}".to_string()),
5300 (404, "{}".to_string()),
5301 (404, "{}".to_string()),
5302 (404, "{}".to_string()),
5303 (404, "{}".to_string()),
5304 ],
5305 )]),
5306 5,
5307 );
5308 let ws = planned_workspace(td.path(), server.base_url.clone());
5309 let mut opts = default_opts(PathBuf::from(".shipper"));
5310 opts.max_attempts = 3;
5311 opts.base_delay = Duration::from_millis(0);
5312 opts.max_delay = Duration::from_millis(0);
5313
5314 let mut reporter = CollectingReporter::default();
5315 let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
5316 assert!(format!("{err:#}").contains("failed"));
5317
5318 let st = state::load_state(&td.path().join(".shipper"))
5319 .expect("load")
5320 .expect("exists");
5321 let pkg = st.packages.get("demo@0.1.0").expect("pkg");
5322 assert_eq!(pkg.attempts, 3, "should have exhausted all 3 attempts");
5323 assert!(
5324 matches!(pkg.state, PackageState::Failed { .. }),
5325 "expected Failed, got {:?}",
5326 pkg.state
5327 );
5328 server.join();
5329 },
5330 );
5331 }
5332
5333 #[test]
5334 #[serial]
5335 fn run_publish_single_attempt_succeeds_on_first_try() {
5336 let td = tempdir().expect("tempdir");
5337 let bin = td.path().join("bin");
5338 write_fake_tools(&bin);
5339 with_test_env(
5340 &bin,
5341 vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
5342 || {
5343 let server = spawn_registry_server(
5344 std::collections::BTreeMap::from([(
5345 "/api/v1/crates/demo/0.1.0".to_string(),
5346 vec![(404, "{}".to_string()), (200, "{}".to_string())],
5347 )]),
5348 2,
5349 );
5350 let ws = planned_workspace(td.path(), server.base_url.clone());
5351 let mut opts = default_opts(PathBuf::from(".shipper"));
5352 opts.max_attempts = 1;
5353
5354 let mut reporter = CollectingReporter::default();
5355 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
5356 assert_eq!(receipt.packages[0].attempts, 1);
5357 assert!(matches!(receipt.packages[0].state, PackageState::Published));
5358 server.join();
5359 },
5360 );
5361 }
5362
5363 #[test]
5366 fn state_transition_pending_to_uploaded_persists() {
5367 let td = tempdir().expect("tempdir");
5368 let state_dir = td.path().join(".shipper");
5369 let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
5370
5371 let mut st = init_state(&ws, &state_dir).expect("init");
5372 let key = "demo@0.1.0";
5373
5374 update_state(&mut st, &state_dir, key, PackageState::Uploaded).expect("update");
5375
5376 assert!(matches!(
5378 st.packages.get(key).unwrap().state,
5379 PackageState::Uploaded
5380 ));
5381
5382 let loaded = state::load_state(&state_dir)
5384 .expect("load")
5385 .expect("exists");
5386 assert!(matches!(
5387 loaded.packages.get(key).unwrap().state,
5388 PackageState::Uploaded
5389 ));
5390 }
5391
5392 #[test]
5393 fn state_transition_uploaded_to_published_persists() {
5394 let td = tempdir().expect("tempdir");
5395 let state_dir = td.path().join(".shipper");
5396 let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
5397
5398 let mut st = init_state(&ws, &state_dir).expect("init");
5399 let key = "demo@0.1.0";
5400
5401 update_state(&mut st, &state_dir, key, PackageState::Uploaded).expect("upload");
5403 update_state(&mut st, &state_dir, key, PackageState::Published).expect("publish");
5404
5405 let loaded = state::load_state(&state_dir)
5406 .expect("load")
5407 .expect("exists");
5408 assert!(matches!(
5409 loaded.packages.get(key).unwrap().state,
5410 PackageState::Published
5411 ));
5412 }
5413
5414 #[test]
5415 fn state_transition_pending_to_failed_persists() {
5416 let td = tempdir().expect("tempdir");
5417 let state_dir = td.path().join(".shipper");
5418 let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
5419
5420 let mut st = init_state(&ws, &state_dir).expect("init");
5421 let key = "demo@0.1.0";
5422
5423 let failed = PackageState::Failed {
5424 class: ErrorClass::Retryable,
5425 message: "service unavailable".to_string(),
5426 };
5427 update_state(&mut st, &state_dir, key, failed).expect("update");
5428
5429 let loaded = state::load_state(&state_dir)
5430 .expect("load")
5431 .expect("exists");
5432 match &loaded.packages.get(key).unwrap().state {
5433 PackageState::Failed { class, message } => {
5434 assert_eq!(*class, ErrorClass::Retryable);
5435 assert_eq!(message, "service unavailable");
5436 }
5437 other => panic!("expected Failed, got {other:?}"),
5438 }
5439 }
5440
5441 #[test]
5442 fn state_updated_at_advances_on_transition() {
5443 let td = tempdir().expect("tempdir");
5444 let state_dir = td.path().join(".shipper");
5445 let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
5446
5447 let mut st = init_state(&ws, &state_dir).expect("init");
5448 let key = "demo@0.1.0";
5449 let initial_updated = st.updated_at;
5450
5451 thread::sleep(Duration::from_millis(10));
5453
5454 update_state(&mut st, &state_dir, key, PackageState::Uploaded).expect("update");
5455 assert!(st.updated_at > initial_updated);
5456
5457 let pkg = st.packages.get(key).unwrap();
5458 assert!(pkg.last_updated_at >= initial_updated);
5459 }
5460
5461 #[test]
5464 fn classify_cargo_failure_connection_refused_is_retryable() {
5465 let (class, _) = classify_cargo_failure("connection refused", "");
5466 assert_eq!(class, ErrorClass::Retryable);
5467 }
5468
5469 #[test]
5470 fn classify_cargo_failure_500_is_retryable() {
5471 let (class, _) = classify_cargo_failure("HTTP 500 internal server error", "");
5472 assert_eq!(class, ErrorClass::Retryable);
5473 }
5474
5475 #[test]
5476 fn classify_cargo_failure_version_exists_is_permanent() {
5477 let (class, _) = classify_cargo_failure("crate version `0.1.0` is already uploaded", "");
5478 assert_eq!(class, ErrorClass::Permanent);
5479 }
5480
5481 #[test]
5482 fn classify_cargo_failure_empty_output_is_ambiguous() {
5483 let (class, _) = classify_cargo_failure("", "");
5484 assert_eq!(class, ErrorClass::Ambiguous);
5485 }
5486
5487 #[test]
5488 fn classify_cargo_failure_message_is_nonempty() {
5489 let (_, msg) = classify_cargo_failure("timeout talking to server", "");
5490 assert!(!msg.is_empty(), "error message should be nonempty");
5491 }
5492
5493 #[test]
5494 fn classify_cargo_failure_stderr_vs_stdout() {
5495 let (class_stderr, _) = classify_cargo_failure("HTTP 429 too many requests", "");
5497 let (class_stdout, _) = classify_cargo_failure("", "HTTP 429 too many requests");
5498 assert_eq!(class_stderr, ErrorClass::Retryable);
5499 assert!(
5501 class_stdout == ErrorClass::Retryable || class_stdout == ErrorClass::Ambiguous,
5502 "expected Retryable or Ambiguous from stdout, got {class_stdout:?}"
5503 );
5504 }
5505
5506 fn multi_package_workspace(
5510 workspace_root: &Path,
5511 api_base: String,
5512 packages: Vec<(&str, &str)>,
5513 ) -> PlannedWorkspace {
5514 PlannedWorkspace {
5515 workspace_root: workspace_root.to_path_buf(),
5516 plan: ReleasePlan {
5517 plan_version: "1".to_string(),
5518 plan_id: "plan-sm-test".to_string(),
5519 created_at: Utc::now(),
5520 registry: Registry {
5521 name: "crates-io".to_string(),
5522 api_base,
5523 index_base: None,
5524 },
5525 packages: packages
5526 .iter()
5527 .map(|(name, ver)| PlannedPackage {
5528 name: name.to_string(),
5529 version: ver.to_string(),
5530 manifest_path: workspace_root.join(*name).join("Cargo.toml"),
5531 })
5532 .collect(),
5533 dependencies: std::collections::BTreeMap::new(),
5534 },
5535 skipped: vec![],
5536 }
5537 }
5538
5539 #[test]
5541 #[serial]
5542 fn sm_pending_to_published_happy_path() {
5543 let td = tempdir().expect("tempdir");
5544 let bin = td.path().join("bin");
5545 write_fake_tools(&bin);
5546 with_test_env(
5547 &bin,
5548 vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
5549 || {
5550 let server = spawn_registry_server(
5551 std::collections::BTreeMap::from([(
5552 "/api/v1/crates/demo/0.1.0".to_string(),
5553 vec![(404, "{}".to_string()), (200, "{}".to_string())],
5554 )]),
5555 2,
5556 );
5557 let ws = planned_workspace(td.path(), server.base_url.clone());
5558 let opts = default_opts(PathBuf::from(".shipper"));
5559
5560 let mut reporter = CollectingReporter::default();
5561 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
5562
5563 assert_eq!(receipt.packages.len(), 1);
5564 assert!(matches!(receipt.packages[0].state, PackageState::Published));
5565 assert_eq!(receipt.packages[0].attempts, 1);
5566
5567 let st = state::load_state(&td.path().join(".shipper"))
5569 .expect("load")
5570 .expect("exists");
5571 let pkg = st.packages.get("demo@0.1.0").expect("pkg");
5572 assert!(matches!(pkg.state, PackageState::Published));
5573 server.join();
5574 },
5575 );
5576 }
5577
5578 #[test]
5580 #[serial]
5581 fn sm_pending_uploaded_verified_two_phase() {
5582 let td = tempdir().expect("tempdir");
5583 let bin = td.path().join("bin");
5584 write_fake_tools(&bin);
5585 with_test_env(
5586 &bin,
5587 vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
5588 || {
5589 let server = spawn_registry_server(
5591 std::collections::BTreeMap::from([(
5592 "/api/v1/crates/demo/0.1.0".to_string(),
5593 vec![(404, "{}".to_string()), (200, "{}".to_string())],
5594 )]),
5595 2,
5596 );
5597 let ws = planned_workspace(td.path(), server.base_url.clone());
5598 let opts = default_opts(PathBuf::from(".shipper"));
5599
5600 let mut reporter = CollectingReporter::default();
5601 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
5602
5603 assert!(matches!(receipt.packages[0].state, PackageState::Published));
5604 assert!(reporter.infos.iter().any(|i| i.contains("publishing")));
5606 assert!(
5607 reporter
5608 .infos
5609 .iter()
5610 .any(|i| i.contains("verifying") || i.contains("visible"))
5611 );
5612 server.join();
5613 },
5614 );
5615 }
5616
5617 #[test]
5619 #[serial]
5620 fn sm_pending_to_failed_permanent_no_retry() {
5621 let td = tempdir().expect("tempdir");
5622 let bin = td.path().join("bin");
5623 write_fake_tools(&bin);
5624 with_test_env(
5625 &bin,
5626 vec![
5627 ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
5628 (
5629 "SHIPPER_CARGO_STDERR",
5630 Some("permission denied".to_string()),
5631 ),
5632 ],
5633 || {
5634 let server = spawn_registry_server(
5635 std::collections::BTreeMap::from([(
5636 "/api/v1/crates/demo/0.1.0".to_string(),
5637 vec![(404, "{}".to_string()), (404, "{}".to_string())],
5638 )]),
5639 2,
5640 );
5641 let ws = planned_workspace(td.path(), server.base_url.clone());
5642 let mut opts = default_opts(PathBuf::from(".shipper"));
5643 opts.max_attempts = 5; opts.base_delay = Duration::from_millis(0);
5645 opts.max_delay = Duration::from_millis(0);
5646
5647 let mut reporter = CollectingReporter::default();
5648 let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
5649 assert!(format!("{err:#}").contains("permanent failure"));
5650
5651 let st = state::load_state(&td.path().join(".shipper"))
5652 .expect("load")
5653 .expect("exists");
5654 let pkg = st.packages.get("demo@0.1.0").expect("pkg");
5655 assert_eq!(pkg.attempts, 1);
5657 assert!(matches!(
5658 pkg.state,
5659 PackageState::Failed {
5660 class: ErrorClass::Permanent,
5661 ..
5662 }
5663 ));
5664 server.join();
5665 },
5666 );
5667 }
5668
5669 #[test]
5671 #[serial]
5672 fn sm_uploaded_then_verify_failed() {
5673 let td = tempdir().expect("tempdir");
5674 let bin = td.path().join("bin");
5675 write_fake_tools(&bin);
5676 with_test_env(
5677 &bin,
5678 vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
5679 || {
5680 let server = spawn_registry_server(
5682 std::collections::BTreeMap::from([(
5683 "/api/v1/crates/demo/0.1.0".to_string(),
5684 vec![
5685 (404, "{}".to_string()), (404, "{}".to_string()), (404, "{}".to_string()), ],
5689 )]),
5690 3,
5691 );
5692 let ws = planned_workspace(td.path(), server.base_url.clone());
5693 let mut opts = default_opts(PathBuf::from(".shipper"));
5694 opts.max_attempts = 1;
5695 opts.readiness.max_total_wait = Duration::from_millis(0);
5696
5697 let mut reporter = CollectingReporter::default();
5698 let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
5699 assert!(format!("{err:#}").contains("failed"));
5700
5701 let st = state::load_state(&td.path().join(".shipper"))
5702 .expect("load")
5703 .expect("exists");
5704 let pkg = st.packages.get("demo@0.1.0").expect("pkg");
5705 assert!(matches!(pkg.state, PackageState::Failed { .. }));
5706 server.join();
5707 },
5708 );
5709 }
5710
5711 #[test]
5713 #[serial]
5714 fn sm_multi_package_partial_progress() {
5715 let td = tempdir().expect("tempdir");
5716 let bin = td.path().join("bin");
5717 write_fake_tools(&bin);
5718 with_test_env(
5719 &bin,
5720 vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
5721 || {
5722 let server = spawn_registry_server(
5725 std::collections::BTreeMap::from([
5726 (
5727 "/api/v1/crates/alpha/1.0.0".to_string(),
5728 vec![(404, "{}".to_string()), (200, "{}".to_string())],
5729 ),
5730 (
5731 "/api/v1/crates/beta/2.0.0".to_string(),
5732 vec![
5733 (404, "{}".to_string()), (404, "{}".to_string()), (404, "{}".to_string()), ],
5737 ),
5738 ]),
5739 5,
5740 );
5741 let ws = multi_package_workspace(
5742 td.path(),
5743 server.base_url.clone(),
5744 vec![("alpha", "1.0.0"), ("beta", "2.0.0")],
5745 );
5746 let mut opts = default_opts(PathBuf::from(".shipper"));
5747 opts.max_attempts = 1;
5748 opts.readiness.max_total_wait = Duration::from_millis(0);
5749
5750 let mut reporter = CollectingReporter::default();
5751 let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
5752 assert!(format!("{err:#}").contains("beta"));
5753
5754 let st = state::load_state(&td.path().join(".shipper"))
5756 .expect("load")
5757 .expect("exists");
5758 let alpha = st.packages.get("alpha@1.0.0").expect("alpha");
5759 assert!(
5760 matches!(alpha.state, PackageState::Published),
5761 "alpha should be Published, got {:?}",
5762 alpha.state
5763 );
5764 let beta = st.packages.get("beta@2.0.0").expect("beta");
5765 assert!(
5766 matches!(beta.state, PackageState::Failed { .. }),
5767 "beta should be Failed, got {:?}",
5768 beta.state
5769 );
5770 server.join();
5771 },
5772 );
5773 }
5774
5775 #[test]
5777 #[serial]
5778 fn sm_resume_from_partial_state() {
5779 let td = tempdir().expect("tempdir");
5780 let bin = td.path().join("bin");
5781 write_fake_tools(&bin);
5782 let args_log = td.path().join("cargo_args.txt");
5783 with_test_env(
5784 &bin,
5785 vec![
5786 ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
5787 (
5788 "SHIPPER_CARGO_ARGS_LOG",
5789 Some(args_log.to_str().expect("utf8").to_string()),
5790 ),
5791 ],
5792 || {
5793 let server = spawn_registry_server(
5795 std::collections::BTreeMap::from([(
5796 "/api/v1/crates/beta/2.0.0".to_string(),
5797 vec![(404, "{}".to_string()), (200, "{}".to_string())],
5798 )]),
5799 2,
5800 );
5801 let ws = multi_package_workspace(
5802 td.path(),
5803 server.base_url.clone(),
5804 vec![("alpha", "1.0.0"), ("beta", "2.0.0")],
5805 );
5806 let state_dir = td.path().join(".shipper");
5807
5808 let mut packages = std::collections::BTreeMap::new();
5810 packages.insert(
5811 "alpha@1.0.0".to_string(),
5812 PackageProgress {
5813 name: "alpha".to_string(),
5814 version: "1.0.0".to_string(),
5815 attempts: 1,
5816 state: PackageState::Published,
5817 last_updated_at: Utc::now(),
5818 },
5819 );
5820 packages.insert(
5821 "beta@2.0.0".to_string(),
5822 PackageProgress {
5823 name: "beta".to_string(),
5824 version: "2.0.0".to_string(),
5825 attempts: 0,
5826 state: PackageState::Pending,
5827 last_updated_at: Utc::now(),
5828 },
5829 );
5830 let st = ExecutionState {
5831 state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
5832 plan_id: ws.plan.plan_id.clone(),
5833 registry: ws.plan.registry.clone(),
5834 created_at: Utc::now(),
5835 updated_at: Utc::now(),
5836 packages,
5837 };
5838 state::save_state(&state_dir, &st).expect("save");
5839
5840 let opts = default_opts(PathBuf::from(".shipper"));
5841 let mut reporter = CollectingReporter::default();
5842 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
5843
5844 assert!(
5846 reporter
5847 .infos
5848 .iter()
5849 .any(|i| i.contains("alpha") && i.contains("already complete"))
5850 );
5851
5852 assert_eq!(receipt.packages.len(), 1);
5854 assert_eq!(receipt.packages[0].name, "beta");
5855 assert!(matches!(receipt.packages[0].state, PackageState::Published));
5856
5857 let log = fs::read_to_string(&args_log).unwrap_or_default();
5859 assert!(
5860 !log.contains("alpha"),
5861 "alpha should not have been published"
5862 );
5863 assert!(log.contains("beta"), "beta should have been published");
5864
5865 server.join();
5866 },
5867 );
5868 }
5869
5870 #[test]
5872 fn sm_plan_id_mismatch_rejected() {
5873 let td = tempdir().expect("tempdir");
5874 let ws = multi_package_workspace(
5875 td.path(),
5876 "http://127.0.0.1:9".to_string(),
5877 vec![("demo", "0.1.0")],
5878 );
5879 let state_dir = td.path().join(".shipper");
5880
5881 let mut packages = std::collections::BTreeMap::new();
5882 packages.insert(
5883 "demo@0.1.0".to_string(),
5884 PackageProgress {
5885 name: "demo".to_string(),
5886 version: "0.1.0".to_string(),
5887 attempts: 0,
5888 state: PackageState::Pending,
5889 last_updated_at: Utc::now(),
5890 },
5891 );
5892 let st = ExecutionState {
5893 state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
5894 plan_id: "completely-different-plan".to_string(),
5895 registry: ws.plan.registry.clone(),
5896 created_at: Utc::now(),
5897 updated_at: Utc::now(),
5898 packages,
5899 };
5900 state::save_state(&state_dir, &st).expect("save");
5901
5902 let opts = default_opts(PathBuf::from(".shipper"));
5903 let mut reporter = CollectingReporter::default();
5904 let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
5905 let msg = format!("{err:#}");
5906 assert!(msg.contains("does not match current plan_id"), "got: {msg}");
5907 }
5908
5909 #[test]
5911 fn sm_empty_package_list_graceful() {
5912 let td = tempdir().expect("tempdir");
5913 let ws = multi_package_workspace(
5914 td.path(),
5915 "http://127.0.0.1:9".to_string(),
5916 vec![], );
5918 let opts = default_opts(PathBuf::from(".shipper"));
5919
5920 let mut reporter = CollectingReporter::default();
5921 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
5922 assert!(receipt.packages.is_empty());
5923 }
5924
5925 #[test]
5930 #[serial]
5931 fn sm_no_verify_flag_respected() {
5932 let td = tempdir().expect("tempdir");
5933 let bin = td.path().join("bin");
5934 write_fake_tools(&bin);
5935 let args_log = td.path().join("cargo_args.txt");
5936 with_test_env(
5937 &bin,
5938 vec![
5939 ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
5940 (
5941 "SHIPPER_CARGO_ARGS_LOG",
5942 Some(args_log.to_str().expect("utf8").to_string()),
5943 ),
5944 ],
5945 || {
5946 let server = spawn_registry_server(
5947 std::collections::BTreeMap::from([(
5948 "/api/v1/crates/demo/0.1.0".to_string(),
5949 vec![(404, "{}".to_string()), (200, "{}".to_string())],
5950 )]),
5951 2,
5952 );
5953 let ws = planned_workspace(td.path(), server.base_url.clone());
5954 let mut opts = default_opts(PathBuf::from(".shipper"));
5955 opts.no_verify = true;
5956
5957 let mut reporter = CollectingReporter::default();
5958 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
5959 assert!(matches!(receipt.packages[0].state, PackageState::Published));
5960
5961 let log = fs::read_to_string(&args_log).unwrap_or_default();
5963 assert!(
5964 log.contains("publish"),
5965 "cargo publish should have been called"
5966 );
5967 server.join();
5968 },
5969 );
5970 }
5971
5972 #[test]
5974 #[serial]
5975 fn sm_max_retries_exceeded_attempt_count() {
5976 let td = tempdir().expect("tempdir");
5977 let bin = td.path().join("bin");
5978 write_fake_tools(&bin);
5979 with_test_env(
5980 &bin,
5981 vec![
5982 ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
5983 (
5984 "SHIPPER_CARGO_STDERR",
5985 Some("timeout talking to server".to_string()),
5986 ),
5987 ],
5988 || {
5989 let server = spawn_registry_server(
5990 std::collections::BTreeMap::from([(
5991 "/api/v1/crates/demo/0.1.0".to_string(),
5992 vec![
5993 (404, "{}".to_string()),
5994 (404, "{}".to_string()),
5995 (404, "{}".to_string()),
5996 (404, "{}".to_string()),
5997 (404, "{}".to_string()),
5998 (404, "{}".to_string()),
5999 (404, "{}".to_string()),
6000 ],
6001 )]),
6002 7,
6003 );
6004 let ws = planned_workspace(td.path(), server.base_url.clone());
6005 let mut opts = default_opts(PathBuf::from(".shipper"));
6006 opts.max_attempts = 3;
6007 opts.base_delay = Duration::from_millis(0);
6008 opts.max_delay = Duration::from_millis(0);
6009
6010 let mut reporter = CollectingReporter::default();
6011 let err = run_publish(&ws, &opts, &mut reporter).expect_err("must fail");
6012 assert!(format!("{err:#}").contains("failed"));
6013
6014 let st = state::load_state(&td.path().join(".shipper"))
6015 .expect("load")
6016 .expect("exists");
6017 let pkg = st.packages.get("demo@0.1.0").expect("pkg");
6018 assert_eq!(pkg.attempts, 3, "should have made exactly 3 attempts");
6019 assert!(matches!(pkg.state, PackageState::Failed { .. }));
6020
6021 let attempt_msgs: Vec<_> = reporter
6023 .infos
6024 .iter()
6025 .filter(|i| i.contains("attempt"))
6026 .collect();
6027 assert!(
6028 attempt_msgs.len() >= 3,
6029 "expected at least 3 attempt messages, got {}: {:?}",
6030 attempt_msgs.len(),
6031 attempt_msgs
6032 );
6033 server.join();
6034 },
6035 );
6036 }
6037
6038 #[test]
6040 #[serial]
6041 fn sm_timeout_preserves_state() {
6042 let td = tempdir().expect("tempdir");
6043 let bin = td.path().join("bin");
6044 write_fake_tools(&bin);
6045 with_test_env(
6046 &bin,
6047 vec![
6048 ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
6049 (
6050 "SHIPPER_CARGO_STDERR",
6051 Some("timeout while uploading".to_string()),
6052 ),
6053 ],
6054 || {
6055 let server = spawn_registry_server(
6057 std::collections::BTreeMap::from([(
6058 "/api/v1/crates/demo/0.1.0".to_string(),
6059 vec![(404, "{}".to_string()), (200, "{}".to_string())],
6060 )]),
6061 2,
6062 );
6063 let ws = planned_workspace(td.path(), server.base_url.clone());
6064 let mut opts = default_opts(PathBuf::from(".shipper"));
6065 opts.base_delay = Duration::from_millis(0);
6066 opts.max_delay = Duration::from_millis(0);
6067
6068 let mut reporter = CollectingReporter::default();
6069 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
6070
6071 assert!(
6073 matches!(receipt.packages[0].state, PackageState::Published),
6074 "expected Published after timeout recovery, got {:?}",
6075 receipt.packages[0].state
6076 );
6077
6078 let st = state::load_state(&td.path().join(".shipper"))
6080 .expect("load")
6081 .expect("exists");
6082 let pkg = st.packages.get("demo@0.1.0").expect("pkg");
6083 assert!(matches!(pkg.state, PackageState::Published));
6084 server.join();
6085 },
6086 );
6087 }
6088
6089 #[test]
6092 #[serial]
6093 fn sm_package_independence_sequential() {
6094 let td = tempdir().expect("tempdir");
6095 let bin = td.path().join("bin");
6096 write_fake_tools(&bin);
6097 with_test_env(
6098 &bin,
6099 vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
6100 || {
6101 let server = spawn_registry_server(
6102 std::collections::BTreeMap::from([
6103 (
6104 "/api/v1/crates/alpha/1.0.0".to_string(),
6105 vec![(200, "{}".to_string())], ),
6107 (
6108 "/api/v1/crates/beta/2.0.0".to_string(),
6109 vec![(404, "{}".to_string()), (200, "{}".to_string())],
6110 ),
6111 (
6112 "/api/v1/crates/gamma/3.0.0".to_string(),
6113 vec![(200, "{}".to_string())], ),
6115 ]),
6116 4,
6117 );
6118 let ws = multi_package_workspace(
6119 td.path(),
6120 server.base_url.clone(),
6121 vec![("alpha", "1.0.0"), ("beta", "2.0.0"), ("gamma", "3.0.0")],
6122 );
6123 let opts = default_opts(PathBuf::from(".shipper"));
6124
6125 let mut reporter = CollectingReporter::default();
6126 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
6127
6128 assert_eq!(receipt.packages.len(), 3);
6129 assert!(
6131 matches!(receipt.packages[0].state, PackageState::Skipped { .. }),
6132 "alpha should be Skipped, got {:?}",
6133 receipt.packages[0].state
6134 );
6135 assert!(
6137 matches!(receipt.packages[1].state, PackageState::Published),
6138 "beta should be Published, got {:?}",
6139 receipt.packages[1].state
6140 );
6141 assert!(
6143 matches!(receipt.packages[2].state, PackageState::Skipped { .. }),
6144 "gamma should be Skipped, got {:?}",
6145 receipt.packages[2].state
6146 );
6147 server.join();
6148 },
6149 );
6150 }
6151
6152 #[test]
6154 #[serial]
6155 fn sm_resume_from_uploaded_skips_cargo() {
6156 let td = tempdir().expect("tempdir");
6157 let bin = td.path().join("bin");
6158 write_fake_tools(&bin);
6159 let args_log = td.path().join("cargo_args.txt");
6160 with_test_env(
6161 &bin,
6162 vec![
6163 ("SHIPPER_CARGO_EXIT", Some("0".to_string())),
6164 (
6165 "SHIPPER_CARGO_ARGS_LOG",
6166 Some(args_log.to_str().expect("utf8").to_string()),
6167 ),
6168 ],
6169 || {
6170 let server = spawn_registry_server(
6171 std::collections::BTreeMap::from([(
6172 "/api/v1/crates/demo/0.1.0".to_string(),
6173 vec![(404, "{}".to_string()), (200, "{}".to_string())],
6174 )]),
6175 2,
6176 );
6177 let ws = planned_workspace(td.path(), server.base_url.clone());
6178 let state_dir = td.path().join(".shipper");
6179
6180 let mut packages = std::collections::BTreeMap::new();
6182 packages.insert(
6183 "demo@0.1.0".to_string(),
6184 PackageProgress {
6185 name: "demo".to_string(),
6186 version: "0.1.0".to_string(),
6187 attempts: 1,
6188 state: PackageState::Uploaded,
6189 last_updated_at: Utc::now(),
6190 },
6191 );
6192 let st = ExecutionState {
6193 state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
6194 plan_id: ws.plan.plan_id.clone(),
6195 registry: ws.plan.registry.clone(),
6196 created_at: Utc::now(),
6197 updated_at: Utc::now(),
6198 packages,
6199 };
6200 state::save_state(&state_dir, &st).expect("save");
6201
6202 let opts = default_opts(PathBuf::from(".shipper"));
6203 let mut reporter = CollectingReporter::default();
6204 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
6205
6206 assert!(matches!(receipt.packages[0].state, PackageState::Published));
6207 assert!(
6208 reporter
6209 .infos
6210 .iter()
6211 .any(|i| i.contains("resuming from uploaded"))
6212 );
6213
6214 let cargo_called = args_log.exists()
6216 && fs::read_to_string(&args_log)
6217 .unwrap_or_default()
6218 .contains("publish");
6219 assert!(
6220 !cargo_called,
6221 "cargo publish should not run on resume from Uploaded"
6222 );
6223 server.join();
6224 },
6225 );
6226 }
6227
6228 #[test]
6230 #[serial]
6231 fn sm_failed_package_event_log() {
6232 let td = tempdir().expect("tempdir");
6233 let bin = td.path().join("bin");
6234 write_fake_tools(&bin);
6235 with_test_env(
6236 &bin,
6237 vec![
6238 ("SHIPPER_CARGO_EXIT", Some("1".to_string())),
6239 (
6240 "SHIPPER_CARGO_STDERR",
6241 Some("permission denied".to_string()),
6242 ),
6243 ],
6244 || {
6245 let server = spawn_registry_server(
6246 std::collections::BTreeMap::from([(
6247 "/api/v1/crates/demo/0.1.0".to_string(),
6248 vec![(404, "{}".to_string()), (404, "{}".to_string())],
6249 )]),
6250 2,
6251 );
6252 let ws = planned_workspace(td.path(), server.base_url.clone());
6253 let opts = default_opts(PathBuf::from(".shipper"));
6254
6255 let mut reporter = CollectingReporter::default();
6256 let _ = run_publish(&ws, &opts, &mut reporter);
6257
6258 let events_path = td.path().join(".shipper").join("events.jsonl");
6259 let log = crate::state::events::EventLog::read_from_file(&events_path)
6260 .expect("read events");
6261 let events = log.all_events();
6262
6263 assert!(
6265 events
6266 .iter()
6267 .any(|e| matches!(e.event_type, EventType::ExecutionStarted))
6268 );
6269 assert!(
6270 events
6271 .iter()
6272 .any(|e| matches!(e.event_type, EventType::PlanCreated { .. }))
6273 );
6274 assert!(
6275 events
6276 .iter()
6277 .any(|e| matches!(e.event_type, EventType::PackageStarted { .. }))
6278 );
6279 assert!(
6280 events
6281 .iter()
6282 .any(|e| matches!(e.event_type, EventType::PackageAttempted { .. }))
6283 );
6284 assert!(
6285 events
6286 .iter()
6287 .any(|e| matches!(e.event_type, EventType::PackageFailed { .. }))
6288 );
6289 server.join();
6290 },
6291 );
6292 }
6293
6294 #[test]
6296 fn sm_state_version_preserved_through_transitions() {
6297 let td = tempdir().expect("tempdir");
6298 let state_dir = td.path().join(".shipper");
6299 let ws = multi_package_workspace(
6300 td.path(),
6301 "http://127.0.0.1:9".to_string(),
6302 vec![("alpha", "1.0.0"), ("beta", "2.0.0")],
6303 );
6304
6305 let mut st = init_state(&ws, &state_dir).expect("init");
6306 assert_eq!(
6307 st.state_version,
6308 crate::state::execution_state::CURRENT_STATE_VERSION
6309 );
6310
6311 update_state(&mut st, &state_dir, "alpha@1.0.0", PackageState::Uploaded).expect("update");
6313 update_state(&mut st, &state_dir, "alpha@1.0.0", PackageState::Published).expect("update");
6314
6315 update_state(
6317 &mut st,
6318 &state_dir,
6319 "beta@2.0.0",
6320 PackageState::Failed {
6321 class: ErrorClass::Permanent,
6322 message: "denied".to_string(),
6323 },
6324 )
6325 .expect("update");
6326
6327 let loaded = state::load_state(&state_dir)
6328 .expect("load")
6329 .expect("exists");
6330 assert_eq!(
6331 loaded.state_version,
6332 crate::state::execution_state::CURRENT_STATE_VERSION
6333 );
6334 assert!(matches!(
6335 loaded.packages.get("alpha@1.0.0").unwrap().state,
6336 PackageState::Published
6337 ));
6338 assert!(matches!(
6339 loaded.packages.get("beta@2.0.0").unwrap().state,
6340 PackageState::Failed { .. }
6341 ));
6342 }
6343
6344 #[test]
6346 #[serial]
6347 fn sm_snapshot_receipt_multi_package() {
6348 let td = tempdir().expect("tempdir");
6349 let bin = td.path().join("bin");
6350 write_fake_tools(&bin);
6351 with_test_env(
6352 &bin,
6353 vec![("SHIPPER_CARGO_EXIT", Some("0".to_string()))],
6354 || {
6355 let server = spawn_registry_server(
6356 std::collections::BTreeMap::from([
6357 (
6358 "/api/v1/crates/alpha/1.0.0".to_string(),
6359 vec![(404, "{}".to_string()), (200, "{}".to_string())],
6360 ),
6361 (
6362 "/api/v1/crates/beta/2.0.0".to_string(),
6363 vec![(200, "{}".to_string())],
6364 ),
6365 ]),
6366 3,
6367 );
6368 let ws = multi_package_workspace(
6369 td.path(),
6370 server.base_url.clone(),
6371 vec![("alpha", "1.0.0"), ("beta", "2.0.0")],
6372 );
6373 let opts = default_opts(PathBuf::from(".shipper"));
6374
6375 let mut reporter = CollectingReporter::default();
6376 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
6377
6378 let snapshot: Vec<String> = receipt
6380 .packages
6381 .iter()
6382 .map(|p| {
6383 format!(
6384 "name={} version={} attempts={} state={}",
6385 p.name,
6386 p.version,
6387 p.attempts,
6388 short_state(&p.state)
6389 )
6390 })
6391 .collect();
6392 insta::assert_debug_snapshot!("sm_receipt_multi_package", snapshot);
6393 server.join();
6394 },
6395 );
6396 }
6397
6398 #[test]
6400 fn sm_snapshot_state_partial_failure() {
6401 let td = tempdir().expect("tempdir");
6402 let state_dir = td.path().join(".shipper");
6403 let ws = multi_package_workspace(
6404 td.path(),
6405 "http://127.0.0.1:9".to_string(),
6406 vec![("alpha", "1.0.0"), ("beta", "2.0.0"), ("gamma", "3.0.0")],
6407 );
6408
6409 let mut st = init_state(&ws, &state_dir).expect("init");
6410
6411 update_state(&mut st, &state_dir, "alpha@1.0.0", PackageState::Published).expect("update");
6413 update_state(&mut st, &state_dir, "beta@2.0.0", PackageState::Uploaded).expect("update");
6414
6415 let snapshot: Vec<String> = st
6416 .packages
6417 .iter()
6418 .map(|(k, v)| {
6419 format!(
6420 "key={} attempts={} state={}",
6421 k,
6422 v.attempts,
6423 short_state(&v.state)
6424 )
6425 })
6426 .collect();
6427 insta::assert_debug_snapshot!("sm_state_partial_failure", snapshot);
6428 }
6429
6430 #[test]
6432 fn sm_force_resume_with_mismatch() {
6433 let td = tempdir().expect("tempdir");
6434 let ws = multi_package_workspace(
6435 td.path(),
6436 "http://127.0.0.1:9".to_string(),
6437 vec![("demo", "0.1.0")],
6438 );
6439 let state_dir = td.path().join(".shipper");
6440
6441 let mut packages = std::collections::BTreeMap::new();
6443 packages.insert(
6444 "demo@0.1.0".to_string(),
6445 PackageProgress {
6446 name: "demo".to_string(),
6447 version: "0.1.0".to_string(),
6448 attempts: 1,
6449 state: PackageState::Published,
6450 last_updated_at: Utc::now(),
6451 },
6452 );
6453 let st = ExecutionState {
6454 state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
6455 plan_id: "old-plan-id".to_string(),
6456 registry: ws.plan.registry.clone(),
6457 created_at: Utc::now(),
6458 updated_at: Utc::now(),
6459 packages,
6460 };
6461 state::save_state(&state_dir, &st).expect("save");
6462
6463 let mut opts = default_opts(PathBuf::from(".shipper"));
6464 opts.force_resume = true;
6465
6466 let mut reporter = CollectingReporter::default();
6467 let receipt = run_publish(&ws, &opts, &mut reporter).expect("publish");
6468 assert!(receipt.packages.is_empty()); assert!(
6470 reporter
6471 .warns
6472 .iter()
6473 .any(|w| w.contains("forcing resume with mismatched plan_id"))
6474 );
6475 }
6476
6477 #[test]
6480 fn snapshot_init_state_single_package() {
6481 let td = tempdir().expect("tempdir");
6482 let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
6483 let state_dir = td.path().join(".shipper");
6484
6485 let st = init_state(&ws, &state_dir).expect("init");
6486
6487 let snapshot: Vec<String> = st
6489 .packages
6490 .iter()
6491 .map(|(k, v)| {
6492 format!(
6493 "key={} name={} version={} attempts={} state={}",
6494 k,
6495 v.name,
6496 v.version,
6497 v.attempts,
6498 short_state(&v.state)
6499 )
6500 })
6501 .collect();
6502 insta::assert_debug_snapshot!("init_state_single_package", snapshot);
6503 }
6504
6505 #[test]
6506 fn snapshot_init_state_multi_package() {
6507 let td = tempdir().expect("tempdir");
6508 let mut ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
6509 ws.plan.packages = vec![
6510 PlannedPackage {
6511 name: "alpha".to_string(),
6512 version: "1.0.0".to_string(),
6513 manifest_path: td.path().join("alpha/Cargo.toml"),
6514 },
6515 PlannedPackage {
6516 name: "beta".to_string(),
6517 version: "2.0.0".to_string(),
6518 manifest_path: td.path().join("beta/Cargo.toml"),
6519 },
6520 PlannedPackage {
6521 name: "gamma".to_string(),
6522 version: "0.3.0".to_string(),
6523 manifest_path: td.path().join("gamma/Cargo.toml"),
6524 },
6525 ];
6526 let state_dir = td.path().join(".shipper");
6527
6528 let st = init_state(&ws, &state_dir).expect("init");
6529
6530 let snapshot: Vec<String> = st
6531 .packages
6532 .iter()
6533 .map(|(k, v)| {
6534 format!(
6535 "key={} name={} version={} attempts={} state={}",
6536 k,
6537 v.name,
6538 v.version,
6539 v.attempts,
6540 short_state(&v.state)
6541 )
6542 })
6543 .collect();
6544 insta::assert_debug_snapshot!("init_state_multi_package", snapshot);
6545 }
6546
6547 #[test]
6548 fn snapshot_state_after_transitions() {
6549 let td = tempdir().expect("tempdir");
6550 let mut ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
6551 ws.plan.packages = vec![
6552 PlannedPackage {
6553 name: "alpha".to_string(),
6554 version: "1.0.0".to_string(),
6555 manifest_path: td.path().join("alpha/Cargo.toml"),
6556 },
6557 PlannedPackage {
6558 name: "beta".to_string(),
6559 version: "2.0.0".to_string(),
6560 manifest_path: td.path().join("beta/Cargo.toml"),
6561 },
6562 ];
6563 let state_dir = td.path().join(".shipper");
6564 let mut st = init_state(&ws, &state_dir).expect("init");
6565
6566 update_state(&mut st, &state_dir, "alpha@1.0.0", PackageState::Published).expect("update");
6568 update_state(
6569 &mut st,
6570 &state_dir,
6571 "beta@2.0.0",
6572 PackageState::Failed {
6573 class: ErrorClass::Permanent,
6574 message: "auth failure".to_string(),
6575 },
6576 )
6577 .expect("update");
6578
6579 let snapshot: Vec<String> = st
6580 .packages
6581 .iter()
6582 .map(|(k, v)| {
6583 format!(
6584 "key={} attempts={} state={}",
6585 k,
6586 v.attempts,
6587 short_state(&v.state)
6588 )
6589 })
6590 .collect();
6591 insta::assert_debug_snapshot!("state_after_mixed_transitions", snapshot);
6592 }
6593
6594 #[test]
6595 fn snapshot_error_class_classification_matrix() {
6596 let cases = vec![
6597 ("HTTP 429 too many requests", ""),
6598 ("timeout talking to server", ""),
6599 ("HTTP 503 service unavailable", ""),
6600 ("connection refused", ""),
6601 ("HTTP 500 internal server error", ""),
6602 ("permission denied", ""),
6603 ("crate version `0.1.0` is already uploaded", ""),
6604 ("something totally unexpected", ""),
6605 ("", ""),
6606 ];
6607
6608 let snapshot: Vec<String> = cases
6609 .iter()
6610 .map(|(stderr, stdout)| {
6611 let (class, msg) = classify_cargo_failure(stderr, stdout);
6612 format!(
6613 "stderr={:50} class={:10} msg={}",
6614 format!("{stderr:?}"),
6615 format!("{class:?}"),
6616 msg
6617 )
6618 })
6619 .collect();
6620 insta::assert_debug_snapshot!("error_classification_matrix", snapshot);
6621 }
6622
6623 mod engine_proptests {
6626 use super::*;
6627 use proptest::prelude::*;
6628
6629 fn arb_error_class() -> impl Strategy<Value = ErrorClass> {
6630 prop_oneof![
6631 Just(ErrorClass::Retryable),
6632 Just(ErrorClass::Permanent),
6633 Just(ErrorClass::Ambiguous),
6634 ]
6635 }
6636
6637 fn arb_package_state() -> impl Strategy<Value = PackageState> {
6638 prop_oneof![
6639 Just(PackageState::Pending),
6640 Just(PackageState::Uploaded),
6641 Just(PackageState::Published),
6642 ".*".prop_map(|r| PackageState::Skipped { reason: r }),
6643 (arb_error_class(), ".*").prop_map(|(c, m)| PackageState::Failed {
6644 class: c,
6645 message: m
6646 }),
6647 ".*".prop_map(|m| PackageState::Ambiguous { message: m }),
6648 ]
6649 }
6650
6651 proptest! {
6652 #[test]
6654 fn update_state_always_persists(new_state in arb_package_state()) {
6655 let td = tempdir().expect("tempdir");
6656 let state_dir = td.path().join(".shipper");
6657 let ws = planned_workspace(td.path(), "http://127.0.0.1:9".to_string());
6658 let mut st = init_state(&ws, &state_dir).expect("init");
6659 let key = "demo@0.1.0";
6660
6661 update_state(&mut st, &state_dir, key, new_state.clone()).expect("update");
6662
6663 assert_eq!(st.packages.get(key).unwrap().state, new_state);
6665
6666 let loaded = state::load_state(&state_dir)
6668 .expect("load")
6669 .expect("exists");
6670 assert_eq!(loaded.packages.get(key).unwrap().state, new_state);
6671 }
6672
6673 #[test]
6675 fn short_state_never_panics(state in arb_package_state()) {
6676 let label = short_state(&state);
6677 assert!(!label.is_empty());
6678 }
6679
6680 #[test]
6682 fn pkg_key_deterministic(
6683 name in "[a-z][a-z0-9_-]{0,19}",
6684 version in "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}"
6685 ) {
6686 let key1 = pkg_key(&name, &version);
6687 let key2 = pkg_key(&name, &version);
6688 assert_eq!(key1, key2);
6689 assert!(key1.contains('@'));
6690 assert!(key1.starts_with(&name));
6691 assert!(key1.ends_with(&version));
6692 }
6693
6694 #[test]
6696 fn backoff_delay_bounded(
6697 base_ms in 1u64..5000,
6698 max_ms in 100u64..30000,
6699 attempt in 1u32..50,
6700 jitter in 0.0f64..1.0,
6701 ) {
6702 let base = Duration::from_millis(base_ms.min(max_ms));
6703 let max = Duration::from_millis(max_ms);
6704
6705 let delay = backoff_delay(
6706 base,
6707 max,
6708 attempt,
6709 crate::retry::RetryStrategyType::Exponential,
6710 jitter,
6711 );
6712
6713 let upper_bound_ms = (max_ms as f64 * (1.0 + jitter)).ceil() as u64 + 1;
6715 assert!(
6716 delay.as_millis() <= upper_bound_ms as u128,
6717 "delay {}ms exceeded upper bound {}ms (base={}ms, max={}ms, attempt={}, jitter={})",
6718 delay.as_millis(), upper_bound_ms, base_ms, max_ms, attempt, jitter
6719 );
6720 }
6721
6722 #[test]
6724 fn execution_state_roundtrip(
6725 attempts in 0u32..100,
6726 state in arb_package_state()
6727 ) {
6728 let mut packages = BTreeMap::new();
6729 packages.insert(
6730 "test@1.0.0".to_string(),
6731 PackageProgress {
6732 name: "test".to_string(),
6733 version: "1.0.0".to_string(),
6734 attempts,
6735 state,
6736 last_updated_at: Utc::now(),
6737 },
6738 );
6739 let st = ExecutionState {
6740 state_version: crate::state::execution_state::CURRENT_STATE_VERSION.to_string(),
6741 plan_id: "plan-proptest".to_string(),
6742 registry: Registry {
6743 name: "crates-io".to_string(),
6744 api_base: "https://crates.io".to_string(),
6745 index_base: None,
6746 },
6747 created_at: Utc::now(),
6748 updated_at: Utc::now(),
6749 packages,
6750 };
6751
6752 let json = serde_json::to_string(&st).expect("serialize");
6753 let parsed: ExecutionState = serde_json::from_str(&json).expect("deserialize");
6754 assert_eq!(parsed.packages.len(), 1);
6755 let pkg = parsed.packages.get("test@1.0.0").unwrap();
6756 assert_eq!(pkg.attempts, attempts);
6757 }
6758 }
6759 }
6760
6761 fn read_events_raw(state_dir: &Path) -> Vec<serde_json::Value> {
6766 let path = events::events_path(state_dir);
6767 let raw = std::fs::read_to_string(&path).unwrap_or_default();
6768 raw.lines()
6769 .filter(|l| !l.trim().is_empty())
6770 .map(|l| serde_json::from_str(l).expect("events.jsonl must parse"))
6771 .collect()
6772 }
6773
6774 fn event_discriminator(event: &serde_json::Value) -> Option<String> {
6775 event
6776 .get("event_type")
6777 .and_then(|et| et.get("type"))
6778 .and_then(|t| t.as_str())
6779 .map(str::to_owned)
6780 }
6781
6782 #[test]
6783 #[serial]
6784 fn run_rehearsal_errors_when_no_rehearsal_registry_configured() {
6785 let td = tempdir().expect("tempdir");
6786 let bin = td.path().join("bin");
6787 write_fake_tools(&bin);
6788 let env_vars = fake_program_env_vars(&bin);
6789 temp_env::with_vars(env_vars, || {
6790 let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
6791 let opts = default_opts(PathBuf::from(".shipper"));
6792
6793 let mut reporter = CollectingReporter::default();
6794 let err = run_rehearsal(&ws, &opts, &mut reporter).expect_err("must fail");
6795 let msg = format!("{err:#}");
6796 assert!(msg.contains("no rehearsal registry"), "err was: {msg}");
6797 });
6798 }
6799
6800 #[test]
6801 #[serial]
6802 fn run_rehearsal_errors_when_rehearsal_equals_live_target() {
6803 let td = tempdir().expect("tempdir");
6804 let bin = td.path().join("bin");
6805 write_fake_tools(&bin);
6806 let env_vars = fake_program_env_vars(&bin);
6807 temp_env::with_vars(env_vars, || {
6808 let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
6809 let mut opts = default_opts(PathBuf::from(".shipper"));
6810 opts.rehearsal_registry = Some("crates-io".to_string());
6812 opts.registries = vec![Registry {
6813 name: "crates-io".to_string(),
6814 api_base: "http://127.0.0.1:1".to_string(),
6815 index_base: None,
6816 }];
6817
6818 let mut reporter = CollectingReporter::default();
6819 let err = run_rehearsal(&ws, &opts, &mut reporter).expect_err("must fail");
6820 let msg = format!("{err:#}");
6821 assert!(
6822 msg.contains("must differ from the live target"),
6823 "err was: {msg}"
6824 );
6825 });
6826 }
6827
6828 #[test]
6829 #[serial]
6830 fn run_rehearsal_errors_when_registry_name_not_in_config() {
6831 let td = tempdir().expect("tempdir");
6832 let bin = td.path().join("bin");
6833 write_fake_tools(&bin);
6834 let env_vars = fake_program_env_vars(&bin);
6835 temp_env::with_vars(env_vars, || {
6836 let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
6837 let mut opts = default_opts(PathBuf::from(".shipper"));
6838 opts.rehearsal_registry = Some("bogus-registry".to_string());
6839 let mut reporter = CollectingReporter::default();
6842 let err = run_rehearsal(&ws, &opts, &mut reporter).expect_err("must fail");
6843 let msg = format!("{err:#}");
6844 assert!(msg.contains("is not configured"), "err was: {msg}");
6845 });
6846 }
6847
6848 #[test]
6849 #[serial]
6850 fn run_rehearsal_skip_flag_returns_without_running() {
6851 let td = tempdir().expect("tempdir");
6852 let bin = td.path().join("bin");
6853 write_fake_tools(&bin);
6854 let env_vars = fake_program_env_vars(&bin);
6855 temp_env::with_vars(env_vars, || {
6856 let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
6857 let mut opts = default_opts(PathBuf::from(".shipper"));
6858 opts.rehearsal_registry = Some("rehearsal".to_string());
6859 opts.rehearsal_skip = true;
6860
6861 let mut reporter = CollectingReporter::default();
6862 let outcome =
6863 run_rehearsal(&ws, &opts, &mut reporter).expect("skip path should not error");
6864 assert!(!outcome.passed, "skip should not claim a pass");
6865 assert_eq!(outcome.packages_published, 0);
6866 assert!(outcome.summary.contains("skipped"));
6867 let events_path = events::events_path(&td.path().join(".shipper"));
6869 assert!(
6870 !events_path.exists(),
6871 "skip path must not create events.jsonl"
6872 );
6873 });
6874 }
6875
6876 #[test]
6877 #[serial]
6878 fn run_rehearsal_happy_path_emits_started_published_complete_events() {
6879 let td = tempdir().expect("tempdir");
6880 let bin = td.path().join("bin");
6881 write_fake_tools(&bin);
6882 let env_vars = fake_program_env_vars(&bin);
6883 temp_env::with_vars(env_vars, || {
6884 let rehearsal_server = spawn_registry_server(
6887 std::collections::BTreeMap::from([(
6888 "/api/v1/crates/demo/0.1.0".to_string(),
6889 vec![(200, "{}".to_string())],
6890 )]),
6891 1,
6892 );
6893
6894 let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
6895 let mut opts = default_opts(PathBuf::from(".shipper"));
6896 opts.rehearsal_registry = Some("rehearsal".to_string());
6897 opts.registries = vec![Registry {
6898 name: "rehearsal".to_string(),
6899 api_base: rehearsal_server.base_url.clone(),
6900 index_base: None,
6901 }];
6902
6903 let mut reporter = CollectingReporter::default();
6904 let outcome = run_rehearsal(&ws, &opts, &mut reporter).expect("rehearse");
6905 assert!(outcome.passed, "outcome: {outcome:?}");
6906 assert_eq!(outcome.packages_published, 1);
6907
6908 let events = read_events_raw(&td.path().join(".shipper"));
6909 let types: Vec<String> = events.iter().filter_map(event_discriminator).collect();
6910 assert!(
6911 types.contains(&"rehearsal_started".to_string()),
6912 "types: {types:?}"
6913 );
6914 assert!(
6915 types.contains(&"rehearsal_package_published".to_string()),
6916 "types: {types:?}"
6917 );
6918 assert!(
6919 types.contains(&"rehearsal_complete".to_string()),
6920 "types: {types:?}"
6921 );
6922
6923 let complete = events
6925 .iter()
6926 .find(|e| event_discriminator(e).as_deref() == Some("rehearsal_complete"))
6927 .expect("RehearsalComplete event");
6928 assert_eq!(
6929 complete["event_type"]["passed"].as_bool(),
6930 Some(true),
6931 "complete event: {complete}"
6932 );
6933
6934 rehearsal_server.join();
6935 });
6936 }
6937
6938 fn write_rehearsal_receipt(
6943 state_dir: &Path,
6944 plan_id: &str,
6945 passed: bool,
6946 ) -> crate::state::rehearsal::RehearsalReceipt {
6947 let receipt = crate::state::rehearsal::RehearsalReceipt {
6948 schema_version: crate::state::rehearsal::CURRENT_REHEARSAL_VERSION.to_string(),
6949 plan_id: plan_id.to_string(),
6950 registry: "rehearsal".to_string(),
6951 passed,
6952 packages_attempted: 1,
6953 packages_published: if passed { 1 } else { 0 },
6954 summary: if passed {
6955 "rehearsed 1 package successfully".into()
6956 } else {
6957 "rehearsal failed".into()
6958 },
6959 started_at: Utc::now(),
6960 completed_at: Utc::now(),
6961 };
6962 crate::state::rehearsal::save_rehearsal(state_dir, &receipt).expect("write");
6963 receipt
6964 }
6965
6966 #[test]
6967 fn gate_is_dormant_when_rehearsal_registry_is_none() {
6968 let td = tempdir().expect("tempdir");
6969 let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
6970 let opts = default_opts(PathBuf::from(".shipper"));
6971 let mut reporter = CollectingReporter::default();
6973 enforce_rehearsal_gate(&ws, &opts, td.path(), &mut reporter).expect("gate dormant");
6974 }
6975
6976 #[test]
6977 fn gate_proceeds_with_warning_when_skip_is_set() {
6978 let td = tempdir().expect("tempdir");
6979 let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
6980 let mut opts = default_opts(PathBuf::from(".shipper"));
6981 opts.rehearsal_registry = Some("rehearsal".into());
6982 opts.rehearsal_skip = true;
6983 let mut reporter = CollectingReporter::default();
6984 enforce_rehearsal_gate(&ws, &opts, td.path(), &mut reporter).expect("skip bypass");
6985 assert!(
6986 reporter
6987 .warns
6988 .iter()
6989 .any(|w| w.contains("--skip-rehearsal")),
6990 "warns: {:?}",
6991 reporter.warns
6992 );
6993 }
6994
6995 #[test]
6996 fn gate_refuses_when_no_receipt_exists() {
6997 let td = tempdir().expect("tempdir");
6998 let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
6999 let mut opts = default_opts(PathBuf::from(".shipper"));
7000 opts.rehearsal_registry = Some("rehearsal".into());
7001 let mut reporter = CollectingReporter::default();
7002 let err =
7003 enforce_rehearsal_gate(&ws, &opts, td.path(), &mut reporter).expect_err("must fail");
7004 let msg = format!("{err:#}");
7005 assert!(msg.contains("no rehearsal receipt was found"), "err: {msg}");
7006 assert!(
7007 msg.contains("shipper rehearse"),
7008 "err should hint fix: {msg}"
7009 );
7010 }
7011
7012 #[test]
7013 fn gate_refuses_on_plan_id_mismatch() {
7014 let td = tempdir().expect("tempdir");
7015 let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
7016 let mut opts = default_opts(PathBuf::from(".shipper"));
7017 opts.rehearsal_registry = Some("rehearsal".into());
7018
7019 write_rehearsal_receipt(td.path(), "some-other-plan", true);
7020
7021 let mut reporter = CollectingReporter::default();
7022 let err =
7023 enforce_rehearsal_gate(&ws, &opts, td.path(), &mut reporter).expect_err("must fail");
7024 let msg = format!("{err:#}");
7025 assert!(msg.contains("stale"), "err: {msg}");
7026 assert!(
7027 msg.contains(&ws.plan.plan_id),
7028 "err should reference current plan_id: {msg}"
7029 );
7030 }
7031
7032 #[test]
7033 fn gate_refuses_on_failing_receipt() {
7034 let td = tempdir().expect("tempdir");
7035 let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
7036 let mut opts = default_opts(PathBuf::from(".shipper"));
7037 opts.rehearsal_registry = Some("rehearsal".into());
7038
7039 write_rehearsal_receipt(td.path(), &ws.plan.plan_id, false);
7040
7041 let mut reporter = CollectingReporter::default();
7042 let err =
7043 enforce_rehearsal_gate(&ws, &opts, td.path(), &mut reporter).expect_err("must fail");
7044 let msg = format!("{err:#}");
7045 assert!(msg.contains("did NOT pass"), "err: {msg}");
7046 }
7047
7048 #[test]
7049 fn gate_passes_on_fresh_passing_receipt() {
7050 let td = tempdir().expect("tempdir");
7051 let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
7052 let mut opts = default_opts(PathBuf::from(".shipper"));
7053 opts.rehearsal_registry = Some("rehearsal".into());
7054
7055 write_rehearsal_receipt(td.path(), &ws.plan.plan_id, true);
7056
7057 let mut reporter = CollectingReporter::default();
7058 enforce_rehearsal_gate(&ws, &opts, td.path(), &mut reporter).expect("fresh pass");
7059 assert!(
7060 reporter.infos.iter().any(|i| i.contains("passing receipt")),
7061 "infos: {:?}",
7062 reporter.infos
7063 );
7064 }
7065
7066 #[test]
7071 #[serial]
7072 fn run_publish_refuses_without_rehearsal_when_required() {
7073 let td = tempdir().expect("tempdir");
7074 let bin = td.path().join("bin");
7075 write_fake_tools(&bin);
7076 let env_vars = fake_program_env_vars(&bin);
7077 temp_env::with_vars(env_vars, || {
7078 let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
7079 let state_dir = td.path().join(".shipper");
7080 let mut opts = default_opts(state_dir);
7081 opts.rehearsal_registry = Some("rehearsal".into());
7082
7083 let mut reporter = CollectingReporter::default();
7084 let err = run_publish(&ws, &opts, &mut reporter).expect_err("gate must bail");
7085 let msg = format!("{err:#}");
7086 assert!(
7087 msg.contains("rehearsal is required") || msg.contains("no rehearsal receipt"),
7088 "expected gate error, got: {msg}"
7089 );
7090 });
7091 }
7092
7093 #[test]
7097 #[serial]
7098 fn run_rehearsal_smoke_install_happy_path_emits_succeeded_event() {
7099 let td = tempdir().expect("tempdir");
7100 let bin = td.path().join("bin");
7101 write_fake_tools(&bin);
7102 let env_vars = fake_program_env_vars(&bin);
7103 temp_env::with_vars(env_vars, || {
7104 let rehearsal_server = spawn_registry_server(
7105 std::collections::BTreeMap::from([(
7106 "/api/v1/crates/demo/0.1.0".to_string(),
7107 vec![(200, "{}".to_string())],
7108 )]),
7109 1,
7110 );
7111
7112 let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
7113 let mut opts = default_opts(PathBuf::from(".shipper"));
7114 opts.rehearsal_registry = Some("rehearsal".to_string());
7115 opts.registries = vec![Registry {
7116 name: "rehearsal".to_string(),
7117 api_base: rehearsal_server.base_url.clone(),
7118 index_base: None,
7119 }];
7120 opts.rehearsal_smoke_install = Some("demo".to_string());
7121
7122 let mut reporter = CollectingReporter::default();
7123 let outcome = run_rehearsal(&ws, &opts, &mut reporter).expect("rehearse");
7124 assert!(outcome.passed, "outcome: {outcome:?}");
7125
7126 let events = read_events_raw(&td.path().join(".shipper"));
7127 let types: Vec<String> = events.iter().filter_map(event_discriminator).collect();
7128 assert!(
7129 types.contains(&"rehearsal_smoke_check_started".to_string()),
7130 "types: {types:?}"
7131 );
7132 assert!(
7133 types.contains(&"rehearsal_smoke_check_succeeded".to_string()),
7134 "types: {types:?}"
7135 );
7136 rehearsal_server.join();
7137 });
7138 }
7139
7140 #[test]
7144 #[serial]
7145 fn run_rehearsal_smoke_install_missing_target_warns_without_failing() {
7146 let td = tempdir().expect("tempdir");
7147 let bin = td.path().join("bin");
7148 write_fake_tools(&bin);
7149 let env_vars = fake_program_env_vars(&bin);
7150 temp_env::with_vars(env_vars, || {
7151 let rehearsal_server = spawn_registry_server(
7152 std::collections::BTreeMap::from([(
7153 "/api/v1/crates/demo/0.1.0".to_string(),
7154 vec![(200, "{}".to_string())],
7155 )]),
7156 1,
7157 );
7158
7159 let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
7160 let mut opts = default_opts(PathBuf::from(".shipper"));
7161 opts.rehearsal_registry = Some("rehearsal".to_string());
7162 opts.registries = vec![Registry {
7163 name: "rehearsal".to_string(),
7164 api_base: rehearsal_server.base_url.clone(),
7165 index_base: None,
7166 }];
7167 opts.rehearsal_smoke_install = Some("nonexistent".to_string());
7168
7169 let mut reporter = CollectingReporter::default();
7170 let outcome = run_rehearsal(&ws, &opts, &mut reporter).expect("rehearse");
7171 assert!(outcome.passed);
7172 assert!(
7173 reporter
7174 .warns
7175 .iter()
7176 .any(|w| w.contains("not in the rehearsal plan")),
7177 "warns: {:?}",
7178 reporter.warns
7179 );
7180 rehearsal_server.join();
7181 });
7182 }
7183
7184 #[test]
7185 #[serial]
7186 fn run_rehearsal_cargo_failure_emits_package_failed_and_marks_not_passed() {
7187 let td = tempdir().expect("tempdir");
7188 let bin = td.path().join("bin");
7189 write_fake_tools(&bin);
7190 let mut env_vars = fake_program_env_vars(&bin);
7191 env_vars.extend([("SHIPPER_CARGO_EXIT", Some("101".to_string()))]);
7192 temp_env::with_vars(env_vars, || {
7193 let rehearsal_server = spawn_registry_server(std::collections::BTreeMap::new(), 0);
7195
7196 let ws = planned_workspace(td.path(), "http://127.0.0.1:1".into());
7197 let mut opts = default_opts(PathBuf::from(".shipper"));
7198 opts.rehearsal_registry = Some("rehearsal".to_string());
7199 opts.registries = vec![Registry {
7200 name: "rehearsal".to_string(),
7201 api_base: rehearsal_server.base_url.clone(),
7202 index_base: None,
7203 }];
7204
7205 let mut reporter = CollectingReporter::default();
7206 let outcome = run_rehearsal(&ws, &opts, &mut reporter).expect("rehearse");
7207 assert!(!outcome.passed);
7208 assert_eq!(outcome.packages_published, 0);
7209
7210 let events = read_events_raw(&td.path().join(".shipper"));
7211 let types: Vec<String> = events.iter().filter_map(event_discriminator).collect();
7212 assert!(
7213 types.contains(&"rehearsal_package_failed".to_string()),
7214 "types: {types:?}"
7215 );
7216 assert!(
7217 types.contains(&"rehearsal_complete".to_string()),
7218 "types: {types:?}"
7219 );
7220
7221 let complete = events
7222 .iter()
7223 .find(|e| event_discriminator(e).as_deref() == Some("rehearsal_complete"))
7224 .expect("RehearsalComplete");
7225 assert_eq!(complete["event_type"]["passed"].as_bool(), Some(false));
7226 rehearsal_server.join();
7227 });
7228 }
7229}
7230
7231pub mod parallel;
7233
7234pub mod plan_yank;
7236
7237pub mod fix_forward;