From f831992d984f8f763e3dfcc9dc3d35cd353015ab Mon Sep 17 00:00:00 2001 From: Albin Skott Date: Mon, 21 Sep 2026 10:25:57 +0200 Subject: [PATCH 1/5] Add FastAPI route entrypoints to the analysis API --- src/extension/fastapi.rs | 9 ++++ src/extension/mod.rs | 73 +++++++++++++++++++++++++++ src/lib.rs | 22 +++++++- tests/fixtures/fastapi_entrypoints.py | 26 ++++++++++ tests/integration_test.rs | 14 ++++- 5 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/fastapi_entrypoints.py diff --git a/src/extension/fastapi.rs b/src/extension/fastapi.rs index ea3b395..4c3456d 100644 --- a/src/extension/fastapi.rs +++ b/src/extension/fastapi.rs @@ -19,6 +19,15 @@ use crate::{ }, }; +pub(crate) fn is_route(function: &StmtFunctionDef) -> bool { + function.decorator_list.iter().any(|decorator| { + decorator + .expression + .as_call_expr() + .is_some_and(|call| is_route_decorator(&call.func)) + }) +} + pub(crate) fn documented_exceptions( db: &dyn Db, file: File, diff --git a/src/extension/mod.rs b/src/extension/mod.rs index f954e6c..0928b3d 100644 --- a/src/extension/mod.rs +++ b/src/extension/mod.rs @@ -9,6 +9,30 @@ pub enum AnalysisExtension { Fastapi, } +#[derive( + Clone, Copy, Debug, PartialEq, Eq, Hash, clap::ValueEnum, serde::Deserialize, get_size2::GetSize, +)] +pub enum Entrypoint { + /// Check FastAPI route handlers while still following their transitive calls. + #[value(name = "fastapi:route")] + #[serde(rename = "fastapi:route")] + FastapiRoute, +} + +impl Entrypoint { + fn required_extension(self) -> AnalysisExtension { + match self { + Self::FastapiRoute => AnalysisExtension::Fastapi, + } + } + + fn name(self) -> &'static str { + match self { + Self::FastapiRoute => "fastapi:route", + } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Deserialize, get_size2::GetSize)] #[serde(rename_all = "kebab-case")] pub enum ContextManagerEffect { @@ -29,6 +53,7 @@ pub struct ContextManagerEffectRule { #[derive(Clone, Debug, Default, PartialEq, Eq, Hash, get_size2::GetSize)] pub struct AnalysisOptions { extensions: Vec, + entrypoints: Vec, context_manager_effects: Vec, } @@ -54,6 +79,36 @@ impl AnalysisOptions { self.extensions.contains(&extension) } + pub fn with_entrypoint(mut self, entrypoint: Entrypoint) -> Self { + if !self.entrypoints.contains(&entrypoint) { + self.entrypoints.push(entrypoint); + } + self + } + + pub fn with_entrypoints(mut self, entrypoints: impl IntoIterator) -> Self { + for entrypoint in entrypoints { + self = self.with_entrypoint(entrypoint); + } + self + } + + pub fn validate(&self) -> anyhow::Result<()> { + for entrypoint in &self.entrypoints { + let required_extension = entrypoint.required_extension(); + anyhow::ensure!( + self.extension_enabled(required_extension), + "entrypoint `{}` requires the `fastapi` extension", + entrypoint.name() + ); + } + Ok(()) + } + + pub(crate) fn entrypoints(&self) -> &[Entrypoint] { + &self.entrypoints + } + pub fn with_context_manager_effects( mut self, effects: impl IntoIterator, @@ -68,3 +123,21 @@ impl AnalysisOptions { self.context_manager_effects.iter() } } + +#[cfg(test)] +mod tests { + use super::{AnalysisOptions, Entrypoint}; + + #[test] + fn fastapi_route_entrypoint_requires_fastapi_extension() { + let error = AnalysisOptions::default() + .with_entrypoint(Entrypoint::FastapiRoute) + .validate() + .unwrap_err(); + + assert_eq!( + error.to_string(), + "entrypoint `fastapi:route` requires the `fastapi` extension" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 1bbdc32..f209f91 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,7 +25,7 @@ mod module; mod transitive_error; pub use extension::{ - AnalysisExtension, AnalysisOptions, ContextManagerEffect, ContextManagerEffectRule, + AnalysisExtension, AnalysisOptions, ContextManagerEffect, ContextManagerEffectRule, Entrypoint, }; pub use transitive_error::analysis::{AnalysisGap, AnalysisGapImpact, AnalysisGapKind}; pub use transitive_error::exception::Exception; @@ -71,6 +71,7 @@ pub fn analyze_project_with_options( progress_bar: Option<&'static ProgressBar>, options: AnalysisOptions, ) -> Result> { + options.validate()?; let (sender, receiver) = bounded(10); let files = db.project().files(&db).iter().collect_vec(); if let Some(pb) = &progress_bar { @@ -145,6 +146,9 @@ fn analyze_file_with_gaps( module_collector.init(&module_ref); for func_def in module_collector.list_functions() { + if !is_selected_entrypoint(options, func_def) { + continue; + } let mut analysis = get_transitive_analysis( db, file, @@ -182,6 +186,22 @@ fn analyze_file_with_gaps( } } +fn is_selected_entrypoint( + options: &AnalysisOptions, + function: &ruff_python_ast::StmtFunctionDef, +) -> bool { + if options.entrypoints().is_empty() { + return true; + } + + options + .entrypoints() + .iter() + .any(|entrypoint| match entrypoint { + Entrypoint::FastapiRoute => extension::fastapi::is_route(function), + }) +} + pub fn resolve_absolute_module_path(db: &dyn Db, path: &str) -> Exception { let parts = path.split(".").collect_vec(); let exception_name = parts diff --git a/tests/fixtures/fastapi_entrypoints.py b/tests/fixtures/fastapi_entrypoints.py new file mode 100644 index 0000000..1446844 --- /dev/null +++ b/tests/fixtures/fastapi_entrypoints.py @@ -0,0 +1,26 @@ +class RouteError(RuntimeError): + pass + + +class Router: + def get(self, *args, **kwargs): + def decorator(function): + return function + + return decorator + + +router = Router() + + +def raises_from_helper() -> None: + raise RouteError() + + +@router.get("/route") +def route() -> None: + raises_from_helper() + + +def unrelated() -> None: + raise RouteError() diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 8f708bd..303709e 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -5,7 +5,7 @@ use std::env::current_dir; use itertools::{EitherOrBoth, Itertools}; use py_checked_exceptions::{ AnalysisEvent, AnalysisExtension, AnalysisGapKind, AnalysisOptions, ContextManagerEffect, - ContextManagerEffectRule, analyze_project, analyze_project_with_gaps, + ContextManagerEffectRule, Entrypoint, analyze_project, analyze_project_with_gaps, analyze_project_with_options, resolve_absolute_module_path, }; use ruff_db::{ @@ -167,6 +167,18 @@ fn test_fastapi_response_models_require_extension() -> Result<()> { ) } +#[test] +fn test_fastapi_route_entrypoints() -> Result<()> { + assert_diagnostics_with_options( + "fastapi_entrypoints.py", + None, + AnalysisOptions::default() + .with_extension(AnalysisExtension::Fastapi) + .with_entrypoint(Entrypoint::FastapiRoute), + vec![("Raises undocumented error RouteError", (22, 5), (22, 25))], + ) +} + #[test] fn test_fastapi_dependencies() -> Result<()> { assert_diagnostics_with_options( From bba36fc4681fc340c69cd290cf1ef34f66e32847 Mon Sep 17 00:00:00 2001 From: Albin Skott Date: Mon, 21 Sep 2026 10:26:33 +0200 Subject: [PATCH 2/5] Expose entrypoint selection in CLI and project configuration --- src/args.rs | 22 ++++++++++++++++++++-- src/config.rs | 5 ++++- src/main.rs | 24 ++++++++++++++++-------- tests/config_test.rs | 10 ++++++++++ 4 files changed, 50 insertions(+), 11 deletions(-) diff --git a/src/args.rs b/src/args.rs index 8e0323b..52db9c5 100644 --- a/src/args.rs +++ b/src/args.rs @@ -8,7 +8,7 @@ use ty_project::metadata::{ }; use crate::logging::Verbosity; -use py_checked_exceptions::AnalysisExtension; +use py_checked_exceptions::{AnalysisExtension, Entrypoint}; #[derive(Debug, Parser)] #[command( @@ -54,6 +54,11 @@ pub(crate) struct CheckCommand { #[arg(long = "extension", value_name = "EXTENSION")] pub(crate) extensions: Vec, + /// Restrict diagnostics to selected analysis entrypoints (can be passed multiple times). + /// Transitive calls from selected entrypoints are still analyzed. + #[arg(long = "entrypoint", value_name = "SELECTOR")] + pub(crate) entrypoints: Vec, + /// Path to the Python environment. /// /// py-checked-exceptions uses the Python environment to resolve type information and third-party dependencies. @@ -246,7 +251,7 @@ pub(crate) enum TerminalColor { mod tests { use clap::Parser; - use super::{AnalysisExtension, AnalysisGapOutput, Cli, Command}; + use super::{AnalysisExtension, AnalysisGapOutput, Cli, Command, Entrypoint}; #[test] fn analysis_gap_output_is_opt_in() { @@ -282,4 +287,17 @@ mod tests { let Command::Check(check) = cli.command; assert_eq!(check.extensions, vec![AnalysisExtension::Fastapi]); } + + #[test] + fn accepts_fastapi_route_entrypoint() { + let cli = Cli::try_parse_from([ + "py-checked-exceptions", + "check", + "--entrypoint", + "fastapi:route", + ]) + .unwrap(); + let Command::Check(check) = cli.command; + assert_eq!(check.entrypoints, vec![Entrypoint::FastapiRoute]); + } } diff --git a/src/config.rs b/src/config.rs index 06e364f..9890627 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,7 @@ use std::{io::ErrorKind, sync::Arc}; use anyhow::{Context, Result}; -use py_checked_exceptions::{AnalysisExtension, ContextManagerEffectRule}; +use py_checked_exceptions::{AnalysisExtension, ContextManagerEffectRule, Entrypoint}; use ruff_db::system::{System, SystemPath, SystemPathBuf}; use ruff_ranged_value::ValueSource; use serde::Deserialize; @@ -14,6 +14,7 @@ use crate::args::{AnalysisGapOutput, OutputFormat, TerminalColor}; pub(crate) struct ProjectConfig { pub(crate) target_exceptions: Option>, pub(crate) extensions: Option>, + pub(crate) entrypoints: Option>, pub(crate) context_manager_effects: Option>, pub(crate) python: Option, pub(crate) typeshed: Option, @@ -145,6 +146,7 @@ mod tests { [tool.py-checked-exceptions] target-exceptions = ["example.BaseError"] extensions = ["fastapi"] + entrypoints = ["fastapi:route"] python = ".venv" typeshed = "typings/typeshed" extra-search-paths = ["packages/one", "packages/two"] @@ -173,6 +175,7 @@ mod tests { Some(vec!["example.BaseError".into()]) ); assert_eq!(config.extensions, Some(vec![AnalysisExtension::Fastapi])); + assert_eq!(config.entrypoints, Some(vec![Entrypoint::FastapiRoute])); let effects = config.context_manager_effects.unwrap(); assert_eq!(effects.len(), 2); assert_eq!(effects[0].function, "example.suppress_error"); diff --git a/src/main.rs b/src/main.rs index 0a2d6fb..1b2ae75 100644 --- a/src/main.rs +++ b/src/main.rs @@ -79,6 +79,11 @@ fn check(check: CheckCommand, cwd: SystemPathBuf) -> Result { } else { check.extensions.clone() }; + let entrypoints = if check.entrypoints.is_empty() { + project_config.entrypoints.clone().unwrap_or_default() + } else { + check.entrypoints.clone() + }; set_colored_override(color); let verbosity = check.verbosity.level(); @@ -97,6 +102,17 @@ fn check(check: CheckCommand, cwd: SystemPathBuf) -> Result { db.project().set_included_paths(&mut db, check_paths); } + let analysis_options = AnalysisOptions::default() + .with_extensions(extensions) + .with_entrypoints(entrypoints) + .with_context_manager_effects( + project_config + .context_manager_effects + .clone() + .unwrap_or_default(), + ); + analysis_options.validate()?; + static PB: LazyLock = LazyLock::new(|| ProgressBar::new(100)); PB.set_style(ProgressStyle::with_template( "{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} \n({msg})", @@ -108,14 +124,6 @@ fn check(check: CheckCommand, cwd: SystemPathBuf) -> Result { .map(|path| resolve_absolute_module_path(&db, &path)) .collect(); - let analysis_options = AnalysisOptions::default() - .with_extensions(extensions) - .with_context_manager_effects( - project_config - .context_manager_effects - .clone() - .unwrap_or_default(), - ); let events = analyze_project_with_options(db.clone(), target_exceptions, Some(&PB), analysis_options)?; let mut diagnostics = Vec::new(); diff --git a/tests/config_test.rs b/tests/config_test.rs index 89b0593..1f9b0f5 100644 --- a/tests/config_test.rs +++ b/tests/config_test.rs @@ -49,3 +49,13 @@ fn command_line_excludes_replace_configured_excludes() { assert!(stdout.contains("excluded.py:5:5: error[raise]")); assert!(stdout.contains("Found 2 diagnostics")); } + +#[test] +fn command_line_entrypoint_restricts_diagnostics_to_fastapi_routes() { + let output = run_check(&["--entrypoint", "fastapi:route"]); + let stdout = String::from_utf8(output.stdout).unwrap(); + + assert!(output.status.success()); + assert!(stdout.contains("All checks passed!")); + assert!(!stdout.contains("sample.py:25:5")); +} From 0bcd9c1e0eb0cebec427b9ea8f8d9c5e74471da2 Mon Sep 17 00:00:00 2001 From: Albin Skott Date: Mon, 21 Sep 2026 10:27:21 +0200 Subject: [PATCH 3/5] Add GitHub Actions CI and binary release workflows --- .github/workflows/ci.yml | 63 ++++++++++++ .github/workflows/release.yml | 174 ++++++++++++++++++++++++++++++++++ README.md | 59 +++++++++++- 3 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b56a855 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref_type == 'branch' }} + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: '0' + # Nightly is required by #![feature(extend_one)]. Keep in sync with release.yml. + RUSTUP_TOOLCHAIN: nightly-2026-04-11 + +jobs: + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + defaults: + run: + shell: bash + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-15, windows-2022] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust + run: rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal --component rustfmt,clippy --no-self-update + + - name: Cache Cargo dependencies and build artifacts + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ci-${{ runner.os }}-${{ runner.arch }}-${{ env.RUSTUP_TOOLCHAIN }}-${{ hashFiles('Cargo.lock', 'Cargo.toml') }} + restore-keys: | + ci-${{ runner.os }}-${{ runner.arch }}-${{ env.RUSTUP_TOOLCHAIN }}- + + - name: Check formatting + if: runner.os == 'Linux' + run: cargo fmt --all -- --check + + - name: Lint + if: runner.os == 'Linux' + run: cargo clippy --locked --all-targets --all-features -- -D warnings + + - name: Run tests + run: cargo test --locked --all-features diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5e905de --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,174 @@ +name: Release + +on: + push: + tags: ['v*'] + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: '0' + # Keep in sync with ci.yml. + RUSTUP_TOOLCHAIN: nightly-2026-04-11 + +jobs: + validate: + name: Validate release version + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + prerelease: ${{ steps.version.outputs.prerelease }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Check tag against Cargo.toml + id: version + env: + TAG_NAME: ${{ github.ref_name }} + run: | + python3 - <<'PY' + import os + import tomllib + + with open("Cargo.toml", "rb") as manifest: + version = tomllib.load(manifest)["package"]["version"] + tag = os.environ["TAG_NAME"] + if tag != f"v{version}": + raise SystemExit(f"Release tag {tag!r} must match Cargo.toml version v{version}") + prerelease = "-" in version.split("+", 1)[0] + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + print(f"prerelease={str(prerelease).lower()}", file=output) + PY + + ci: + name: Release checks + needs: validate + uses: ./.github/workflows/ci.yml + + build: + name: Build (${{ matrix.target }}) + needs: [validate, ci] + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + exe_suffix: '' + - os: ubuntu-22.04-arm + target: aarch64-unknown-linux-gnu + exe_suffix: '' + - os: macos-15-intel + target: x86_64-apple-darwin + exe_suffix: '' + - os: macos-15 + target: aarch64-apple-darwin + exe_suffix: '' + - os: windows-2022 + target: x86_64-pc-windows-msvc + exe_suffix: .exe + defaults: + run: + shell: bash + env: + TARGET: ${{ matrix.target }} + EXE_SUFFIX: ${{ matrix.exe_suffix }} + TAG_NAME: ${{ github.ref_name }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install Rust + run: rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal --target "$TARGET" --no-self-update + + - name: Cache Cargo dependencies and build artifacts + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: release-${{ matrix.os }}-${{ matrix.target }}-${{ env.RUSTUP_TOOLCHAIN }}-${{ hashFiles('Cargo.lock', 'Cargo.toml') }} + restore-keys: | + release-${{ matrix.os }}-${{ matrix.target }}-${{ env.RUSTUP_TOOLCHAIN }}- + + - name: Build release binary + run: cargo build --locked --release --bin py-checked-exceptions --target "$TARGET" + + - name: Smoke test release binary + run: '"target/$TARGET/release/py-checked-exceptions$EXE_SUFFIX" --help' + + - name: Stage archive contents + run: | + archive="py-checked-exceptions-$TAG_NAME-$TARGET" + mkdir -p "dist/$archive" + cp "target/$TARGET/release/py-checked-exceptions$EXE_SUFFIX" "dist/$archive/" + cp README.md LICENSE "dist/$archive/" + + - name: Package Unix binary + if: runner.os != 'Windows' + run: | + archive="py-checked-exceptions-$TAG_NAME-$TARGET" + tar -czf "dist/$archive.tar.gz" -C dist "$archive" + + - name: Package Windows binary + if: runner.os == 'Windows' + shell: pwsh + run: | + $archive = "py-checked-exceptions-$env:TAG_NAME-$env:TARGET" + Compress-Archive -Path "dist/$archive" -DestinationPath "dist/$archive.zip" + + - name: Upload release archive + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: binary-${{ matrix.target }} + path: | + dist/*.tar.gz + dist/*.zip + if-no-files-found: error + compression-level: 0 + retention-days: 7 + + publish: + name: Publish GitHub release + needs: [validate, build] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Download release archives + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: binary-* + path: dist + merge-multiple: true + + - name: Generate checksums + working-directory: dist + run: sha256sum -- *.tar.gz *.zip > SHA256SUMS + + - name: Create release with binaries + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG_NAME: ${{ github.ref_name }} + PRERELEASE: ${{ needs.validate.outputs.prerelease }} + run: | + flags=() + if [[ "$PRERELEASE" == 'true' ]]; then + flags+=(--prerelease) + fi + gh release create "$TAG_NAME" dist/* \ + --verify-tag --generate-notes --title "$TAG_NAME" "${flags[@]}" diff --git a/README.md b/README.md index ce4cfe3..a2ed721 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,25 @@ A static analyzer that enforces exception documentation in Python code. It verif ## Installation +Download the archive for your platform from [GitHub Releases](https://github.com/cstruct/py-checked-exceptions/releases), +extract it, and put `py-checked-exceptions` (or `py-checked-exceptions.exe`) on your `PATH`. + +| Platform | Archive target | +| --- | --- | +| Linux x64 | `x86_64-unknown-linux-gnu` | +| Linux ARM64 | `aarch64-unknown-linux-gnu` | +| macOS Intel | `x86_64-apple-darwin` | +| macOS Apple Silicon | `aarch64-apple-darwin` | +| Windows x64 | `x86_64-pc-windows-msvc` | + +Linux binaries are built on Ubuntu 22.04 and require glibc 2.35 or newer. +Each release includes `SHA256SUMS` to verify the downloaded archives. + +To build from source, use nightly Rust: + ```bash -cargo install --git https://github.com/cstruct/py-checked-exceptions.git +rustup toolchain install nightly-2026-04-11 --profile minimal +cargo +nightly-2026-04-11 install --locked --git https://github.com/cstruct/py-checked-exceptions.git ``` ## Usage @@ -163,6 +180,24 @@ Enable FastAPI-specific analysis with `--extension fastapi`. The extension: FastAPI behavior is disabled unless the extension is explicitly enabled. +To emit exception-documentation diagnostics only for FastAPI route handlers, select the +extension-provided entrypoint: + +```bash +py-checked-exceptions check --extension fastapi --entrypoint fastapi:route +``` + +The same setting can be configured in `pyproject.toml`: + +```toml +[tool.py-checked-exceptions] +extensions = ["fastapi"] +entrypoints = ["fastapi:route"] +``` + +Entrypoints restrict which functions start a check. Calls made by a selected route are still +followed transitively. + ## Known Limitations Use `--show-analysis-gaps` to print a summary of code the analyzer could not fully model, or @@ -192,6 +227,28 @@ hk install This ensures all commits pass linting and tests. +GitHub Actions runs tests on Linux, macOS, and Windows for pull requests and pushes to `main`. +The Linux job also checks formatting and runs Clippy with warnings treated as errors. +CI and release builds use `nightly-2026-04-11`, since the project requires nightly Rust. +Keep the toolchain versions in both workflows in sync when updating them. + +## Releasing + +1. Update the package version in `Cargo.toml` and regenerate `Cargo.lock` with `cargo check`. +2. Commit and push the version change and wait for CI to pass. +3. Create and push a tag matching the package version, for example: + + ```bash + git tag v0.1.0 + git push origin v0.1.0 + ``` + +The release workflow verifies the tag, reruns CI, and builds and smoke-tests binaries for all +platforms listed above. Once every job succeeds, it publishes a GitHub release with generated +release notes, `.tar.gz` archives for Linux/macOS, a `.zip` archive for Windows, and `SHA256SUMS`. +Each archive contains the executable, README, and license. Versions such as `0.2.0-rc.1` are +published as prereleases. The workflow uses the built-in `GITHUB_TOKEN`; no extra secret is needed. + ## LLM disclosure Large language models (LLMs) are used in the development of commits after the commit titled From d49bf6e41268e33ab61c77477ac87db2cf26311f Mon Sep 17 00:00:00 2001 From: Albin Skott Date: Mon, 21 Sep 2026 10:33:20 +0200 Subject: [PATCH 4/5] Enable long Git dependency paths on Windows runners --- .github/workflows/ci.yml | 4 ++++ .github/workflows/release.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b56a855..738b04a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,10 @@ jobs: with: persist-credentials: false + - name: Enable long paths for Git dependencies + if: runner.os == 'Windows' + run: git config --global core.longpaths true + - name: Install Rust run: rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal --component rustfmt,clippy --no-self-update diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e905de..7945dde 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -89,6 +89,10 @@ jobs: with: persist-credentials: false + - name: Enable long paths for Git dependencies + if: runner.os == 'Windows' + run: git config --global core.longpaths true + - name: Install Rust run: rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal --target "$TARGET" --no-self-update From e006f071743ae438646798d057ae7b8179549c3b Mon Sep 17 00:00:00 2001 From: Albin Skott Date: Mon, 21 Sep 2026 10:43:02 +0200 Subject: [PATCH 5/5] Skip Windows CI and releases pending integration test fixes --- .github/workflows/ci.yml | 6 +----- .github/workflows/release.yml | 32 +++++--------------------------- README.md | 9 +++++---- 3 files changed, 11 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 738b04a..c8ef1f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,16 +31,12 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-22.04, macos-15, windows-2022] + os: [ubuntu-22.04, macos-15] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Enable long paths for Git dependencies - if: runner.os == 'Windows' - run: git config --global core.longpaths true - - name: Install Rust run: rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal --component rustfmt,clippy --no-self-update diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7945dde..370e4ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,35 +64,23 @@ jobs: include: - os: ubuntu-22.04 target: x86_64-unknown-linux-gnu - exe_suffix: '' - os: ubuntu-22.04-arm target: aarch64-unknown-linux-gnu - exe_suffix: '' - os: macos-15-intel target: x86_64-apple-darwin - exe_suffix: '' - os: macos-15 target: aarch64-apple-darwin - exe_suffix: '' - - os: windows-2022 - target: x86_64-pc-windows-msvc - exe_suffix: .exe defaults: run: shell: bash env: TARGET: ${{ matrix.target }} - EXE_SUFFIX: ${{ matrix.exe_suffix }} TAG_NAME: ${{ github.ref_name }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Enable long paths for Git dependencies - if: runner.os == 'Windows' - run: git config --global core.longpaths true - - name: Install Rust run: rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal --target "$TARGET" --no-self-update @@ -111,35 +99,25 @@ jobs: run: cargo build --locked --release --bin py-checked-exceptions --target "$TARGET" - name: Smoke test release binary - run: '"target/$TARGET/release/py-checked-exceptions$EXE_SUFFIX" --help' + run: '"target/$TARGET/release/py-checked-exceptions" --help' - name: Stage archive contents run: | archive="py-checked-exceptions-$TAG_NAME-$TARGET" mkdir -p "dist/$archive" - cp "target/$TARGET/release/py-checked-exceptions$EXE_SUFFIX" "dist/$archive/" + cp "target/$TARGET/release/py-checked-exceptions" "dist/$archive/" cp README.md LICENSE "dist/$archive/" - - name: Package Unix binary - if: runner.os != 'Windows' + - name: Package binary run: | archive="py-checked-exceptions-$TAG_NAME-$TARGET" tar -czf "dist/$archive.tar.gz" -C dist "$archive" - - name: Package Windows binary - if: runner.os == 'Windows' - shell: pwsh - run: | - $archive = "py-checked-exceptions-$env:TAG_NAME-$env:TARGET" - Compress-Archive -Path "dist/$archive" -DestinationPath "dist/$archive.zip" - - name: Upload release archive uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: binary-${{ matrix.target }} - path: | - dist/*.tar.gz - dist/*.zip + path: dist/*.tar.gz if-no-files-found: error compression-level: 0 retention-days: 7 @@ -161,7 +139,7 @@ jobs: - name: Generate checksums working-directory: dist - run: sha256sum -- *.tar.gz *.zip > SHA256SUMS + run: sha256sum -- *.tar.gz > SHA256SUMS - name: Create release with binaries env: diff --git a/README.md b/README.md index a2ed721..43b1e83 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A static analyzer that enforces exception documentation in Python code. It verif ## Installation Download the archive for your platform from [GitHub Releases](https://github.com/cstruct/py-checked-exceptions/releases), -extract it, and put `py-checked-exceptions` (or `py-checked-exceptions.exe`) on your `PATH`. +extract it, and put `py-checked-exceptions` on your `PATH`. | Platform | Archive target | | --- | --- | @@ -15,7 +15,6 @@ extract it, and put `py-checked-exceptions` (or `py-checked-exceptions.exe`) on | Linux ARM64 | `aarch64-unknown-linux-gnu` | | macOS Intel | `x86_64-apple-darwin` | | macOS Apple Silicon | `aarch64-apple-darwin` | -| Windows x64 | `x86_64-pc-windows-msvc` | Linux binaries are built on Ubuntu 22.04 and require glibc 2.35 or newer. Each release includes `SHA256SUMS` to verify the downloaded archives. @@ -227,8 +226,10 @@ hk install This ensures all commits pass linting and tests. -GitHub Actions runs tests on Linux, macOS, and Windows for pull requests and pushes to `main`. +GitHub Actions runs tests on Linux and macOS for pull requests and pushes to `main`. The Linux job also checks formatting and runs Clippy with warnings treated as errors. +Windows CI and release binaries are currently disabled because several integration tests fail +on that platform. CI and release builds use `nightly-2026-04-11`, since the project requires nightly Rust. Keep the toolchain versions in both workflows in sync when updating them. @@ -245,7 +246,7 @@ Keep the toolchain versions in both workflows in sync when updating them. The release workflow verifies the tag, reruns CI, and builds and smoke-tests binaries for all platforms listed above. Once every job succeeds, it publishes a GitHub release with generated -release notes, `.tar.gz` archives for Linux/macOS, a `.zip` archive for Windows, and `SHA256SUMS`. +release notes, `.tar.gz` archives for Linux/macOS, and `SHA256SUMS`. Each archive contains the executable, README, and license. Versions such as `0.2.0-rc.1` are published as prereleases. The workflow uses the built-in `GITHUB_TOKEN`; no extra secret is needed.