@@ -518,6 +518,103 @@ public function single_flow_exclude_files_skips_job_like_flow_command()
518518 );
519519 }
520520
521+ // ─── Multi-flow modes must propagate entry-attrs end-to-end ──────
522+ // The bug spanned every mode, but the legacy multi-flow tests use
523+ // attribute-free fixtures (job_a/b/c → script true), so they could not
524+ // observe the loss. These exercise needs across the merged union (ad-hoc),
525+ // through meta-flow expansion, and the topological ordering in mixed runs.
526+
527+ /**
528+ * Fixture with several normal flows whose entries carry `needs`, plus a
529+ * meta-flow `ci`. `compile` fails so `pkg` (in a *different* flow than the
530+ * ones it merges with) must propagate the skip across the union.
531+ */
532+ private function buildMultiFlowAttrsFixture (): void
533+ {
534+ $ this ->configurationFileBuilder
535+ ->enableV3Mode ()
536+ ->setV3Hooks (['pre-commit ' => ['qa ' ]])
537+ ->setV3Flows ([
538+ 'options ' => ['processes ' => 1 , 'fail-fast ' => false ],
539+ 'build ' => ['jobs ' => [
540+ 'compile ' ,
541+ ['job ' => 'pkg ' , 'needs ' => ['compile ' ]],
542+ ]
543+ ],
544+ 'qa ' => ['jobs ' => [
545+ 'prep ' ,
546+ ['job ' => 'tests ' , 'needs ' => ['prep ' ]],
547+ ]
548+ ],
549+ 'deploy ' => ['jobs ' => [
550+ ['job ' => 'ship ' , 'needs ' => ['verify ' ]],
551+ 'verify ' ,
552+ ]
553+ ],
554+ 'ci ' => ['flows ' => ['build ' , 'qa ' ]],
555+ ])
556+ ->setV3Jobs ([
557+ 'compile ' => ['type ' => 'custom ' , 'script ' => 'exit 1 ' ],
558+ 'pkg ' => ['type ' => 'custom ' , 'script ' => 'echo pkg ' ],
559+ 'prep ' => ['type ' => 'custom ' , 'script ' => 'echo prep ' ],
560+ 'tests ' => ['type ' => 'custom ' , 'script ' => 'echo tests ' ],
561+ 'ship ' => ['type ' => 'custom ' , 'script ' => 'echo ship ' ],
562+ 'verify ' => ['type ' => 'custom ' , 'script ' => 'echo verify ' ],
563+ ])
564+ ->buildInFileSystem ();
565+ }
566+
567+ /** @test ad-hoc: `needs` propagates across the merged union of two flows */
568+ public function ad_hoc_multiflow_propagates_cross_flow_needs ()
569+ {
570+ $ this ->buildMultiFlowAttrsFixture ();
571+
572+ $ output = $ this ->runJson ("flows build qa --format=json --config= $ this ->configPath " );
573+
574+ $ this ->assertSame (['build ' , 'qa ' ], $ output ['flows ' ]);
575+ $ jobs = $ this ->indexJobs ($ output ['jobs ' ]);
576+ $ this ->assertFalse ($ jobs ['compile ' ]['success ' ], 'compile must fail ' );
577+ $ this ->assertTrue ($ jobs ['pkg ' ]['skipped ' ], 'pkg (flow build) must propagate its failed `needs` ' );
578+ $ this ->assertSame ('needs compile failed ' , $ jobs ['pkg ' ]['skipReason ' ]);
579+ $ this ->assertSame (['compile ' ], $ jobs ['pkg ' ]['needs ' ]);
580+ $ this ->assertFalse ($ jobs ['tests ' ]['skipped ' ], 'tests (flow qa) runs — its dependency prep passed ' );
581+ $ this ->assertSame (['prep ' ], $ jobs ['tests ' ]['needs ' ]);
582+ }
583+
584+ /** @test declarative: a meta-flow propagates `needs` through expansion */
585+ public function declarative_multiflow_propagates_needs_through_expansion ()
586+ {
587+ $ this ->buildMultiFlowAttrsFixture ();
588+
589+ $ output = $ this ->runJson ("flows ci --format=json --config= $ this ->configPath " );
590+
591+ $ this ->assertSame (['build ' , 'qa ' ], $ output ['flows ' ]);
592+ $ jobs = $ this ->indexJobs ($ output ['jobs ' ]);
593+ $ this ->assertTrue ($ jobs ['pkg ' ]['skipped ' ], 'pkg must propagate via needs after meta-flow expansion ' );
594+ $ this ->assertSame ('needs compile failed ' , $ jobs ['pkg ' ]['skipReason ' ]);
595+ }
596+
597+ /** @test mixed: the merged union is ordered topologically across flows */
598+ public function mixed_multiflow_orders_merged_jobs_topologically ()
599+ {
600+ $ this ->buildMultiFlowAttrsFixture ();
601+
602+ $ output = $ this ->runJson ("flows ci deploy --format=json --config= $ this ->configPath " );
603+
604+ $ this ->assertSame (['build ' , 'qa ' , 'deploy ' ], $ output ['flows ' ]);
605+ $ names = array_column ($ output ['jobs ' ], 'name ' );
606+ // `ship` declares `needs: [verify]` *before* verify in the flow; the
607+ // reconstructed graph must still emit verify first.
608+ $ this ->assertLessThan (
609+ array_search ('ship ' , $ names , true ),
610+ array_search ('verify ' , $ names , true ),
611+ 'verify must precede ship (topological order across the aggregate) '
612+ );
613+ $ jobs = $ this ->indexJobs ($ output ['jobs ' ]);
614+ $ this ->assertSame (['verify ' ], $ jobs ['ship ' ]['needs ' ]);
615+ $ this ->assertFalse ($ jobs ['ship ' ]['skipped ' ], 'ship runs — verify passed ' );
616+ }
617+
521618 /**
522619 * Index a JSON v2 `jobs` array by job name.
523620 *
0 commit comments