Repository navigation
Release #10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| # A release is one distribution, `simplecadapi`, built as four platform wheels per Python version (3.10-3.13): | |
| # | |
| # linux-x86_64 manylinux_2_28 | |
| # linux-aarch64 manylinux_2_28 | |
| # macos-arm64 macOS 14+ (Apple silicon) | |
| # windows-x86_64 | |
| # | |
| # Each wheel carries the compiled C++ module (`simplecadapi._cpp`) linked against the same static OpenCASCADE, built from | |
| # source by src/cpp/tools/build_occt.py, so no OCCT is needed on the user's machine and the kernel behaves identically | |
| # everywhere. Building OCCT takes the better part of an hour per platform; the install prefix is cached by the hash of the | |
| # build script, so only a change to OCCT's version or options pays for it again. | |
| on: | |
| push: | |
| tags: | |
| - "v*" | |
| workflow_dispatch: | |
| inputs: | |
| tag: | |
| description: Existing v-prefixed tag to release (a branch or tag name when dry_run is set) | |
| required: true | |
| type: string | |
| platforms: | |
| description: "Wheel platforms to build: all, or a comma list of linux-x86_64, linux-aarch64, macos-arm64, windows-x86_64" | |
| required: false | |
| default: all | |
| type: string | |
| dry_run: | |
| description: Build and verify everything, but publish nothing (no GitHub release, no PyPI upload) | |
| required: false | |
| default: false | |
| type: boolean | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: release-${{ github.ref_name }} | |
| cancel-in-progress: false | |
| env: | |
| RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} | |
| DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} | |
| jobs: | |
| validate: | |
| # Releases are built and published from this repository only; a fork carries the same workflow file but | |
| # not the release credentials, so it must not try. | |
| if: github.repository == 'PhySpace/SimpleCADAPI' | |
| name: Validate the tag | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Check out tagged revision | |
| uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 | |
| with: | |
| ref: ${{ env.RELEASE_TAG }} | |
| - name: Install uv and Python | |
| uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 | |
| with: | |
| python-version: "3.12" | |
| enable-cache: false | |
| - name: Validate tag and project version | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if [[ "$DRY_RUN" == "true" ]]; then | |
| echo "dry run of $RELEASE_TAG: no tag or version check, nothing will be published" | |
| exit 0 | |
| fi | |
| if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([ab]|rc)?[0-9]*$ ]]; then | |
| echo "Invalid release tag: $RELEASE_TAG" >&2 | |
| exit 1 | |
| fi | |
| project_version="$(uv run --python 3.12 --no-project python - <<'PY' | |
| import tomllib | |
| with open('pyproject.toml', 'rb') as file: | |
| print(tomllib.load(file)['project']['version']) | |
| PY | |
| )" | |
| if [[ "$RELEASE_TAG" != "v$project_version" ]]; then | |
| echo "Tag $RELEASE_TAG does not match pyproject version $project_version" >&2 | |
| exit 1 | |
| fi | |
| webui: | |
| # `sca edit` serves a web UI that is built from editor-web/ and git-ignored: without this step the wheels would ship an | |
| # editor with no front end. Built once, shared with every wheel and the sdist. | |
| name: Build the editor web UI | |
| needs: validate | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Check out tagged revision | |
| uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 | |
| with: | |
| ref: ${{ env.RELEASE_TAG }} | |
| - name: Set up Node.js | |
| uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 | |
| with: | |
| node-version: "22" | |
| cache: npm | |
| cache-dependency-path: editor-web/package-lock.json | |
| - name: Build the editor web UI | |
| working-directory: editor-web | |
| run: | | |
| npm ci | |
| npm run build | |
| - name: Keep the built UI | |
| uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 | |
| with: | |
| name: webui | |
| path: src/simplecadapi/editor/webui | |
| if-no-files-found: error | |
| plan: | |
| # Which wheels to build. A release builds all four; a dry run may pick some (to iterate on one platform). | |
| name: Plan the wheels | |
| needs: validate | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| outputs: | |
| matrix: ${{ steps.matrix.outputs.matrix }} | |
| steps: | |
| - name: Select the platforms | |
| id: matrix | |
| shell: bash | |
| env: | |
| PLATFORMS: ${{ github.event_name == 'workflow_dispatch' && inputs.platforms || 'all' }} | |
| run: | | |
| set -euo pipefail | |
| python3 - <<'PY' >> "$GITHUB_OUTPUT" | |
| import json, os | |
| every = [ | |
| {"name": "linux-x86_64", "os": "ubuntu-latest", "archs": "x86_64", "occt_cache": "/home/runner/occt-cache"}, | |
| {"name": "linux-aarch64", "os": "ubuntu-24.04-arm", "archs": "aarch64", "occt_cache": "/home/runner/occt-cache"}, | |
| {"name": "macos-arm64", "os": "macos-14", "archs": "arm64", "occt_cache": "/tmp/occt"}, | |
| {"name": "windows-x86_64", "os": "windows-2022", "archs": "AMD64", "occt_cache": "C:\\occt"}, | |
| ] | |
| wanted = [p.strip() for p in os.environ["PLATFORMS"].split(",") if p.strip()] | |
| chosen = every if wanted in ([], ["all"]) else [e for e in every if e["name"] in wanted] | |
| unknown = [p for p in wanted if p != "all" and p not in {e["name"] for e in every}] | |
| if unknown or not chosen: | |
| raise SystemExit(f"unknown platforms {unknown}; choose from {[e['name'] for e in every]}") | |
| print("matrix=" + json.dumps({"include": chosen})) | |
| PY | |
| wheels: | |
| name: Wheels (${{ matrix.name }}) | |
| needs: [webui, plan] | |
| runs-on: ${{ matrix.os }} | |
| timeout-minutes: 240 | |
| strategy: | |
| fail-fast: false | |
| matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} | |
| steps: | |
| - name: Check out tagged revision | |
| uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 | |
| with: | |
| ref: ${{ env.RELEASE_TAG }} | |
| - name: Fetch the built editor web UI | |
| uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 | |
| with: | |
| name: webui | |
| path: src/simplecadapi/editor/webui | |
| # The static OpenCASCADE prefix. On Linux the build runs inside the manylinux container, which gets this directory | |
| # mounted at /opt/occt; elsewhere cibuildwheel's before-all writes to the path configured in pyproject.toml. | |
| # Restored here and saved by the explicit step after the build, which runs even when the wheel build fails: | |
| # the default post-step saves only a successful job, and rebuilding OCCT costs the better part of an hour. | |
| - name: Restore the static OpenCASCADE build | |
| id: occt | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ${{ matrix.occt_cache }} | |
| key: occt-${{ matrix.name }}-${{ hashFiles('src/cpp/tools/build_occt.py') }} | |
| - name: Prepare the OpenCASCADE cache directory (Linux) | |
| if: runner.os == 'Linux' | |
| run: mkdir -p "${{ matrix.occt_cache }}" | |
| # Windows: the MSVC environment, so the OpenCASCADE build and the extension use Ninja (every translation unit in | |
| # parallel) instead of the Visual Studio generator, which is several times slower for a build this size. | |
| - name: Activate the MSVC environment (Windows) | |
| if: runner.os == 'Windows' | |
| uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0 | |
| with: | |
| arch: x64 | |
| - name: Build the wheels (Linux) | |
| if: runner.os == 'Linux' | |
| uses: pypa/cibuildwheel@b9e566a2b284570af229f6c2a33673410d0389a3 # v4.3.4 | |
| env: | |
| CIBW_ARCHS: ${{ matrix.archs }} | |
| # share the cached prefix with the container (before-all in pyproject.toml builds OCCT only if it is absent) | |
| CIBW_CONTAINER_ENGINE: "docker; create_args: --volume ${{ matrix.occt_cache }}:/opt/occt" | |
| - name: Build the wheels (macOS) | |
| if: runner.os == 'macOS' | |
| uses: pypa/cibuildwheel@b9e566a2b284570af229f6c2a33673410d0389a3 # v4.3.4 | |
| env: | |
| CIBW_ARCHS: ${{ matrix.archs }} | |
| - name: Build the wheels (Windows) | |
| if: runner.os == 'Windows' | |
| uses: pypa/cibuildwheel@b9e566a2b284570af229f6c2a33673410d0389a3 # v4.3.4 | |
| env: | |
| CIBW_ARCHS: ${{ matrix.archs }} | |
| # the MSVC environment is active (above): Ninja, every translation unit in parallel, and keep going after a | |
| # failure so one run reports all the compiler errors rather than the first | |
| CIBW_ENVIRONMENT_WINDOWS: >- | |
| SCAD_OCCT_PREFIX="C:/occt" | |
| CMAKE_ARGS="-DSCAD_OCCT_PREFIX=C:/occt" | |
| CMAKE_GENERATOR="Ninja" | |
| SKBUILD_BUILD_TOOL_ARGS="-k;0" | |
| - name: Save the static OpenCASCADE build | |
| if: always() && steps.occt.outputs.cache-hit != 'true' | |
| continue-on-error: true | |
| uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ${{ matrix.occt_cache }} | |
| key: occt-${{ matrix.name }}-${{ hashFiles('src/cpp/tools/build_occt.py') }} | |
| - name: Keep the wheels | |
| uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 | |
| with: | |
| name: wheels-${{ matrix.name }} | |
| path: wheelhouse/*.whl | |
| if-no-files-found: error | |
| sdist: | |
| name: Source distribution | |
| needs: webui | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Check out tagged revision | |
| uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 | |
| with: | |
| ref: ${{ env.RELEASE_TAG }} | |
| - name: Fetch the built editor web UI | |
| uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 | |
| with: | |
| name: webui | |
| path: src/simplecadapi/editor/webui | |
| - name: Install uv and Python | |
| uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 | |
| with: | |
| python-version: "3.12" | |
| enable-cache: false | |
| - name: Build the source distribution | |
| run: uv build --sdist --out-dir dist | |
| - name: Keep the source distribution | |
| uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 | |
| with: | |
| name: sdist | |
| path: dist/*.tar.gz | |
| if-no-files-found: error | |
| test: | |
| # The full test suite, on the Linux x86_64 wheel of every supported Python (the other platforms run the wheel smoke | |
| # test inside cibuildwheel). The checkout carries the tests only: `simplecadapi` is the installed wheel. | |
| name: Tests (Python ${{ matrix.python-version }}) | |
| needs: wheels | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| python-version: ["3.10", "3.11", "3.12", "3.13"] | |
| steps: | |
| - name: Check out tagged revision | |
| uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 | |
| with: | |
| ref: ${{ env.RELEASE_TAG }} | |
| - name: Fetch the Linux x86_64 wheels | |
| uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 | |
| with: | |
| name: wheels-linux-x86_64 | |
| path: dist | |
| - name: Install uv and Python | |
| uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 | |
| with: | |
| python-version: ${{ matrix.python-version }} | |
| enable-cache: true | |
| cache-python: true | |
| - name: Install the test dependencies and the wheel | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| py="cp${{ matrix.python-version }}"; py="${py/./}" | |
| # the locked dev dependencies without the project itself, then the built wheel on top | |
| uv sync --locked --group dev --no-install-project | |
| uv pip install --no-deps dist/*-"$py"-"$py"-*.whl | |
| - name: Install headless rendering support | |
| run: sudo apt-get update && sudo apt-get install --yes xvfb | |
| - name: Run tests | |
| run: | | |
| xvfb-run --auto-servernum .venv/bin/python -m pytest test tests -q | |
| xvfb-run --auto-servernum .venv/bin/python -m pytest src/cpp/tests/python -q | |
| release: | |
| name: Build the skill and publish the release | |
| needs: [wheels, sdist, test] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: write | |
| steps: | |
| - name: Check out tagged revision | |
| uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 | |
| with: | |
| ref: ${{ env.RELEASE_TAG }} | |
| fetch-depth: 0 | |
| - name: Fetch the wheels and the source distribution | |
| uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 | |
| with: | |
| path: artifacts | |
| - name: Gather the distributions | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| mkdir -p dist | |
| cp artifacts/wheels-*/*.whl artifacts/sdist/*.tar.gz dist/ | |
| ls -l dist | |
| - name: Install uv and Python | |
| uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 | |
| with: | |
| python-version: "3.12" | |
| enable-cache: true | |
| cache-python: true | |
| - name: Verify the distributions | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| uvx twine check dist/* | |
| # four platforms, four Pythons each, plus the sdist | |
| wheels="$(ls dist/*.whl | wc -l)" | |
| if [[ "$wheels" -ne 16 ]]; then | |
| echo "expected 16 wheels (4 platforms x 4 Pythons), found $wheels" >&2 | |
| ls dist | |
| exit 1 | |
| fi | |
| for tag in 'manylinux.*x86_64' 'manylinux.*aarch64' 'macosx.*arm64' 'win_amd64'; do | |
| for py in cp310 cp311 cp312 cp313; do | |
| if ! ls dist/*-"$py"-"$py"-*.whl | grep -Eq "$tag"; then | |
| echo "no wheel for $py / $tag" >&2 | |
| exit 1 | |
| fi | |
| done | |
| done | |
| - name: Verify every wheel carries the compiled module, the editor, the skill and the example | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| for wheel in dist/*.whl; do | |
| listing="$(unzip -Z1 "$wheel")" | |
| for required in \ | |
| simplecadapi/editor/webui/index.html \ | |
| simplecadapi/skill/resources/skillproj.toml \ | |
| simplecadapi/skill/resources/docs/skill/SKILL.md \ | |
| simplecadapi/example/resources/u_link_motor_mount/u_link.py \ | |
| simplecadapi/example/resources/u_link_motor_mount/dimensions.py; do | |
| if ! grep -qx "$required" <<<"$listing"; then | |
| echo "$wheel is missing $required" >&2 | |
| exit 1 | |
| fi | |
| done | |
| # exactly one compiled module, built for the Python this wheel is tagged for | |
| extensions="$(grep -E '^simplecadapi/_cpp/_native\..*\.(so|pyd)$' <<<"$listing" || true)" | |
| if [[ "$(grep -c . <<<"$extensions")" -ne 1 ]]; then | |
| echo "$wheel must carry exactly one compiled module, found:" >&2 | |
| echo "$extensions" >&2 | |
| exit 1 | |
| fi | |
| py_tag="$(basename "$wheel" | sed -E 's/^[^-]+-[^-]+-(cp3[0-9]+)-.*/\1/')" | |
| if ! grep -q "${py_tag#cp}" <<<"$extensions"; then | |
| echo "$wheel is tagged $py_tag but carries $extensions" >&2 | |
| exit 1 | |
| fi | |
| done | |
| - name: Install the Linux wheel to build the skill from it | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| uv sync --locked --group dev --no-install-project | |
| uv pip install --no-deps dist/*-cp312-cp312-*manylinux*x86_64*.whl | |
| - name: Build and verify Agent Skill archive | |
| run: | | |
| .venv/bin/python tools/auto_docs_gen.py --quiet | |
| .venv/bin/python tools/skillbuild.py --target omp | |
| .venv/bin/python -m pytest test/test_skill_build.py -q | |
| tar -C skills -czf skills/simplecadapi.tar.gz simplecadapi-omp | |
| - name: Keep the verified distributions | |
| uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 | |
| with: | |
| name: dist | |
| path: | | |
| dist/* | |
| skills/simplecadapi.tar.gz | |
| if-no-files-found: error | |
| - name: Create GitHub release | |
| if: env.DRY_RUN != 'true' | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| release_flags=() | |
| if [[ "$RELEASE_TAG" =~ (a|b|rc)[0-9]+$ ]]; then | |
| release_flags+=(--prerelease --latest=false) | |
| fi | |
| gh release create "$RELEASE_TAG" dist/* skills/simplecadapi.tar.gz \ | |
| --verify-tag \ | |
| --generate-notes \ | |
| --title "$RELEASE_TAG" \ | |
| "${release_flags[@]}" | |
| publish-pypi: | |
| # PyPI keeps a version forever, so this runs last: only after every wheel is built, tested and verified, and the | |
| # GitHub release exists. Uses PyPI trusted publishing (this workflow + the `pypi` environment registered as the | |
| # project's trusted publisher); a `PYPI_API_TOKEN` secret, when set, is used instead. | |
| if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }} | |
| name: Publish to PyPI | |
| needs: release | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| environment: | |
| name: pypi | |
| url: https://pypi.org/p/simplecadapi | |
| permissions: | |
| id-token: write | |
| steps: | |
| - name: Fetch the verified distributions | |
| uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 | |
| with: | |
| name: dist | |
| path: artifacts | |
| - name: Select the files PyPI takes | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| mkdir -p dist | |
| cp artifacts/dist/*.whl artifacts/dist/*.tar.gz dist/ | |
| ls -l dist | |
| - name: Upload to PyPI | |
| uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 | |
| with: | |
| packages-dir: dist | |
| password: ${{ secrets.PYPI_API_TOKEN }} |