Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ jobs:
- name: Setup uv
uses: astral-sh/setup-uv@v3

- name: Setup Rust
uses: dtolnay/rust-toolchain@stable

- name: Cache cargo build
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
s2and_rust/target
key: cargo-${{ runner.os }}-${{ hashFiles('s2and_rust/Cargo.lock') }}
restore-keys: |
cargo-${{ runner.os }}-

# Optional: ensure a specific Python (uv can also manage this on its own)
- name: Setup Python
uses: actions/setup-python@v5
Expand All @@ -70,24 +84,28 @@ jobs:
shell: bash
run: |
if [[ -f uv.lock ]]; then
uv sync --all-extras --dev --frozen
uv sync --extra dev --frozen
else
# No lock present; resolve once, then install
uv sync --all-extras --dev
uv sync --extra dev
fi

# Build/install Rust extension for parity tests
- name: Build Rust extension
run: uvx --from maturin maturin develop -m s2and_rust/Cargo.toml

# Type checking (run mypy commands directly)
- name: mypy (s2and)
run: uv run mypy s2and --ignore-missing-imports
run: uv run --no-project mypy s2and --ignore-missing-imports
- name: mypy (scripts)
run: uv run mypy scripts/*.py --ignore-missing-imports
run: uv run --no-project mypy scripts/*.py --ignore-missing-imports

# Single pytest run with coverage (replaces the two docker pytest calls)
- name: pytest (coverage)
env:
# keep startup lean; avoid user-level plugins on hosted runners
PYTHONPATH: .
run: |
uv run pytest tests/ \
uv run --no-project pytest tests/ \
--cov=s2and --cov-report=term-missing --cov-fail-under=40

336 changes: 336 additions & 0 deletions .github/workflows/release-rust.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,336 @@
name: build-and-publish

on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
push:
branches: [main]
workflow_dispatch:
inputs:
force_build:
description: "Force build jobs (manual/PR testing)"
required: false
type: boolean
publish_s2and:
description: "Publish s2and to PyPI (manual run)"
required: false
type: boolean
publish_rust:
description: "Publish s2and-rust to PyPI (manual run)"
required: false
type: boolean

jobs:
detect-versions:
name: detect version changes
runs-on: ubuntu-latest
outputs:
s2and_changed: ${{ steps.detect.outputs.s2and_changed }}
rust_changed: ${{ steps.detect.outputs.rust_changed }}
publish_any: ${{ steps.detect.outputs.publish_any }}
publish_s2and: ${{ steps.detect.outputs.publish_s2and }}
publish_rust: ${{ steps.detect.outputs.publish_rust }}
force_build: ${{ steps.detect.outputs.force_build }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Detect version changes
id: detect
env:
BEFORE_SHA: ${{ github.event.before }}
run: |
python - <<'PY'
import json
import os
import subprocess
import sys
import tomllib

event_name = os.environ.get("GITHUB_EVENT_NAME") or ""
event_path = os.environ.get("GITHUB_EVENT_PATH") or ""
event = None
if event_path and os.path.exists(event_path):
with open(event_path, "r", encoding="utf-8") as f:
event = json.load(f)

force_build = False
publish_s2and = False
publish_rust = False
if event_name == "workflow_dispatch":
inputs = (event or {}).get("inputs", {})
force_build = str(inputs.get("force_build", "")).lower() in {"1", "true", "yes", "on"}
publish_s2and = str(inputs.get("publish_s2and", "")).lower() in {"1", "true", "yes", "on"}
publish_rust = str(inputs.get("publish_rust", "")).lower() in {"1", "true", "yes", "on"}
elif event_name == "pull_request":
labels = (event or {}).get("pull_request", {}).get("labels", [])
force_build = any((label or {}).get("name", "").lower() == "force-build" for label in labels)

before = os.environ.get("BEFORE_SHA") or ""
if event_name == "pull_request" and event:
before = (event.get("pull_request", {}).get("base", {}).get("sha", "")) or ""
if before.startswith("0000000"):
before = ""

def read_toml_at(path, rev=None):
if rev:
try:
data = subprocess.check_output(["git", "show", f"{rev}:{path}"], text=True)
except subprocess.CalledProcessError:
return None
return tomllib.loads(data)
with open(path, "rb") as f:
return tomllib.load(f)

def get_project_version(toml_obj, path):
if toml_obj is None:
return None
try:
return toml_obj["project"]["version"]
except KeyError:
raise SystemExit(f"Missing [project].version in {path}")

def get_cargo_version(toml_obj, path):
if toml_obj is None:
return None
try:
return toml_obj["package"]["version"]
except KeyError:
raise SystemExit(f"Missing [package].version in {path}")

cur_py = read_toml_at("pyproject.toml")
cur_rust_py = read_toml_at("s2and_rust/pyproject.toml")
cur_rust_cargo = read_toml_at("s2and_rust/Cargo.toml")

cur_py_ver = get_project_version(cur_py, "pyproject.toml")
cur_rust_py_ver = get_project_version(cur_rust_py, "s2and_rust/pyproject.toml")
cur_rust_cargo_ver = get_cargo_version(cur_rust_cargo, "s2and_rust/Cargo.toml")

if cur_rust_py_ver != cur_rust_cargo_ver:
raise SystemExit(
f"Rust version mismatch: pyproject={cur_rust_py_ver} cargo={cur_rust_cargo_ver}"
)

before_py_ver = get_project_version(read_toml_at("pyproject.toml", before), "pyproject.toml") if before else None
before_rust_py_ver = (
get_project_version(read_toml_at("s2and_rust/pyproject.toml", before), "s2and_rust/pyproject.toml")
if before
else None
)

s2and_changed = before_py_ver != cur_py_ver
rust_changed = before_rust_py_ver != cur_rust_py_ver
publish_any = s2and_changed or rust_changed or publish_s2and or publish_rust

out_path = os.environ["GITHUB_OUTPUT"]
with open(out_path, "a", encoding="utf-8") as f:
f.write(f"s2and_changed={'true' if s2and_changed else 'false'}\n")
f.write(f"rust_changed={'true' if rust_changed else 'false'}\n")
f.write(f"publish_any={'true' if publish_any else 'false'}\n")
f.write(f"publish_s2and={'true' if publish_s2and else 'false'}\n")
f.write(f"publish_rust={'true' if publish_rust else 'false'}\n")
f.write(f"force_build={'true' if force_build else 'false'}\n")

print(f"s2and: {before_py_ver} -> {cur_py_ver} changed={s2and_changed}")
print(f"rust: {before_rust_py_ver} -> {cur_rust_py_ver} changed={rust_changed}")
print(f"force_build: {force_build}")
print(f"publish_s2and: {publish_s2and}")
print(f"publish_rust: {publish_rust}")
PY

s2and-dist:
name: s2and sdist+wheel
runs-on: ubuntu-latest
needs: [detect-versions]
if: needs.detect-versions.outputs.s2and_changed == 'true' || needs.detect-versions.outputs.force_build == 'true' || needs.detect-versions.outputs.publish_s2and == 'true'
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Build s2and distributions
run: |
python -m pip install --upgrade pip
python -m pip install build
python -m build --sdist --wheel --outdir dist

- uses: actions/upload-artifact@v4
with:
name: dist-s2and
path: dist/*

wheels-windows:
name: wheels (windows, py${{ matrix.py }})
runs-on: windows-latest
needs: [detect-versions]
if: needs.detect-versions.outputs.rust_changed == 'true' || needs.detect-versions.outputs.force_build == 'true' || needs.detect-versions.outputs.publish_rust == 'true'
strategy:
fail-fast: false
matrix:
py: ["3.10", "3.11", "3.12"]

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.py }}

- name: Build wheels
uses: PyO3/maturin-action@86b9d133d34bc1b40018696f782949dac11bd380
with:
maturin-version: v1.11.5
command: build
args: --release --locked --compatibility pypi --out dist
working-directory: s2and_rust

- uses: actions/upload-artifact@v4
with:
name: dist-s2and-rust-windows-py${{ matrix.py }}
path: s2and_rust/dist/*

wheels-macos:
name: wheels (macos universal2, py${{ matrix.py }})
runs-on: macos-latest
needs: [detect-versions]
if: needs.detect-versions.outputs.rust_changed == 'true' || needs.detect-versions.outputs.force_build == 'true' || needs.detect-versions.outputs.publish_rust == 'true'
strategy:
fail-fast: false
matrix:
py: ["3.10", "3.11", "3.12"]

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.py }}

- name: Install Rust targets (universal2)
run: rustup target add x86_64-apple-darwin aarch64-apple-darwin

- name: Build wheels (universal2)
uses: PyO3/maturin-action@86b9d133d34bc1b40018696f782949dac11bd380
env:
ARCHFLAGS: "-arch x86_64 -arch arm64"
with:
maturin-version: v1.11.5
command: build
args: --release --locked --compatibility pypi --out dist
working-directory: s2and_rust

- uses: actions/upload-artifact@v4
with:
name: dist-s2and-rust-macos-universal2-py${{ matrix.py }}
path: s2and_rust/dist/*

wheels-linux:
name: wheels (linux ${{ matrix.platform.name }})
runs-on: ubuntu-latest
needs: [detect-versions]
if: needs.detect-versions.outputs.rust_changed == 'true' || needs.detect-versions.outputs.force_build == 'true' || needs.detect-versions.outputs.publish_rust == 'true'
strategy:
fail-fast: false
matrix:
platform:
- name: manylinux2014-x86_64
target: x86_64-unknown-linux-gnu
manylinux: "2_17"
# Optional: enable when you want aarch64 wheels.
- name: manylinux2014-aarch64
target: aarch64-unknown-linux-gnu
manylinux: "2_17"
- name: musllinux-x86_64
target: x86_64-unknown-linux-musl
manylinux: "musllinux_1_2"

steps:
- uses: actions/checkout@v4

- name: Build wheels (manylinux/musllinux)
uses: PyO3/maturin-action@86b9d133d34bc1b40018696f782949dac11bd380
with:
maturin-version: v1.11.5
command: build
target: ${{ matrix.platform.target }}
manylinux: ${{ matrix.platform.manylinux }}
args: >
--release --locked --compatibility pypi --out dist
-i python3.10 -i python3.11 -i python3.12
working-directory: s2and_rust

- uses: actions/upload-artifact@v4
with:
name: dist-s2and-rust-linux-${{ matrix.platform.name }}
path: s2and_rust/dist/*

sdist:
name: sdist
runs-on: ubuntu-latest
needs: [detect-versions]
if: needs.detect-versions.outputs.rust_changed == 'true' || needs.detect-versions.outputs.force_build == 'true' || needs.detect-versions.outputs.publish_rust == 'true'
steps:
- uses: actions/checkout@v4

- name: Build sdist
uses: PyO3/maturin-action@86b9d133d34bc1b40018696f782949dac11bd380
with:
maturin-version: v1.11.5
command: sdist
args: --out dist
working-directory: s2and_rust

- uses: actions/upload-artifact@v4
with:
name: dist-s2and-rust-sdist
path: s2and_rust/dist/*

# Publish is split so skipped build jobs don't block unrelated publishes.
# Manual runs can publish by setting workflow_dispatch inputs.
publish-s2and:
name: publish s2and to PyPI
if: (github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.detect-versions.outputs.s2and_changed == 'true') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && needs.detect-versions.outputs.publish_s2and == 'true')
needs: [detect-versions, s2and-dist]
runs-on: ubuntu-latest
environment:
name: pypi
permissions:
id-token: write

steps:
- name: Download s2and dists
uses: actions/download-artifact@v4
with:
name: dist-s2and
path: dist/s2and

- name: Publish s2and
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist/s2and

publish-rust:
name: publish s2and-rust to PyPI
if: (github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.detect-versions.outputs.rust_changed == 'true') || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && needs.detect-versions.outputs.publish_rust == 'true')
needs: [detect-versions, wheels-windows, wheels-macos, wheels-linux, sdist]
runs-on: ubuntu-latest
environment:
name: pypi
permissions:
id-token: write

steps:
- name: Download s2and-rust dists
uses: actions/download-artifact@v4
with:
pattern: dist-s2and-rust-*
merge-multiple: true
path: dist/s2and-rust

- name: Publish s2and-rust
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist/s2and-rust
Loading