546 lines
18 KiB
YAML
546 lines
18 KiB
YAML
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
|