name: CI on: push: branches: ["main"] pull_request: branches: ["main"] release: types: [published] permissions: contents: read pages: write id-token: write jobs: # ------------------------------------------------------------------------- # Dependency audits — cargo audit (Rust) and pip-audit (Python) # ------------------------------------------------------------------------- audit: name: Dependency audit (cargo + pip) runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install cargo-audit and run cargo audit run: | cargo install cargo-audit cargo audit - name: Install cargo-deny and run cargo deny check run: | cargo install cargo-deny --locked cargo deny check - name: Set up Python 3.12 uses: actions/setup-python@v6 with: python-version: "3.12" - name: Install uv run: pip install uv - name: Install pip-audit via uv and run pip-audit run: | uv run --with pip-audit pip-audit - name: Verify uv.lock is up-to-date run: uv lock --check # ------------------------------------------------------------------------- # Version consistency — Cargo.toml and pyproject.toml must have same version # ------------------------------------------------------------------------- changelog-check: name: CHANGELOG has [Unreleased] entry runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - uses: actions/checkout@v6 - name: Check CHANGELOG.md run: python3 scripts/check_changelog.py version-check: name: Version consistency (Cargo.toml == pyproject.toml) runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Check version parity run: | CARGO_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/') PYPROJECT_VERSION=$(grep '^version' pyproject.toml | head -1 | sed 's/version = "\(.*\)"/\1/') echo "Cargo.toml version: $CARGO_VERSION" echo "pyproject.toml version: $PYPROJECT_VERSION" if [ "$CARGO_VERSION" != "$PYPROJECT_VERSION" ]; then echo "ERROR: Version mismatch! Update both files before releasing." exit 1 fi echo "OK: versions match ($CARGO_VERSION)" rust: name: Rust (fmt + clippy) runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@v1 with: toolchain: stable components: rustfmt, clippy - run: cargo fmt --all -- --check - run: cargo clippy --release -- -D warnings - name: Verify benchmarks compile (ferro_ta_core) run: cargo bench -p ferro_ta_core --no-run # ------------------------------------------------------------------------- # Rust coverage — optional, runs in a separate non-blocking job # ------------------------------------------------------------------------- rust-coverage: name: Rust coverage (tarpaulin, optional) runs-on: ubuntu-latest continue-on-error: true steps: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@v1 with: toolchain: stable - name: Install cargo-tarpaulin run: cargo install cargo-tarpaulin --locked - name: Collect Rust coverage (ferro_ta_core) run: cargo tarpaulin -p ferro_ta_core --out Xml --output-dir coverage/ - name: Upload Rust coverage artifact uses: actions/upload-artifact@v7 with: name: rust-coverage path: coverage/ if-no-files-found: ignore lint: name: Lint (ruff) runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Set up Python 3.12 uses: actions/setup-python@v6 with: python-version: "3.12" - name: Install uv run: pip install uv - name: Run ruff check via uv run: uv run --with ruff ruff check python/ tests/ - name: Run ruff format check via uv run: uv run --with ruff ruff format --check python/ tests/ typecheck: name: Type checking (mypy + pyright) runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Set up Python 3.12 uses: actions/setup-python@v6 with: python-version: "3.12" - name: Install uv run: pip install uv - name: Run mypy on ferro_ta via uv run: uv run --with mypy --with numpy mypy python/ferro_ta --ignore-missing-imports --no-error-summary - name: Run pyright on ferro_ta via uv run: uv run --with pyright pyright python/ferro_ta test: name: Test (ubuntu-latest / Python ${{ matrix.python-version }}) runs-on: ubuntu-latest strategy: # Python 3.10–3.13 supported (requires-python in pyproject.toml) fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install maturin and test dependencies run: | pip install maturin numpy pytest pytest-cov pandas polars hypothesis pyyaml - name: Build and install ferro_ta (dev mode) run: | maturin build --release --out dist pip install dist/*.whl - name: Run unit tests with coverage run: pytest tests/unit/ tests/integration/ -v --cov=ferro_ta --cov-report=xml --cov-report=term-missing --cov-fail-under=65 - name: Upload coverage report uses: actions/upload-artifact@v7 if: matrix.python-version == '3.12' with: name: coverage-python-${{ matrix.python-version }} path: coverage.xml # ------------------------------------------------------------------------- # WASM binding — build and test with wasm-pack # ------------------------------------------------------------------------- wasm: name: WASM binding (wasm-pack test --node) runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install Rust (stable) uses: dtolnay/rust-toolchain@v1 with: toolchain: stable targets: wasm32-unknown-unknown - name: Install wasm-pack run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh - name: Build and test WASM binding working-directory: wasm run: wasm-pack test --node - name: Build WASM package (nodejs target) working-directory: wasm run: wasm-pack build --target nodejs --out-dir pkg - name: Upload WASM package artifact uses: actions/upload-artifact@v7 with: name: wasm-pkg path: wasm/pkg/ # ------------------------------------------------------------------------- # ferro_ta vs TA-Lib speed comparison — required for PRs. # Performance regressions block merging. # ------------------------------------------------------------------------- benchmark-vs-talib: name: Benchmark vs TA-Lib runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Set up Python 3.12 uses: actions/setup-python@v6 with: python-version: "3.12" - name: Install TA-Lib C library (Ubuntu) run: | sudo apt-get update sudo apt-get install -y build-essential curl curl -sL https://sourceforge.net/projects/ta-lib/files/ta-lib/0.4.0/ta-lib-0.4.0-src.tar.gz/download -o ta-lib-0.4.0-src.tar.gz tar -xzf ta-lib-0.4.0-src.tar.gz cd ta-lib ./configure --prefix=/usr make sudo make install sudo ldconfig - name: Install maturin and ta-lib Python package run: | pip install maturin numpy pip install ta-lib - name: Build and install ferro_ta run: | maturin build --release --out dist pip install dist/*.whl - name: Run benchmark comparison run: | python benchmarks/bench_vs_talib.py --sizes 10000 100000 --json benchmark_vs_talib.json - name: Enforce benchmark regression policy run: | python3 benchmarks/check_vs_talib_regression.py --input benchmark_vs_talib.json - name: Upload benchmark results uses: actions/upload-artifact@v7 with: name: benchmark-vs-talib path: benchmark_vs_talib.json # ------------------------------------------------------------------------- # Pure Rust core library — build and test without PyO3 / numpy # ------------------------------------------------------------------------- rust-core: name: Rust core library (ferro_ta_core) runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@v1 with: toolchain: stable - name: Build core crate run: cargo build -p ferro_ta_core - name: Test core crate run: cargo test -p ferro_ta_core # ------------------------------------------------------------------------- # Fuzzing (short run in CI to catch panics) — optional, non-blocking # This job is explicitly marked continue-on-error because cargo-fuzz # requires nightly Rust and may not be available in all environments. # Failures here are visible in the job log but do not block the PR. # ------------------------------------------------------------------------- fuzz: name: Fuzz targets (short CI run, optional) runs-on: ubuntu-latest continue-on-error: true steps: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@v1 with: toolchain: nightly - name: Install cargo-fuzz run: cargo install cargo-fuzz --locked - name: Run fuzz_sma (10000 iterations) working-directory: fuzz run: cargo fuzz run fuzz_sma -- -runs=10000 -max_len=512 - name: Run fuzz_rsi (10000 iterations) working-directory: fuzz run: cargo fuzz run fuzz_rsi -- -runs=10000 -max_len=512 - name: Upload fuzz artifacts on crash uses: actions/upload-artifact@v7 if: always() with: name: fuzz-artifacts path: fuzz/artifacts/ if-no-files-found: ignore # ------------------------------------------------------------------------- # Sphinx documentation build # ------------------------------------------------------------------------- docs: name: Documentation (Sphinx build) runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Set up Python 3.12 uses: actions/setup-python@v6 with: python-version: "3.12" - name: Install maturin and docs dependencies run: | pip install maturin numpy pip install sphinx sphinx-rtd-theme - name: Build and install ferro_ta wheel run: | maturin build --release --out dist pip install dist/*.whl - name: Build Sphinx documentation run: sphinx-build -b html docs docs/_build -W --keep-going - name: Upload docs artifact uses: actions/upload-artifact@v7 with: name: sphinx-docs path: docs/_build/ - name: Upload GitHub Pages artifact if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/upload-pages-artifact@v4 with: path: docs/_build/ # ------------------------------------------------------------------------- # Deploy docs to GitHub Pages (on push to main only) # ------------------------------------------------------------------------- deploy-docs: name: Deploy docs to GitHub Pages runs-on: ubuntu-latest needs: docs if: github.event_name == 'push' && github.ref == 'refs/heads/main' environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} permissions: pages: write id-token: write steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 # ------------------------------------------------------------------------- # CI gate — all required jobs must pass before this job succeeds. # Set "ci-complete" as a required status check in branch protection rules # to block merging of PRs that fail any required job. # ------------------------------------------------------------------------- ci-complete: name: CI complete (required gate) runs-on: ubuntu-latest needs: - audit - version-check - rust - rust-core - lint - typecheck - test - wasm - benchmark-vs-talib - docs if: always() steps: - name: Check all required jobs passed run: | results='${{ toJSON(needs) }}' echo "Job results: $results" failed=$(echo "$results" | python3 -c " import json, sys needs = json.load(sys.stdin) failed = [name for name, data in needs.items() if data['result'] not in ('success', 'skipped')] if failed: print(' '.join(failed)) ") if [ -n \"\$failed\" ]; then echo \"FAILED jobs: \$failed\" exit 1 fi echo \"All required CI jobs passed.\" # ------------------------------------------------------------------------- # Build wheels for all platforms and publish to PyPI on release # ------------------------------------------------------------------------- build-wheels: name: Build wheels (${{ matrix.os }}) runs-on: ${{ matrix.os }} if: github.event_name == 'release' && github.event.action == 'published' strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@v6 - name: Build wheels uses: PyO3/maturin-action@v1 with: command: build args: --release --out dist manylinux: auto # Build for both Intel and Apple Silicon on macOS target: ${{ matrix.os == 'macos-latest' && 'universal2-apple-darwin' || '' }} - name: Upload wheels as artifact uses: actions/upload-artifact@v7 with: name: wheels-${{ matrix.os }} path: dist # ------------------------------------------------------------------------- # Publish to PyPI # ------------------------------------------------------------------------- publish: name: Publish to PyPI runs-on: ubuntu-latest needs: build-wheels if: github.event_name == 'release' && github.event.action == 'published' permissions: id-token: write steps: - name: Download all wheels uses: actions/download-artifact@v8 with: pattern: wheels-* merge-multiple: true path: dist - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: # Use token if set; otherwise the action uses OIDC trusted publishing username: __token__ password: ${{ secrets.PYPI_API_TOKEN }} # ------------------------------------------------------------------------- # Publish ferro_ta_core to crates.io (requires CARGO_REGISTRY_TOKEN secret) # ------------------------------------------------------------------------- publish-cratesio: name: Publish to crates.io runs-on: ubuntu-latest if: github.event_name == 'release' && github.event.action == 'published' steps: - uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@v1 with: toolchain: stable - name: Publish ferro_ta_core to crates.io run: cargo publish -p ferro_ta_core --token ${{ secrets.CARGO_REGISTRY_TOKEN }} # ------------------------------------------------------------------------- # SBOM generation — Software Bill of Materials for supply-chain transparency # Generates SBOMs for both Python (syft/CycloneDX) and Rust (cargo-cyclonedx) # and uploads them as GitHub Release assets. # ------------------------------------------------------------------------- sbom: name: Generate SBOM (Python + Rust) runs-on: ubuntu-latest needs: build-wheels if: github.event_name == 'release' && github.event.action == 'published' permissions: contents: write id-token: write steps: - uses: actions/checkout@v6 - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install maturin run: pip install maturin numpy - name: Build and install ferro_ta wheel run: | maturin build --release --out dist pip install dist/*.whl - name: Generate Python SBOM (CycloneDX via anchore/sbom-action) uses: anchore/sbom-action@v0.17 with: artifact-name: ferro-ta-python-sbom.spdx.json output-file: ferro-ta-python-sbom.spdx.json format: spdx-json - name: Install Rust stable uses: dtolnay/rust-toolchain@v1 with: toolchain: stable - name: Install cargo-cyclonedx run: cargo install cargo-cyclonedx --locked - name: Generate Rust SBOM (CycloneDX) run: cargo cyclonedx --format json --output-cdx ferro-ta-rust-sbom.cdx.json - name: Upload Python SBOM to release uses: softprops/action-gh-release@v2 with: files: ferro-ta-python-sbom.spdx.json - name: Upload Rust SBOM to release uses: softprops/action-gh-release@v2 with: files: ferro-ta-rust-sbom.cdx.json