From 44dccfa3d221558a1af23d6aa47b4e966ea077df Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Fri, 11 Sep 2026 15:26:49 -0600 Subject: [PATCH] Require version config to be workflow.toml beside its graph WorkflowLocation dispatches any `.toml` path to the config loader, so a supplied entrypoint such as `sub/run.toml` was accepted, its graph became the version entrypoint, and the config file was registered under its own name. Runtime only reads WorkflowVersion::config_path(), the fixed sibling `workflow.toml`, so the version's goal, environment, and Dockerfile settings were silently dropped on every run. In workflow-version projection, reject a config whose collected path is not the graph's sibling `workflow.toml`. This applies to every caller that packages versions, including `fabro run /other.toml`, which previously registered the config and then ignored it; failing at packaging replaces a silent drop. Manifest bundling for the legacy run path does not project versions and is unchanged. Co-Authored-By: Claude Fable 5.1 --- .../fabro-manifest/src/supplied_workflow.rs | 40 +++++++++++++++++++ .../fabro-manifest/src/workflow_bundler.rs | 17 +++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/lib/components/fabro-manifest/src/supplied_workflow.rs b/lib/components/fabro-manifest/src/supplied_workflow.rs index 9f247294c..0970bf9ff 100644 --- a/lib/components/fabro-manifest/src/supplied_workflow.rs +++ b/lib/components/fabro-manifest/src/supplied_workflow.rs @@ -254,6 +254,46 @@ mod tests { } } + #[test] + fn config_entrypoint_must_be_workflow_toml_beside_its_graph() { + let config = "_version = 1\n[workflow]\ngraph = \"g.fabro\"\n[run]\ngoal = \"hello\"\n"; + let accepted = supplied("sub/workflow.toml", &[ + ("sub/workflow.toml", config), + ("sub/g.fabro", "digraph W {}"), + ]); + let root = collect(&accepted) + .versions() + .last() + .unwrap() + .1 + .version() + .clone(); + assert!(root.files().contains_key(&root.config_path())); + + // Same tree under another config name: runtime would never read it. + let renamed = supplied("sub/run.toml", &[ + ("sub/run.toml", config), + ("sub/g.fabro", "digraph W {}"), + ]); + let error = + collect_supplied_workflow_versions(&renamed.entrypoint, &renamed.files).unwrap_err(); + assert!( + format!("{error:#}").contains("must be `sub/workflow.toml`"), + "{error:#}" + ); + // A config that selects a graph in another directory is not its sibling. + let elsewhere = supplied("sub/workflow.toml", &[ + ( + "sub/workflow.toml", + "_version = 1\n[workflow]\ngraph = \"../g.fabro\"\n", + ), + ("g.fabro", "digraph W {}"), + ]); + assert!( + collect_supplied_workflow_versions(&elsewhere.entrypoint, &elsewhere.files).is_err() + ); + } + #[test] fn preserves_literal_scripts_without_executing() { let directory = tempfile::tempdir().unwrap(); diff --git a/lib/components/fabro-manifest/src/workflow_bundler.rs b/lib/components/fabro-manifest/src/workflow_bundler.rs index 9eb0add4a..98202fbe1 100644 --- a/lib/components/fabro-manifest/src/workflow_bundler.rs +++ b/lib/components/fabro-manifest/src/workflow_bundler.rs @@ -97,9 +97,22 @@ impl<'a> WorkflowBundler<'a> { let source = self.read_package_file(&location.graph)?; let config = if let Some(workflow_toml_path) = location.toml.as_ref() { + let config_path = manifest_path_from_absolute(workflow_toml_path, self.package_root)?; + if self.workflow_version_projection { + // A version's config is read at run time from the fixed + // sibling path only, so a config file under any other name + // would be registered and then silently ignored. + let expected = dot_path.parent_or_dot().join("workflow.toml"); + if config_path.as_path() != expected { + bail!( + "workflow configuration `{config_path}` must be `{}` beside its graph \ + `{dot_path}`", + expected.display() + ); + } + } Some(types::ManifestWorkflowConfig { - path: manifest_path_from_absolute(workflow_toml_path, self.package_root)? - .to_string(), + path: config_path.to_string(), source: self.read_package_file(workflow_toml_path)?, }) } else {