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
136 changes: 124 additions & 12 deletions .github/workflows/publish.yml
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,105 @@
name: Publish to PyPI
name: Bump and publish to PyPI

on:
release:
types: [published]
push:
branches: [main]
paths:
- "pyproject.toml"
- "uv.lock"
- "src/**"
workflow_dispatch:
inputs:
pr_number:
description: "Merged PR number to release (retry or bootstrap)"
required: true
type: string

permissions:
id-token: write
contents: write
pull-requests: read

concurrency:
group: syncfield-python-release
cancel-in-progress: false

jobs:
publish:
bump:
name: bump version
runs-on: ubuntu-latest
outputs:
commit_sha: ${{ steps.release.outputs.commit_sha }}
version: ${{ steps.release.outputs.version }}
steps:
- name: Check out main
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"

- name: Set up uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-dependency-glob: uv.lock

- name: Resolve and commit release version
id: release
env:
GH_TOKEN: ${{ github.token }}
MANUAL_PR_NUMBER: ${{ inputs.pr_number }}
run: |
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
pr_number="$MANUAL_PR_NUMBER"
else
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls" \
--jq '[.[] | select(.merged_at != null)][0].number')"
fi
test -n "$pr_number" && test "$pr_number" != "null"
pr_title="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$pr_number" --jq .title)"

source_pr="Source-PR: #$pr_number"
existing_commit="$(git log main --format='%H' --fixed-strings --grep="$source_pr" -1)"
if [ -n "$existing_commit" ]; then
commit_sha="$existing_commit"
version="$(git show "$existing_commit:pyproject.toml" | python -c 'import sys, tomllib; print(tomllib.loads(sys.stdin.read())["project"]["version"])')"
else
version="$(python scripts/bump_version.py --title "$pr_title")"
uv lock
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add pyproject.toml uv.lock
git commit \
-m "chore: release $version [skip ci]" \
-m "$source_pr"
git push origin HEAD:main
commit_sha="$(git rev-parse HEAD)"
fi

printf 'commit_sha=%s\n' "$commit_sha" >>"$GITHUB_OUTPUT"
printf 'version=%s\n' "$version" >>"$GITHUB_OUTPUT"

release:
name: release to PyPI
needs: bump
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v6
- name: Check out release commit
uses: actions/checkout@v6
with:
ref: ${{ needs.bump.outputs.commit_sha }}
fetch-depth: 0

- name: Set up Node (for viewer frontend)
- name: Set up Node
uses: actions/setup-node@v6
with:
# camera-controls (transitive dep via @react-three/drei) requires
# Node >= 22. yarn enforces engines.node strictly — npm only warned,
# which is how the dep slipped past our previous Node 20 runs.
node-version: "22"
cache: "yarn"
cache-dependency-path: src/syncfield/viewer/frontend/yarn.lock
Expand All@@ -38,8 +118,40 @@ jobs:
- name: Install build tools
run: python -m pip install --upgrade pip build

- name: Build sdist and wheel
run: python -m build --sdist --wheel
- name: Build and verify distributions
env:
VERSION: ${{ needs.bump.outputs.version }}
run: |
package_version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
test "$package_version" = "$VERSION"
python -m build --sdist --wheel
python -m pip install --force-reinstall --no-deps dist/*.whl
python -c 'import importlib.metadata, os; assert importlib.metadata.version("syncfield") == os.environ["VERSION"]'

- name: Tag release commit
env:
VERSION: ${{ needs.bump.outputs.version }}
RELEASE_SHA: ${{ needs.bump.outputs.commit_sha }}
run: |
tag="v$VERSION"
if git rev-parse "$tag" >/dev/null 2>&1; then
test "$(git rev-list -n 1 "$tag")" = "$RELEASE_SHA"
else
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$tag" "$RELEASE_SHA" -m "SyncField $VERSION"
git push origin "$tag"
fi

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
skip-existing: true

- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.bump.outputs.version }}
run: |
gh release view "v$VERSION" >/dev/null 2>&1 || \
gh release create "v$VERSION" --title "v$VERSION" --generate-notes
26 changes: 14 additions & 12 deletions .github/workflows/test.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,32 +11,34 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12"]
python-version: ["3.12", "3.13"]

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6

- name: Set up Node (for viewer frontend)
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: "20"
node-version: "22"
cache: "yarn"
cache-dependency-path: src/syncfield/viewer/frontend/yarn.lock

- name: Build viewer frontend
working-directory: src/syncfield/viewer/frontend
run: |
# Same lockfile-regeneration dance as publish.yml: optional
# platform-specific deps (e.g. rollup's @rollup/rollup-linux-*)
# aren't re-resolved when a macOS-generated lockfile runs on
# Ubuntu. Regenerating from package.json on CI works.
rm -f package-lock.json
npm install --no-audit --no-fund
npm run build
yarn install --frozen-lockfile
yarn build

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}

- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install --yes libportaudio2

- name: Install dependencies
run: |
python -m pip install --upgrade pip
Expand Down
5 changes: 1 addition & 4 deletions pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,16 +8,13 @@ version = "0.6.0"
description = "Multi-modal capture orchestration framework with precision sync for Physical AI data collection"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.9"
requires-python = ">=3.12"
authors = [{ name = "OpenGraph Labs" }]
keywords = ["synchronization", "timestamp", "multi-camera", "robotics", "data-collection", "physical-ai"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering",
Expand Down
85 changes: 85 additions & 0 deletions scripts/bump_version.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Increment the SyncField SemVer stored in pyproject.toml."""

from __future__ import annotations

import argparse
import re
from pathlib import Path

VERSION_PATTERN = re.compile(
r'(?m)^version = "(?P<major>0|[1-9]\d*)\.'
r'(?P<minor>0|[1-9]\d*)\.'
r'(?P<patch>0|[1-9]\d*)"$'
)
PROJECT_SECTION_PATTERN = re.compile(
r"(?ms)^\[project\]\s*$.*?(?=^\[|\Z)"
)
MAJOR_TITLE_PATTERN = re.compile(r"^major(?:[!: ]|$)", re.IGNORECASE)
FEAT_TITLE_PATTERN = re.compile(r"^feat(?:\([^)]*\))?!?:", re.IGNORECASE)


def bump_kind_for_title(title: str) -> str:
normalized = title.strip()
if MAJOR_TITLE_PATTERN.match(normalized):
return "major"
if FEAT_TITLE_PATTERN.match(normalized):
return "minor"
return "patch"


def bump_version(pyproject: Path, bump: str) -> str:
text = pyproject.read_text(encoding="utf-8")
project_sections = list(PROJECT_SECTION_PATTERN.finditer(text))
if len(project_sections) != 1:
raise ValueError(f"expected exactly one [project] section in {pyproject}")

project_section = project_sections[0]
matches = list(VERSION_PATTERN.finditer(project_section.group()))
if len(matches) != 1:
raise ValueError(f"expected exactly one [project] version in {pyproject}")

match = matches[0]
major, minor, patch = (
int(match.group("major")),
int(match.group("minor")),
int(match.group("patch")),
)
if bump == "major":
major, minor, patch = major + 1, 0, 0
elif bump == "minor":
minor, patch = minor + 1, 0
elif bump == "patch":
patch += 1
else:
raise ValueError(f"unsupported bump: {bump}")

version = f"{major}.{minor}.{patch}"
start = project_section.start() + match.start()
end = project_section.start() + match.end()
updated = text[:start] + f'version = "{version}"' + text[end:]
pyproject.write_text(updated, encoding="utf-8")
return version


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("bump", nargs="?", choices=("major", "minor", "patch"))
parser.add_argument("--title")
parser.add_argument(
"--pyproject",
type=Path,
default=Path(__file__).resolve().parents[1] / "pyproject.toml",
)
args = parser.parse_args()
if (args.bump is None) == (args.title is None):
parser.error("provide exactly one of bump or --title")
bump = args.bump
if bump is None:
assert args.title is not None
bump = bump_kind_for_title(args.title)
print(bump_version(args.pyproject, bump))


if __name__ == "__main__":
main()
22 changes: 16 additions & 6 deletions src/syncfield/tone.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -591,14 +591,12 @@ def create_default_player(sample_rate: int = 44100) -> ChirpPlayer:
"""Return the best available :class:`ChirpPlayer` for this environment.

Returns a :class:`SoundDeviceChirpPlayer` when ``sounddevice`` is
importable, else a :class:`SilentChirpPlayer`. Import errors are
logged at WARNING — never raised — so the SDK stays usable on
headless machines with no audio output, but interactive users see
the explicit "install ``syncfield[audio]`` to hear chirps" hint
instead of silently wondering why nothing beeps.
importable and a default output device is available, else a
:class:`SilentChirpPlayer`. Detection errors are logged at WARNING —
never raised — so the SDK stays usable on headless machines.
"""
try:
import sounddevice # noqa: F401
import sounddevice
except (ImportError, OSError) as exc:
logger.warning(
"sounddevice failed to load (%s). The 3/2/1 countdown and "
Expand All@@ -608,4 +606,16 @@ def create_default_player(sample_rate: int = 44100) -> ChirpPlayer:
exc,
)
return SilentChirpPlayer()

try:
device_info = sounddevice.query_devices(kind="output")
if not device_info or device_info.get("max_output_channels", 0) <= 0:
raise RuntimeError("no default audio output device")
except Exception as exc: # noqa: BLE001 - backend defines PortAudioError
logger.warning(
"audio output unavailable (%s). The 3/2/1 countdown and "
"start/stop chirps will be SILENT.",
exc,
)
return SilentChirpPlayer()
return SoundDeviceChirpPlayer(sample_rate=sample_rate)
Loading