diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4dd473b5..7c6b2e2f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,10 +3,7 @@ updates: - package-ecosystem: cargo directories: - / - - /benchmark - /fuzz - - /repository-process-spawn - - /xtask schedule: interval: weekly open-pull-requests-limit: 5 diff --git a/CHANGELOG.md b/CHANGELOG.md index d2619b2e..8e3a6598 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ after its public API and format compatibility policies are established. ## [Unreleased] +- Stop naming Cargo workspace members as Dependabot update directories. Dependabot refuses to update from inside a member and the job for the workspace root already updates member manifests, so those entries failed the scheduled run without adding coverage. The dependency policy check now reads the root workspace member list, refuses a member listed as an update directory, and still requires an entry for every standalone manifest. + - Admit macOS APFS as a production storage platform, on both default and case-sensitive volumes. Keep requires every existing protocol name byte-for-byte in a listing of its parent, so `head` never answers for `HEAD`. Adds a native macOS CI job; no format, dependency, or unsafe change. The crash matrix passes natively, but no power-loss experiment has been run on APFS. - Update the development-only Markdown validation graph and exact admission policy to markdownlint-cli2 0.24.0. On the 2026-11-03 audit this removes the markdown-it and smol-toml reports and adds the high-severity re2js advisory GHSA-gg6h-894w-4m9c through markdownlint's exact pin. This is a currency update, not a demonstrated security gain: markdown-it was not loaded on Keep's path, re2js now is, and the admission records why its affected path appears unreachable. MD011 also reports slightly less, and #209 tracks removing re2js (#188). diff --git a/xtask/src/documentation_integrity/dependabot.rs b/xtask/src/documentation_integrity/dependabot.rs index 1f6f8e83..8b6ad97c 100644 --- a/xtask/src/documentation_integrity/dependabot.rs +++ b/xtask/src/documentation_integrity/dependabot.rs @@ -1,6 +1,7 @@ //! This module owns Dependabot manifest coverage and maintenance policy. mod manifest; +mod workspace_members; use std::collections::BTreeSet; @@ -11,6 +12,7 @@ use crate::repository_file::{RepositoryProcessDirectory, RepositoryRoot}; use super::error::DocumentationError; use super::repository_text::{self, RepositoryText}; use manifest::tracked_scopes; +use workspace_members::ROOT_MANIFEST_PATH; const DEPENDABOT_PATH: &str = ".github/dependabot.yml"; @@ -20,18 +22,36 @@ struct DependencyScope { directory: String, } +/// Separates scopes Dependabot must update from workspace members it must not name. +/// Dependabot refuses to update from inside a member, and the job for the workspace +/// root already updates member manifests. +struct ScopeContract { + required: BTreeSet, + members: BTreeSet, +} + +impl ScopeContract { + fn new(tracked: &BTreeSet, members: BTreeSet) -> Self { + let required = tracked.difference(&members).cloned().collect(); + Self { required, members } + } +} + pub(super) fn check( repository_root: &RepositoryRoot, process_directory: &RepositoryProcessDirectory, ) -> Result { let raw = repository_text::read(repository_root, DEPENDABOT_PATH)?; - let required = tracked_scopes(process_directory)?; - admit(raw.as_str(), &required)?; + let manifest = repository_text::read(repository_root, ROOT_MANIFEST_PATH)?; + let members = workspace_members::admit(manifest.as_str())?; + let scopes = ScopeContract::new(&tracked_scopes(process_directory)?, members); + admit(raw.as_str(), &scopes)?; + manifest.verify(repository_root)?; raw.verify(repository_root)?; Ok(raw) } -fn admit(raw: &str, required: &BTreeSet) -> Result<(), DocumentationError> { +fn admit(raw: &str, scopes: &ScopeContract) -> Result<(), DocumentationError> { let documents = YamlLoader::load_from_str(raw).map_err(|source| DocumentationError::RepositoryYaml { path: DEPENDABOT_PATH, @@ -62,7 +82,13 @@ fn admit(raw: &str, required: &BTreeSet) -> Result<(), Document } } } - if let Some(missing) = required.difference(&configured).next() { + if let Some(member) = scopes.members.intersection(&configured).next() { + return Err(contract_at( + member.diagnostic(), + "workspace member is updated through its workspace root", + )); + } + if let Some(missing) = scopes.required.difference(&configured).next() { return Err(contract_at( missing.diagnostic(), "tracked dependency scope has an update policy", diff --git a/xtask/src/documentation_integrity/dependabot/tests.rs b/xtask/src/documentation_integrity/dependabot/tests.rs index 28b63342..0c4921b5 100644 --- a/xtask/src/documentation_integrity/dependabot/tests.rs +++ b/xtask/src/documentation_integrity/dependabot/tests.rs @@ -3,7 +3,7 @@ use std::path::Path; use crate::repository_file::RepositoryRoot; -use super::DependencyScope; +use super::{DependencyScope, ScopeContract, workspace_members}; const POLICY: &str = r"version: 2 updates: @@ -26,7 +26,7 @@ updates: - dependencies "; -fn required() -> BTreeSet { +fn tracked() -> BTreeSet { [ DependencyScope::new("cargo", "/"), DependencyScope::new("cargo", "/xtask"), @@ -36,6 +36,15 @@ fn required() -> BTreeSet { .collect() } +fn required() -> ScopeContract { + ScopeContract::new(&tracked(), BTreeSet::new()) +} + +fn member_contract() -> ScopeContract { + let members = [DependencyScope::new("cargo", "/xtask")].into(); + ScopeContract::new(&tracked(), members) +} + #[test] fn complete_uniform_dependabot_policy_is_admitted() { assert!(super::admit(POLICY, &required()).is_ok()); @@ -150,3 +159,92 @@ fn committed_dependabot_policy_covers_every_tracked_manifest() assert!(repository_root.is_current_path()?); Ok(()) } + +#[test] +fn workspace_members_need_no_update_scope_of_their_own() { + let policy = POLICY.replace(" - /xtask\n", ""); + assert!(super::admit(&policy, &member_contract()).is_ok()); +} + +#[test] +fn a_workspace_member_listed_as_an_update_scope_is_refused() { + assert!(matches!( + super::admit(POLICY, &member_contract()), + Err(super::DocumentationError::RepositoryContractAt { + path: super::DEPENDABOT_PATH, + ref subject, + requirement: "workspace member is updated through its workspace root", + }) if subject == "cargo /xtask" + )); +} + +#[test] +fn a_standalone_manifest_still_needs_its_own_update_scope() { + let members = [DependencyScope::new("cargo", "/xtask")].into(); + let tracked = [ + DependencyScope::new("cargo", "/"), + DependencyScope::new("cargo", "/fuzz"), + DependencyScope::new("cargo", "/xtask"), + DependencyScope::new("github-actions", "/"), + ] + .into(); + let policy = POLICY.replace(" - /xtask\n", ""); + assert!(matches!( + super::admit(&policy, &ScopeContract::new(&tracked, members)), + Err(super::DocumentationError::RepositoryContractAt { + path: super::DEPENDABOT_PATH, + ref subject, + requirement: "tracked dependency scope has an update policy", + }) if subject == "cargo /fuzz" + )); +} + +#[test] +fn workspace_members_exclude_the_root_and_name_exact_directories() { + let manifest = "[package]\nname = \"keep\"\n\n[workspace]\nmembers = [\".\", \"xtask\", \"tools/a\"]\nresolver = \"3\"\n\n[workspace.lints.rust]\n"; + let expected: BTreeSet<_> = [ + DependencyScope::new("cargo", "/tools/a"), + DependencyScope::new("cargo", "/xtask"), + ] + .into(); + assert_eq!(workspace_members::admit(manifest).ok(), Some(expected)); +} + +#[test] +fn unprovable_workspace_member_lists_are_refused() { + for (manifest, expected) in [ + ("[package]\n", "workspace declares exactly one member list"), + ( + "[workspace]\nresolver = \"3\"\n", + "workspace declares exactly one member list", + ), + ( + "[workspace]\nmembers = [\"a\"]\nmembers = [\"b\"]\n", + "workspace declares exactly one member list", + ), + ( + "[workspace]\nmembers = [\n \"a\",\n]\n", + "workspace members are one inline list", + ), + ( + "[workspace]\nmembers = [a]\n", + "workspace member is one quoted path", + ), + ( + "[workspace]\nmembers = [\"crates/*\"]\n", + "workspace member is one exact relative path", + ), + ( + "[workspace]\nmembers = [\"../a\"]\n", + "workspace member is one exact relative path", + ), + ] { + assert!(matches!( + workspace_members::admit(manifest), + Err(super::DocumentationError::RepositoryContract { + path: workspace_members::ROOT_MANIFEST_PATH, + requirement, + }) if requirement == expected + )); + } +} diff --git a/xtask/src/documentation_integrity/dependabot/workspace_members.rs b/xtask/src/documentation_integrity/dependabot/workspace_members.rs new file mode 100644 index 00000000..e8b822c3 --- /dev/null +++ b/xtask/src/documentation_integrity/dependabot/workspace_members.rs @@ -0,0 +1,53 @@ +//! This module owns workspace-member discovery from the root Cargo manifest. + +use std::collections::BTreeSet; + +use xtask::protocol_admission::posix_relative_path; + +use super::DependencyScope; +use crate::documentation_integrity::error::DocumentationError; + +pub(super) const ROOT_MANIFEST_PATH: &str = "Cargo.toml"; + +/// Returns the Cargo scopes that belong to the root workspace, excluding the root. +/// +/// Xtask has no TOML parser and reads one fixed manifest, so this admits a single +/// inline list of exact paths. A glob or any other spelling is refused, because a +/// guess could drop a standalone manifest from Dependabot coverage. +pub(super) fn admit(manifest: &str) -> Result, DocumentationError> { + let mut lists = manifest + .lines() + .skip_while(|line| *line != "[workspace]") + .skip(1) + .take_while(|line| !line.starts_with('[')) + .filter(|line| line.starts_with("members")); + let (Some(list), None) = (lists.next(), lists.next()) else { + return Err(contract("workspace declares exactly one member list")); + }; + let entries = list + .strip_prefix("members = [") + .and_then(|rest| rest.strip_suffix(']')) + .ok_or_else(|| contract("workspace members are one inline list"))?; + let mut members = BTreeSet::new(); + for entry in entries.split(", ") { + let path = entry + .strip_prefix('"') + .and_then(|rest| rest.strip_suffix('"')) + .ok_or_else(|| contract("workspace member is one quoted path"))?; + if path == "." { + continue; + } + if path.contains('*') || posix_relative_path(path).is_err() { + return Err(contract("workspace member is one exact relative path")); + } + members.insert(DependencyScope::new("cargo", &format!("/{path}"))); + } + Ok(members) +} + +const fn contract(requirement: &'static str) -> DocumentationError { + DocumentationError::RepositoryContract { + path: ROOT_MANIFEST_PATH, + requirement, + } +}