From 753610d1c2cb2d33834479a0074bcf03b354c8f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B3zsa=20Zolt=C3=A1n?= <67325669+rozsazoltan@users.noreply.github.com> Date: Fri, 19 Jun 2026 09:28:54 +0200 Subject: [PATCH] fix: enforce datarose schema directive validation --- Cargo.lock | 190 ++++++++++++++++-- README.md | 12 +- crates/repository/src/lib.rs | 4 +- crates/repository/src/quality.rs | 6 +- crates/repository/src/schema.rs | 246 +++++++++++++++++++++++- crates/repository/tests/cli_contract.rs | 5 +- 6 files changed, 430 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 513c9b1..8ad6dba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -255,6 +255,17 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -387,6 +398,12 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -424,6 +441,48 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -528,6 +587,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + [[package]] name = "instability" version = "0.3.12" @@ -699,6 +770,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "predicates" version = "3.1.4" @@ -739,6 +816,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -839,6 +925,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + [[package]] name = "repository" version = "0.4.0" @@ -848,10 +940,13 @@ dependencies = [ "clap", "comfy-table", "crossterm 0.29.0", + "insta", "predicates", "ratatui", + "rstest", "serde_json", "tempfile", + "toml", ] [[package]] @@ -865,6 +960,35 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rstest" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +dependencies = [ + "futures-timer", + "futures-util", + "rstest_macros", +] + +[[package]] +name = "rstest_macros" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + [[package]] name = "rtoolbox" version = "0.0.5" @@ -886,6 +1010,15 @@ dependencies = [ "toml", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -1039,6 +1172,18 @@ dependencies = [ "libc", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.2" @@ -1148,6 +1293,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -1216,6 +1373,21 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "verzly" +version = "0.4.0" +dependencies = [ + "android-signing", + "anyhow", + "cargo-release", + "clap", + "github-release", + "ios-signing", + "repository", + "rust-cache", + "tauri-release", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -1235,21 +1407,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "verzly" -version = "0.4.0" -dependencies = [ - "android-signing", - "anyhow", - "cargo-release", - "clap", - "github-release", - "ios-signing", - "repository", - "rust-cache", - "tauri-release", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1432,6 +1589,9 @@ name = "winnow" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] [[package]] name = "wit-bindgen" diff --git a/README.md b/README.md index 6c2dc00..8ef65ba 100644 --- a/README.md +++ b/README.md @@ -435,7 +435,7 @@ verzly rust-cache env --config datarose.toml verzly repository check ``` -If no `datarose.toml` exists, `verzly` allows the command to run. This keeps the executable usable outside Verzly-managed repositories. If a `datarose.toml` exists, it must pass the embedded schema validator before the selected tool runs. +If no `datarose.toml` exists, `verzly` allows the command to run. This keeps the executable usable outside Verzly-managed repositories. If a `datarose.toml` exists, it must declare a supported schema reference and pass the embedded schema validator before the selected tool runs. The validator catches TOML syntax errors, unknown sections, unknown keys, wrong value types, unsupported enum values, invalid arrays, and required release fields that are missing. This prevents typos from being silently ignored in workflow or release configuration. @@ -452,7 +452,7 @@ manage_workflowz = true # typo: should be manage_workflows nam = "app" # typo: should be name ``` -`datarose.toml` may include a leading schema directive for humans and optional editor tooling, but this directive is not required by runtime validation: +`datarose.toml` must include a leading schema directive as the first non-empty line. Released consumer repositories should use the versioned public schema URL that matches the executable release: ```toml #:schema https://raw.githubusercontent.com/verzly/toolchain/v0.4.0/schemas/datarose.toml.schema.json @@ -466,7 +466,13 @@ The toolchain repository itself may use the local schema file while developing t version = 1 ``` -Runtime validation is implemented in Rust and parses the actual TOML document. It does not fetch the schema URL and does not require the directive to exist. The public `schemas/datarose.toml.schema.json` file is a mirrored public contract for review and optional editor integrations. When a supported config key changes, update the embedded Rust validator and the public schema together. +Runtime validation is implemented in Rust and parses the actual TOML document. It does not fetch the schema URL. Instead, it verifies the directive shape before running the embedded validator: + +- Local schema references must point to an existing file under the repository root. +- `https://raw.githubusercontent.com/verzly/toolchain/{ref}/schemas/datarose.toml.schema.json` references must use the same `{ref}` that was compiled into the executable. +- Missing directives, unrelated URLs, and mismatched refs are validation errors. + +The public `schemas/datarose.toml.schema.json` file is a mirrored public contract for review and optional editor integrations such as Even Better TOML/Taplo. When a supported config key changes, update the embedded Rust validator and the public schema together. Use `repository` first in downstream projects. It should describe the project layout, release targets, quality rules, cache conventions, and generated workflow expectations before release tooling is wired in. diff --git a/crates/repository/src/lib.rs b/crates/repository/src/lib.rs index 644ed74..de7a51b 100644 --- a/crates/repository/src/lib.rs +++ b/crates/repository/src/lib.rs @@ -28,8 +28,8 @@ where run(Cli::parse_from(args)) } -/// Validate an existing datarose.toml file with the same embedded schema used by -/// `repository check`. Missing files are ignored so `verzly` can run outside a managed repo. +/// Validate an existing datarose.toml file with the same directive and embedded schema checks used +/// by `repository check`. Missing files are ignored so `verzly` can run outside a managed repo. pub fn validate_datarose_for_tool_run(path: &Path) -> Result<()> { schema::validate_datarose_for_tool_run(path) } diff --git a/crates/repository/src/quality.rs b/crates/repository/src/quality.rs index 583f6ef..450076d 100644 --- a/crates/repository/src/quality.rs +++ b/crates/repository/src/quality.rs @@ -316,11 +316,13 @@ mod tests { assert!(!config.contains(" }\n\n}\n\nlocal qualitySteps")); assert!(!config.contains(" }\n\n}\n\nlocal fullQualitySteps")); - insta::assert_snapshot!(step_block(&config, "[\"test-rust\"]"), @r###"["test-rust"] { + insta::assert_snapshot!(step_block(&config, "[\"test-rust\"]"), @r###" +["test-rust"] { shell = defaultShell depends = List("format-rust", "lint-rust") check = "cd \"workspace/app\" && cargo nextest run --workspace && cargo test --workspace --doc" - }"###); + } +"###); } fn step_block(config: &str, marker: &str) -> String { diff --git a/crates/repository/src/schema.rs b/crates/repository/src/schema.rs index a7dda04..7adcee5 100644 --- a/crates/repository/src/schema.rs +++ b/crates/repository/src/schema.rs @@ -12,6 +12,8 @@ use toml::{Table, Value}; pub const DATAROSE_SCHEMA_DIRECTIVE: &str = "#:schema"; pub const DATAROSE_SCHEMA_PATH: &str = "schemas/datarose.toml.schema.json"; +const DATAROSE_SCHEMA_URL_PREFIX: &str = "https://raw.githubusercontent.com/verzly/toolchain/"; +const DEFAULT_CONFIG_NAME: &str = "datarose.toml"; /// The schema reference is owned by the executable build. /// @@ -45,25 +47,28 @@ pub fn validate_datarose_schema(path: &Path) -> Result> { let raw = fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; + let mut issues = Vec::new(); + validate_schema_directive(path, &raw, &mut issues); + let root = match raw.parse::() { Ok(root) => root, Err(error) => { - return Ok(vec![format!( + issues.push(format!( "{} has invalid TOML syntax: {error}", path.display() - )]); + )); + return Ok(issues); } }; - let mut issues = Vec::new(); validate_root(&root, &mut issues); Ok(issues) } /// Validate a repository datarose.toml file before any unified `verzly` command runs. /// /// Missing config files are allowed so tools can still run outside a Verzly-managed repository. -/// Existing config files are always validated with the embedded Rust schema validator, without -/// network access and without relying on editor-specific TOML schema mapping files. +/// Existing config files must declare a supported schema directive and are always validated with +/// the embedded Rust schema validator, without network access or editor-specific TOML mapping. pub fn validate_datarose_for_tool_run(path: &Path) -> Result<()> { let issues = validate_datarose_schema(path)?; if issues.is_empty() { @@ -118,6 +123,132 @@ fn validate_root(root: &Table, issues: &mut Vec) { } } +fn validate_schema_directive(path: &Path, raw: &str, issues: &mut Vec) { + let Some((_, line)) = raw + .lines() + .enumerate() + .find(|(_, line)| !line.trim().is_empty()) + else { + issues.push(format!( + "{DEFAULT_CONFIG_NAME} is missing a leading `{DATAROSE_SCHEMA_DIRECTIVE}` directive" + )); + return; + }; + + let line = line.trim(); + let Some(reference) = line.strip_prefix(DATAROSE_SCHEMA_DIRECTIVE) else { + issues.push(format!( + "{DEFAULT_CONFIG_NAME} is missing a leading `{DATAROSE_SCHEMA_DIRECTIVE}` directive; add `{}` as the first non-empty line", + datarose_schema_directive_line() + )); + return; + }; + if !reference.is_empty() && !reference.starts_with(char::is_whitespace) { + issues.push(format!( + "{DEFAULT_CONFIG_NAME} has an invalid `{DATAROSE_SCHEMA_DIRECTIVE}` directive; add a whitespace before the schema reference" + )); + return; + } + + let Some(reference) = reference.split_whitespace().next() else { + issues.push(format!( + "{DEFAULT_CONFIG_NAME} schema directive is missing a schema reference" + )); + return; + }; + + if reference.starts_with(DATAROSE_SCHEMA_URL_PREFIX) { + validate_online_schema_reference(reference, issues); + } else if reference.contains("://") { + issues.push(format!( + "{DEFAULT_CONFIG_NAME} schema directive must use a Verzly toolchain schema URL or a local relative path; found `{reference}`" + )); + } else { + validate_local_schema_reference(path, reference, issues); + } +} + +fn validate_online_schema_reference(reference: &str, issues: &mut Vec) { + let Some(rest) = reference.strip_prefix(DATAROSE_SCHEMA_URL_PREFIX) else { + issues.push(format!( + "{DEFAULT_CONFIG_NAME} schema directive must use `{DATAROSE_SCHEMA_URL_PREFIX}{{ref}}/{DATAROSE_SCHEMA_PATH}`; found `{reference}`" + )); + return; + }; + let suffix = format!("/{DATAROSE_SCHEMA_PATH}"); + let Some(schema_ref) = rest.strip_suffix(&suffix) else { + issues.push(format!( + "{DEFAULT_CONFIG_NAME} schema directive must use `{DATAROSE_SCHEMA_URL_PREFIX}{{ref}}/{DATAROSE_SCHEMA_PATH}`; found `{reference}`" + )); + return; + }; + if schema_ref.is_empty() { + issues.push(format!( + "{DEFAULT_CONFIG_NAME} schema directive is missing the Verzly toolchain ref" + )); + return; + } + if schema_ref == "local" { + issues.push(format!( + "{DEFAULT_CONFIG_NAME} schema directive uses reserved schema ref `local` in an HTTPS URL; use `{}` for this executable", + datarose_schema_directive_line() + )); + return; + } + + let expected = datarose_schema_ref(); + if schema_ref != expected { + issues.push(format!( + "{DEFAULT_CONFIG_NAME} schema directive uses schema ref `{schema_ref}`, but this executable expects `{expected}`" + )); + } +} + +fn validate_local_schema_reference(path: &Path, reference: &str, issues: &mut Vec) { + let schema_path = Path::new(reference); + if schema_path.is_absolute() { + issues.push(format!( + "{DEFAULT_CONFIG_NAME} schema directive local path must be relative; found `{reference}`" + )); + return; + } + + let root = path.parent().unwrap_or_else(|| Path::new(".")); + let expected_root = match root.canonicalize() { + Ok(path) => path, + Err(error) => { + issues.push(format!( + "failed to resolve {} parent directory for schema validation: {error}", + path.display() + )); + return; + } + }; + let schema_path = root.join(schema_path); + if !schema_path.is_file() { + issues.push(format!( + "{DEFAULT_CONFIG_NAME} schema directive points to `{reference}`, but {} does not exist", + schema_path.display() + )); + return; + } + let resolved_schema_path = match schema_path.canonicalize() { + Ok(path) => path, + Err(error) => { + issues.push(format!( + "failed to resolve {} for schema validation: {error}", + schema_path.display() + )); + return; + } + }; + if !resolved_schema_path.starts_with(&expected_root) { + issues.push(format!( + "{DEFAULT_CONFIG_NAME} schema directive local path must stay under the repository root; found `{reference}`" + )); + } +} + fn validate_quality(table: &Table, issues: &mut Vec) { validate_unknown_keys( table, @@ -923,7 +1054,7 @@ quality.rust.lint_profile has unsupported value `max`; expected one of strict, r quality.rust.lints.clippy.unwrap_used has unsupported lint level `sometimes`; expected one of allow, warn, deny, forbid" )] fn reports_schema_issues(#[case] content: &str, #[case] expected: &str) { - let path = temp_config("invalid", content); + let path = temp_config("invalid", &with_schema(content)); let issues = validate_datarose_schema(&path).unwrap(); @@ -934,7 +1065,8 @@ quality.rust.lints.clippy.unwrap_used has unsupported lint level `sometimes`; ex fn snapshots_complex_schema_diagnostics() { let path = temp_config( "snapshot", - r#"version = "1" + &with_schema( + r#"version = "1" [quality] languages = ["rust", 42] @@ -946,6 +1078,7 @@ target_branch = 123 source_repository = "verzly/toolchain" targets = "verzly" "#, + ), ); let issues = validate_datarose_schema(&path).unwrap(); @@ -961,20 +1094,113 @@ release.targets must be an array of tables; found string } #[test] - fn allows_missing_schema_reference_for_runtime_validation() { + fn reports_missing_schema_reference_for_runtime_validation() { let path = temp_config("missing-directive", "version = 1\n"); let issues = validate_datarose_schema(&path).unwrap(); - assert!(issues.is_empty()); + assert_eq!( + issues, + vec![format!( + "datarose.toml is missing a leading `#:schema` directive; add `{}` as the first non-empty line", + datarose_schema_directive_line() + )] + ); + } + + #[test] + fn reports_missing_local_schema_file() { + let path = temp_config_without_schema_file( + "missing-local-schema", + "#:schema ./schemas/datarose.toml.schema.json\nversion = 1\n", + ); + + let issues = validate_datarose_schema(&path).unwrap(); + + assert_eq!( + issues, + vec![format!( + "datarose.toml schema directive points to `./schemas/datarose.toml.schema.json`, but {} does not exist", + path.parent() + .unwrap() + .join(format!("./{DATAROSE_SCHEMA_PATH}")) + .display() + )] + ); + } + + #[test] + fn reports_mismatched_online_schema_ref() { + let expected = datarose_schema_ref(); + let mismatched = if expected == "v0.0.0-test" { + "v0.0.1-test" + } else { + "v0.0.0-test" + }; + let path = temp_config( + "mismatched-online-schema", + &format!( + "#:schema https://raw.githubusercontent.com/verzly/toolchain/{mismatched}/schemas/datarose.toml.schema.json\nversion = 1\n" + ), + ); + + let issues = validate_datarose_schema(&path).unwrap(); + + assert_eq!( + issues, + vec![format!( + "datarose.toml schema directive uses schema ref `{mismatched}`, but this executable expects `{expected}`" + )] + ); + } + + #[test] + fn reports_non_toolchain_schema_url() { + let path = temp_config( + "other-schema-url", + "#:schema https://example.com/datarose.toml.schema.json\nversion = 1\n", + ); + + let issues = validate_datarose_schema(&path).unwrap(); + + assert_eq!( + issues, + vec![ + "datarose.toml schema directive must use a Verzly toolchain schema URL or a local relative path; found `https://example.com/datarose.toml.schema.json`" + .to_string() + ] + ); + } + + fn with_schema(content: &str) -> String { + format!("{}\n{content}", datarose_schema_directive_line()) } fn temp_config(name: &str, content: &str) -> std::path::PathBuf { + temp_config_with_schema_file(name, content, true) + } + + fn temp_config_without_schema_file(name: &str, content: &str) -> std::path::PathBuf { + temp_config_with_schema_file(name, content, false) + } + + fn temp_config_with_schema_file( + name: &str, + content: &str, + create_schema_file: bool, + ) -> std::path::PathBuf { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); - let path = std::env::temp_dir().join(format!("datarose-schema-{name}-{unique}.toml")); + let root = std::env::temp_dir().join(format!("datarose-schema-{name}-{unique}")); + fs::create_dir_all(&root).unwrap(); + if create_schema_file { + let schema_path = root.join(DATAROSE_SCHEMA_PATH); + fs::create_dir_all(schema_path.parent().unwrap()).unwrap(); + fs::write(schema_path, "{}").unwrap(); + } + let path = root.join(DEFAULT_CONFIG_NAME); fs::write(&path, content).unwrap(); path } diff --git a/crates/repository/tests/cli_contract.rs b/crates/repository/tests/cli_contract.rs index d5ced65..8f41ab9 100644 --- a/crates/repository/tests/cli_contract.rs +++ b/crates/repository/tests/cli_contract.rs @@ -122,7 +122,8 @@ fn check_rejects_legacy_distribution_directory() { .arg(repo.path()) .assert() .failure() - .stderr(predicate::str::contains(".verzly/distributions")) + .stderr(predicate::str::contains(".verzly")) + .stderr(predicate::str::contains("distributions")) .stderr(predicate::str::contains("unified root action")); } @@ -155,6 +156,8 @@ fn fixture_repo() -> TempDir { fs::create_dir_all(root.join("packages/api")).unwrap(); fs::create_dir_all(root.join(".github/workflows")).unwrap(); + fs::create_dir_all(root.join("schemas")).unwrap(); + fs::write(root.join("schemas/datarose.toml.schema.json"), "{}").unwrap(); fs::write( root.join("datarose.toml"),