@@ -58,6 +58,12 @@ struct StepOutcome {
5858 exit_code : i32 ,
5959 snapshot : Option < SnapshotRef > ,
6060 summaries : Vec < StepResultSummary > ,
61+ /// Set when this step did not complete successfully — it failed, timed
62+ /// out, was cancelled, or was itself skipped. Descendants gate on this
63+ /// (not on `exit_code`) so a skip propagates transitively: a skipped
64+ /// step reports `exit_code == 0`, so the exit code alone cannot
65+ /// distinguish "passed" from "skipped" and the cascade would break.
66+ failed_or_skipped : bool ,
6167}
6268
6369type StepFuture = futures:: future:: Shared < BoxFuture < ' static , StepOutcome > > ;
@@ -325,8 +331,12 @@ pub(crate) async fn run(
325331 let pred_outcomes: Vec < StepOutcome > =
326332 join_all ( preds. iter ( ) . map ( |( _, f) | f. clone ( ) ) ) . await ;
327333
328- // Early exit if any predecessor failed or the build was cancelled.
329- if cancel. is_cancelled ( ) || pred_outcomes. iter ( ) . any ( |o| o. exit_code != 0 ) {
334+ // Early exit if any predecessor failed/was skipped, or the build
335+ // was cancelled. Gating on `failed_or_skipped` (not `exit_code`)
336+ // is what makes the skip propagate transitively: a skipped
337+ // predecessor reports `exit_code == 0`, so an exit-code-only gate
338+ // would let a skipped step's descendants run anyway.
339+ if cancel. is_cancelled ( ) || pred_outcomes. iter ( ) . any ( |o| o. failed_or_skipped ) {
330340 let status = if cancel. is_cancelled ( ) {
331341 StepStatus :: Canceled
332342 } else {
@@ -342,6 +352,7 @@ pub(crate) async fn run(
342352 exit_code: None ,
343353 duration_ms: 0 ,
344354 } ] ,
355+ failed_or_skipped : true ,
345356 } ;
346357 }
347358
@@ -392,6 +403,7 @@ pub(crate) async fn run(
392403 exit_code: Some ( 1 ) ,
393404 duration_ms: 0 ,
394405 } ] ,
406+ failed_or_skipped : true ,
395407 }
396408 }
397409 }
@@ -428,6 +440,22 @@ pub(crate) async fn run(
428440 let outcomes: Vec < StepOutcome > = join_all ( pending) . await ;
429441 let any_failed = outcomes. iter ( ) . any ( |o| o. exit_code != 0 ) ;
430442
443+ // Reap ephemeral leaf snapshots. Uncached steps commit an `ephemeral:*`
444+ // image for downstream container lineage; the cache registry never tracks
445+ // them, so once the run is over nothing else will. Collect every such
446+ // snapshot the steps produced and ask the default runner to remove them
447+ // (best-effort — failures are logged, not fatal).
448+ let ephemeral: Vec < SnapshotRef > = outcomes
449+ . iter ( )
450+ . filter_map ( |o| o. snapshot . clone ( ) )
451+ . filter ( |s| s. 0 . starts_with ( "ephemeral:" ) )
452+ . collect ( ) ;
453+ if !ephemeral. is_empty ( )
454+ && let Some ( runner) = runner_registry. resolve ( None )
455+ {
456+ runner. reap_snapshots ( ephemeral) . await ;
457+ }
458+
431459 // Derive the overall verdict. Timeout wins (it also fired cancellation);
432460 // then cancellation; then any failed step; otherwise the build passed.
433461 let status = if timed_out {
@@ -552,6 +580,7 @@ async fn execute_step(
552580 exit_code : 0 ,
553581 snapshot : None ,
554582 summaries : vec ! [ summary] ,
583+ failed_or_skipped : true ,
555584 } ) ;
556585 continue ;
557586 }
@@ -612,10 +641,15 @@ async fn execute_step(
612641 } else {
613642 None
614643 } ;
644+ let failed_or_skipped = outcomes
645+ . iter ( )
646+ . flatten ( )
647+ . any ( |outcome| outcome. failed_or_skipped ) ;
615648 Ok ( StepOutcome {
616649 exit_code : first_failure. unwrap_or ( 0 ) ,
617650 snapshot,
618651 summaries,
652+ failed_or_skipped,
619653 } )
620654}
621655
@@ -641,10 +675,13 @@ async fn execute_command(
641675 let step_key = step_wire. key . clone ( ) ;
642676 let display_name = step_wire. label . clone ( ) . unwrap_or_else ( || {
643677 let cmd = step_wire. cmd . trim ( ) ;
644- if cmd. len ( ) <= 40 {
678+ if cmd. chars ( ) . count ( ) <= 40 {
645679 cmd. to_owned ( )
646680 } else {
647- format ! ( "{}…" , & cmd[ ..39 ] )
681+ // Truncate on a char boundary, not a byte offset: `&cmd[..39]`
682+ // panics if byte 39 falls inside a multibyte UTF-8 sequence.
683+ let truncated: String = cmd. chars ( ) . take ( 39 ) . collect ( ) ;
684+ format ! ( "{truncated}…" )
648685 }
649686 } ) ;
650687 let env_map = transition. env ;
@@ -754,6 +791,7 @@ async fn execute_command(
754791 exit_code: Some ( 124 ) ,
755792 duration_ms: dur_ms,
756793 } ] ,
794+ failed_or_skipped : true ,
757795 } ) ;
758796 }
759797 }
@@ -800,6 +838,7 @@ async fn execute_command(
800838 exit_code: Some ( sr. exit_code) ,
801839 duration_ms: dur_ms,
802840 } ] ,
841+ failed_or_skipped : sr. exit_code != 0 ,
803842 } )
804843 }
805844 Err ( e) => {
0 commit comments