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 <dir>/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 <noreply@anthropic.com>
This commit is contained in:
Scott Werner 2026-09-11 15:26:49 -06:00
parent 70b1090f58
commit 44dccfa3d2
2 changed files with 55 additions and 2 deletions

View file

@ -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();

View file

@ -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 {