Semver-Check (#3439)

* Semver-Check

* Clippy

* Improve

* Fix

* Adjust log level

* Add initial API baseline

* CI

* Fix

* Docs

* Debug CI

* Honor CARGO_TARGET_DIR

* Compressed API baseline

* Renaming

* Refactor

* Move code to commands mod

* Prepare for 1.85

* Address review comments

* Simplify
This commit is contained in:
Björn Quentin 2025-05-09 16:40:42 +02:00 committed by GitHub
parent 00e1cf7d7d
commit 7b7844a855
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 407 additions and 14 deletions

View File

@ -1,5 +1,6 @@
[alias] [alias]
xtask = "run --package xtask --" xtask = "run --package xtask --"
xdoc = "run --package xtask --features=deploy-docs,preview-docs --" xdoc = "run --package xtask --features=deploy-docs,preview-docs --"
xfmt = "xtask fmt-packages" xfmt = "xtask fmt-packages"
qa = "xtask run example qa-test" qa = "xtask run example qa-test"
xcheck = "run --package xtask --features=semver-checks --"

View File

@ -96,6 +96,10 @@ jobs:
shell: bash shell: bash
run: cargo +${{ matrix.device.toolchain }} xtask ci ${{ matrix.device.soc }} run: cargo +${{ matrix.device.toolchain }} xtask ci ${{ matrix.device.soc }}
- name: Semver-Check
shell: bash
run: cargo +${{ matrix.device.toolchain }} xcheck semver-check --chips ${{ matrix.device.soc }} check
extras: extras:
runs-on: macos-m1-self-hosted runs-on: macos-m1-self-hosted

View File

@ -11,6 +11,8 @@ repository = "https://github.com/esp-rs/esp-hal"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
links = "esp-hal" links = "esp-hal"
exclude = [ "api-baseline", "MIGRATING-*", "CHANGELOG.md" ]
[package.metadata.docs.rs] [package.metadata.docs.rs]
default-target = "riscv32imac-unknown-none-elf" default-target = "riscv32imac-unknown-none-elf"
features = ["esp32c6"] features = ["esp32c6"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -728,7 +728,7 @@ macro_rules! io_type {
(Analog, $gpionum:literal) => { (Analog, $gpionum:literal) => {
// FIXME: the implementation shouldn't be in the GPIO module // FIXME: the implementation shouldn't be in the GPIO module
#[cfg(any(esp32c2, esp32c3, esp32c6, esp32h2))] #[cfg(any(esp32c2, esp32c3, esp32c6, esp32h2))]
#[cfg(any(doc, feature = "unstable"))] #[cfg(feature = "unstable")]
#[cfg_attr(docsrs, doc(cfg(feature = "unstable")))] #[cfg_attr(docsrs, doc(cfg(feature = "unstable")))]
impl $crate::gpio::AnalogPin for paste::paste!( [<GPIO $gpionum>]<'_> ) { impl $crate::gpio::AnalogPin for paste::paste!( [<GPIO $gpionum>]<'_> ) {
/// Configures the pin for analog mode. /// Configures the pin for analog mode.

View File

@ -576,14 +576,16 @@ pub struct Config {
cpu_clock: CpuClock, cpu_clock: CpuClock,
/// Enable watchdog timer(s). /// Enable watchdog timer(s).
#[cfg(any(doc, feature = "unstable"))] #[cfg(feature = "unstable")]
#[cfg_attr(docsrs, doc(cfg(feature = "unstable")))] #[cfg_attr(docsrs, doc(cfg(feature = "unstable")))]
#[builder_lite(unstable)]
watchdog: WatchdogConfig, watchdog: WatchdogConfig,
/// PSRAM configuration. /// PSRAM configuration.
#[cfg(any(doc, feature = "unstable"))] #[cfg(feature = "unstable")]
#[cfg_attr(docsrs, doc(cfg(feature = "unstable")))] #[cfg_attr(docsrs, doc(cfg(feature = "unstable")))]
#[cfg(feature = "psram")] #[cfg(feature = "psram")]
#[builder_lite(unstable)]
psram: psram::PsramConfig, psram: psram::PsramConfig,
} }

View File

@ -51,7 +51,7 @@ macro_rules! peripherals {
#[doc = "**This API is marked as unstable** and is only available when the `unstable` #[doc = "**This API is marked as unstable** and is only available when the `unstable`
crate feature is enabled. This comes with no stability guarantees, and could be changed crate feature is enabled. This comes with no stability guarantees, and could be changed
or removed at any time."] or removed at any time."]
#[cfg(any(doc, feature = "unstable"))] #[cfg(feature = "unstable")]
#[cfg_attr(docsrs, doc(cfg(feature = "unstable")))] #[cfg_attr(docsrs, doc(cfg(feature = "unstable")))]
pub $unstable_name: $unstable_name<'static>, pub $unstable_name: $unstable_name<'static>,
@ -59,7 +59,7 @@ macro_rules! peripherals {
#[doc = "**This API is marked as unstable** and is only available when the `unstable` #[doc = "**This API is marked as unstable** and is only available when the `unstable`
crate feature is enabled. This comes with no stability guarantees, and could be changed crate feature is enabled. This comes with no stability guarantees, and could be changed
or removed at any time."] or removed at any time."]
#[cfg(not(any(doc, feature = "unstable")))] #[cfg(not(feature = "unstable"))]
#[allow(unused)] #[allow(unused)]
pub(crate) $unstable_name: $unstable_name<'static>, pub(crate) $unstable_name: $unstable_name<'static>,
)* )*

View File

@ -2469,10 +2469,10 @@ mod dma {
} }
mod ehal1 { mod ehal1 {
#[cfg(any(doc, feature = "unstable"))] #[cfg(feature = "unstable")]
use embedded_hal::spi::{ErrorType, SpiBus}; use embedded_hal::spi::{ErrorType, SpiBus};
#[cfg(any(doc, feature = "unstable"))] #[cfg(feature = "unstable")]
use super::*; use super::*;
#[instability::unstable] #[instability::unstable]

View File

@ -23,7 +23,7 @@ crate::unstable_module! {
#[non_exhaustive] #[non_exhaustive]
pub enum Error { pub enum Error {
/// Error occurred due to a DMA-related issue. /// Error occurred due to a DMA-related issue.
#[cfg(any(doc, feature = "unstable"))] #[cfg(feature = "unstable")]
#[cfg_attr(docsrs, doc(cfg(feature = "unstable")))] #[cfg_attr(docsrs, doc(cfg(feature = "unstable")))]
#[allow(clippy::enum_variant_names, reason = "DMA is unstable")] #[allow(clippy::enum_variant_names, reason = "DMA is unstable")]
DmaError(DmaError), DmaError(DmaError),
@ -48,7 +48,7 @@ impl From<DmaError> for Error {
} }
#[doc(hidden)] #[doc(hidden)]
#[cfg(not(any(doc, feature = "unstable")))] #[cfg(not(feature = "unstable"))]
impl From<DmaError> for Error { impl From<DmaError> for Error {
fn from(_value: DmaError) -> Self { fn from(_value: DmaError) -> Self {
Error::Unknown Error::Unknown

View File

@ -450,7 +450,7 @@ pub struct UartRx<'d, Dm: DriverMode> {
#[non_exhaustive] #[non_exhaustive]
pub enum ConfigError { pub enum ConfigError {
/// The requested baud rate is not achievable. /// The requested baud rate is not achievable.
#[cfg(any(doc, feature = "unstable"))] #[cfg(feature = "unstable")]
#[cfg_attr(docsrs, doc(cfg(feature = "unstable")))] #[cfg_attr(docsrs, doc(cfg(feature = "unstable")))]
UnachievableBaudrate, UnachievableBaudrate,

View File

@ -30,6 +30,16 @@ reqwest = { version = "0.12.12", features = [
"native-tls-vendored", "native-tls-vendored",
], optional = true } ], optional = true }
# This pulls a gazillion crates - don't include it by default
cargo-semver-checks = { version = "0.41.0", optional = true }
# THIS NEEDS TO MATCH THE rustfmt-json FORMAT - see https://github.com/rust-lang/rustdoc-types/blob/trunk/CHANGELOG.md
rustdoc-types = { version = "0.35.0", optional = true }
flate2 = { version = "1.1.1", optional = true }
temp-file = { version = "0.1.9", optional = true }
[features] [features]
deploy-docs = ["dep:reqwest"] deploy-docs = ["dep:reqwest"]
preview-docs = ["dep:opener", "dep:rocket"] preview-docs = ["dep:opener", "dep:rocket"]
semver-checks = [ "dep:cargo-semver-checks", "dep:rustdoc-types", "dep:flate2", "dep:temp-file" ]

View File

@ -4,12 +4,13 @@ use anyhow::{Result, bail};
use clap::Args; use clap::Args;
use esp_metadata::Chip; use esp_metadata::Chip;
pub use self::{build::*, bump_version::*, run::*}; pub use self::{build::*, bump_version::*, run::*, semver_check::*};
use crate::{Package, cargo::CargoAction}; use crate::{Package, cargo::CargoAction};
mod build; mod build;
mod bump_version; mod bump_version;
mod run; mod run;
mod semver_check;
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Subcommand Arguments // Subcommand Arguments

View File

@ -0,0 +1,145 @@
use std::path::Path;
use clap::{Args, Subcommand};
use esp_metadata::Chip;
use strum::IntoEnumIterator;
use crate::Package;
#[derive(Debug, Subcommand)]
pub enum SemverCheckCmd {
GenerateBaseline,
Check,
}
#[derive(Debug, Args)]
pub struct SemverCheckArgs {
#[command(subcommand)]
pub command: SemverCheckCmd,
/// Package(s) to target.
#[arg(long, value_enum, value_delimiter = ',', default_values_t = vec![Package::EspHal])]
pub packages: Vec<Package>,
/// Chip(s) to target.
#[arg(long, value_enum, value_delimiter = ',', default_values_t = Chip::iter())]
pub chips: Vec<Chip>,
}
pub fn semver_checks(workspace: &Path, args: SemverCheckArgs) -> anyhow::Result<()> {
#[cfg(not(feature = "semver-checks"))]
{
let _ = workspace;
let _ = args;
return Err(anyhow::anyhow!(
"Feature `semver-checks` is not enabled. Use the `xcheck` alias",
));
}
#[cfg(feature = "semver-checks")]
match args.command {
SemverCheckCmd::GenerateBaseline => {
checker::generate_baseline(&workspace, args.packages, args.chips)
}
SemverCheckCmd::Check => {
checker::check_for_breaking_changes(&workspace, args.packages, args.chips)
}
}
}
#[cfg(feature = "semver-checks")]
pub mod checker {
use std::{
fs,
io::Write,
path::{Path, PathBuf},
};
use cargo_semver_checks::ReleaseType;
use esp_metadata::Chip;
use crate::{
Package,
semver_check::{build_doc_json, minimum_update, remove_unstable_items},
};
pub fn generate_baseline(
workspace: &Path,
packages: Vec<Package>,
chips: Vec<Chip>,
) -> Result<(), anyhow::Error> {
for package in packages {
log::info!("Generating API baseline for {package}");
for chip in &chips {
log::info!("Chip = {}", chip.to_string());
let package_name = package.to_string();
let package_path = crate::windows_safe_path(&workspace.join(&package_name));
let current_path = build_doc_json(package, chip, &package_path)?;
remove_unstable_items(&current_path)?;
let file_name = if package.chip_features_matter() {
chip.to_string()
} else {
"api".to_string()
};
let to_path = PathBuf::from(&package_path)
.join(format!("api-baseline/{}.json.gz", file_name));
fs::create_dir_all(to_path.parent().unwrap())?;
log::debug!("Compress into {current_path:?}");
let mut encoder = flate2::write::GzEncoder::new(
fs::File::create(to_path)?,
flate2::Compression::default(),
);
encoder.write_all(&std::fs::read(current_path)?)?;
if !package.chip_features_matter() {
break;
}
}
}
Ok(())
}
pub fn check_for_breaking_changes(
workspace: &Path,
packages: Vec<Package>,
chips: Vec<Chip>,
) -> Result<(), anyhow::Error> {
let mut semver_incompatible_packages = Vec::new();
for package in packages {
log::info!("Semver-check API for {package}");
for chip in &chips {
let result = minimum_update(workspace, package, *chip)?;
log::info!("Required bump = {:?}", result);
if result == ReleaseType::Major {
semver_incompatible_packages.push(package.to_string());
// no need to check other chips for this package
break;
}
if !package.chip_features_matter() {
break;
}
}
}
if !semver_incompatible_packages.is_empty() {
Err(anyhow::anyhow!(
"Semver check failed - needs a major bump: {}",
semver_incompatible_packages.join(", ")
))
} else {
Ok(())
}
}
}

View File

@ -16,6 +16,9 @@ pub mod commands;
pub mod documentation; pub mod documentation;
pub mod firmware; pub mod firmware;
#[cfg(feature = "semver-checks")]
pub mod semver_check;
#[derive( #[derive(
Debug, Debug,
Clone, Clone,

View File

@ -15,6 +15,8 @@ use xtask::{
commands::*, commands::*,
}; };
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Command-line Interface // Command-line Interface
@ -40,6 +42,8 @@ enum Cli {
Publish(PublishArgs), Publish(PublishArgs),
/// Generate git tags for all new package releases. /// Generate git tags for all new package releases.
TagReleases(TagReleasesArgs), TagReleases(TagReleasesArgs),
/// Semver Checks
SemverCheck(SemverCheckArgs),
} }
#[derive(Debug, Args)] #[derive(Debug, Args)]
@ -137,6 +141,7 @@ fn main() -> Result<()> {
Cli::LintPackages(args) => lint_packages(&workspace, args), Cli::LintPackages(args) => lint_packages(&workspace, args),
Cli::Publish(args) => publish(&workspace, args), Cli::Publish(args) => publish(&workspace, args),
Cli::TagReleases(args) => tag_releases(&workspace, args), Cli::TagReleases(args) => tag_releases(&workspace, args),
Cli::SemverCheck(args) => semver_checks(&workspace, args),
} }
} }

220
xtask/src/semver_check.rs Normal file
View File

@ -0,0 +1,220 @@
use std::{
fs,
io::Write,
path::{Path, PathBuf},
};
use cargo_semver_checks::{Check, GlobalConfig, ReleaseType, Rustdoc};
use esp_metadata::Chip;
use rustdoc_types::ItemEnum;
use crate::{Package, cargo::CargoArgsBuilder};
/// Return the minimum required bump for the next release.
/// Even if nothing changed this will be [ReleaseType::Patch]
pub fn minimum_update(
workspace: &Path,
package: Package,
chip: Chip,
) -> Result<ReleaseType, anyhow::Error> {
log::info!("Chip = {}", chip.to_string());
let package_name = package.to_string();
let package_path = crate::windows_safe_path(&workspace.join(&package_name));
let current_path = build_doc_json(package, &chip, &package_path)?;
remove_unstable_items(&current_path)?;
let file_name = if package.chip_features_matter() {
chip.to_string()
} else {
"api".to_string()
};
let baseline_path_gz =
PathBuf::from(&package_path).join(format!("api-baseline/{}.json.gz", file_name));
let baseline_path = temp_file::TempFile::new()?;
let buffer = Vec::new();
let mut decoder = flate2::write::GzDecoder::new(buffer);
decoder.write_all(&(fs::read(&baseline_path_gz)?))?;
fs::write(baseline_path.path(), decoder.finish()?)?;
let mut semver_check = Check::new(Rustdoc::from_path(current_path));
semver_check.set_baseline(Rustdoc::from_path(baseline_path.path()));
let mut cfg = GlobalConfig::new();
cfg.set_log_level(Some(log::Level::Info));
let result = semver_check.check_release(&mut cfg)?;
log::info!("Result {:?}", result);
let mut min_required_update = ReleaseType::Patch;
for (_, report) in result.crate_reports() {
if let Some(required_bump) = report.required_bump() {
let required_is_stricter = (min_required_update == ReleaseType::Patch)
|| (required_bump == ReleaseType::Major);
if required_is_stricter {
min_required_update = required_bump;
}
}
}
Ok(min_required_update)
}
pub(crate) fn build_doc_json(
package: Package,
chip: &Chip,
package_path: &PathBuf,
) -> Result<PathBuf, anyhow::Error> {
let target_dir = std::env::var("CARGO_TARGET_DIR");
let target_path = if let Ok(target) = target_dir {
PathBuf::from(target)
} else {
PathBuf::from(package_path).join("target")
};
let current_path = target_path
.join(chip.target())
.join("doc")
.join(format!("{}.json", package.to_string().replace("-", "_")));
std::fs::remove_file(&current_path).ok();
let features = if package.chip_features_matter() {
vec![chip.to_string(), "unstable".to_string()]
} else {
vec!["unstable".to_string()]
};
// always use `esp` toolchain so we don't have to deal with potentially
// different versions of the doc-json
let mut cargo_builder = CargoArgsBuilder::default()
.toolchain("esp")
.subcommand("rustdoc")
.features(&features)
.target(chip.target())
.arg("-Zunstable-options")
.arg("--lib")
.arg("--output-format=json");
cargo_builder = cargo_builder.arg("-Zbuild-std=alloc,core");
let cargo_args = cargo_builder.build();
log::debug!("{cargo_args:#?}");
crate::cargo::run(&cargo_args, package_path)?;
Ok(current_path)
}
pub(crate) fn remove_unstable_items(path: &Path) -> Result<(), anyhow::Error> {
// this leaves orphaned items! cargo-semver-checks seems to be fine with that
// however the json fmt is unstable - we might fail when using the "wrong"
// version of `rustdoc_types` here
//
// Hopefully this whole pre-processing is just a stop-gap solution until it's
// possible to generate docs for the stable-API only.
log::info!("{:?}", path);
let json_string = std::fs::read_to_string(path)?;
let mut krate: rustdoc_types::Crate = serde_json::from_str(&json_string)?;
let mut to_remove = vec![];
// first pass - just look for cfg-gated items
//
// the string to match depends on the rustfmt-json version!
// later version emit `#[<cfg>(...` instead
let cfg_gates = vec![
"#[cfg(any(doc, feature = \"unstable\"))]",
"#[cfg(feature = \"unstable\")]",
];
for (id, item) in &mut krate.index {
if item
.attrs
.iter()
.any(|attr| cfg_gates.contains(&attr.as_str()))
{
to_remove.push(id.clone());
}
}
log::debug!("Items to remove {:?}", to_remove);
for id in &to_remove {
krate.index.remove(&id);
krate.paths.remove(&id);
}
for (_id, item) in &mut krate.index {
match &mut item.inner {
ItemEnum::Module(module) => {
module.items.retain(|id| !to_remove.contains(id));
}
ItemEnum::Struct(struct_) => {
struct_.impls.retain(|id| !to_remove.contains(id));
match &mut struct_.kind {
rustdoc_types::StructKind::Unit => (),
rustdoc_types::StructKind::Tuple(_ids) => (),
rustdoc_types::StructKind::Plain {
fields,
has_stripped_fields: _,
} => {
for id in &to_remove {
if let Some(found) = fields.iter().enumerate().find(|i| i.1 == id) {
fields.remove(found.0);
}
}
}
}
if struct_.impls.is_empty() {
to_remove.push(_id.clone());
}
}
ItemEnum::Enum(enum_) => {
enum_.impls.retain(|id| !to_remove.contains(id));
enum_.variants.retain(|id| !to_remove.contains(id));
if enum_.impls.is_empty() {
to_remove.push(_id.clone());
}
}
ItemEnum::Impl(impl_) => {
impl_.items.retain(|id| !to_remove.contains(id));
if impl_.items.is_empty() {
to_remove.push(_id.clone());
}
}
// don't honor (because they either don't contain sub-items (= already handled in the
// first pass) or we currently don't use them)
//
// ItemEnum::Use(_)
// ItemEnum::Union(union)
// ItemEnum::StructField(_)
// ItemEnum::Variant(variant)
// ItemEnum::Function(function)
// ItemEnum::Trait(_)
// ItemEnum::TraitAlias(trait_alias)
// ItemEnum::TypeAlias(type_alias)
// ItemEnum::Constant {
// ItemEnum::Static(_)
// ItemEnum::ExternType =>
// ItemEnum::Macro(_)
// ItemEnum::ProcMacro(proc_macro)
// ItemEnum::Primitive(primitive)
// ItemEnum::AssocConst {
// ItemEnum::AssocType {
_ => (),
}
}
// if we added something more to remove (because the items are "empty" now -
// remove them, too)
for id in &to_remove {
krate.index.remove(&id);
krate.paths.remove(&id);
}
std::fs::write(path, serde_json::to_string(&krate)?)?;
Ok(())
}