diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2822466 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,155 @@ +name: Release + +# v* tag push: build wheel, sdist, and Windows zip; publish attaches all three to the Release. +# workflow_dispatch runs the build jobs only (no publish). +# +# Asset names (version from pyproject.toml at the tagged commit): +# cppa_cursor_browser--py3-none-any.whl (~154 KiB at 0.2.0) +# cppa_cursor_browser-.tar.gz (~225 KiB at 0.2.0) +# CursorChatBrowser-windows.zip +# +# Fork rehearsal: push v0.2.0 (pyproject is already 0.2.0) and confirm all three assets on the Release. + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + build-python: + name: Build wheel and sdist + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: Tag must match pyproject version + if: github.ref_type == 'tag' + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + tag_version="${RELEASE_TAG#v}" + pyproject_version="$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")" + if [ "$tag_version" != "$pyproject_version" ]; then + echo "Release tag is $tag_version but pyproject.toml [project].version is $pyproject_version" + exit 1 + fi + + - name: Build hatchling distributables + run: | + python -m pip install --upgrade pip + python -m pip install 'build>=1,<2' + python -m build + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: python-distributables + path: | + dist/*.whl + dist/*.tar.gz + if-no-files-found: error + + build-windows: + name: Build Windows PyInstaller bundle + runs-on: windows-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: Install runtime dependencies + # Runtime from requirements-lock.txt; pywebview pin from pyproject [desktop]. + shell: pwsh + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements-lock.txt + $spec = python scripts/read_desktop_pywebview_spec.py + python -m pip install $spec + + - name: Install PyInstaller + run: python -m pip install 'pyinstaller>=6,<7' + + - name: Build PyInstaller bundle + run: pyinstaller cursor-browser.spec --noconfirm + + - name: Check webview/lib in bundle + shell: pwsh + run: | + $lib = 'dist\CursorChatBrowser\_internal\webview\lib' + if (-not (Test-Path $lib)) { + Write-Error "pywebview bundle missing: $lib" + exit 1 + } + + - name: Zip onedir bundle + shell: pwsh + run: | + if (-not (Test-Path dist\CursorChatBrowser\CursorChatBrowser.exe)) { + Write-Error "dist\CursorChatBrowser\CursorChatBrowser.exe not found" + exit 1 + } + Compress-Archive -Path dist\CursorChatBrowser -DestinationPath CursorChatBrowser-windows.zip + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: windows-bundle + path: CursorChatBrowser-windows.zip + if-no-files-found: error + + publish: + name: Publish GitHub Release assets + needs: [build-python, build-windows] + if: github.event_name == 'push' && github.ref_type == 'tag' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download Python distributables + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: python-distributables + path: release-assets + + - name: Download Windows bundle + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: windows-bundle + path: release-assets + + - name: Verify release asset set + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + version="${RELEASE_TAG#v}" + ls -la release-assets/ + count=$(find release-assets -maxdepth 1 -type f | wc -l) + test "$count" -eq 3 + test -f "release-assets/cppa_cursor_browser-${version}-py3-none-any.whl" + test -f "release-assets/cppa_cursor_browser-${version}.tar.gz" + test -f release-assets/CursorChatBrowser-windows.zip + + - name: Attach artifacts to Release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 + with: + files: release-assets/* diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9ce023b..8d5db70 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -54,6 +54,11 @@ jobs: sys.exit(1) PY + - name: Check pywebview pin is single-sourced + run: | + python scripts/read_desktop_pywebview_spec.py + python scripts/check_pywebview_workflow_pin.py + - name: Install pip-tools # Pin matches update-lock.yml so lock verification uses the same resolver. run: python -m pip install 'pip-tools==7.5.3' @@ -118,8 +123,14 @@ jobs: run: python -m pytest tests/test_api_search.py tests/test_api_workspaces.py tests/test_api_export.py tests/test_pdf_export.py tests/test_search_helpers.py tests/test_check_benchmark_regression.py tests/test_reduce_baselines.py -v --tb=short -o addopts= # ── PyInstaller desktop build (Windows only, once per workflow) ──────── - # Closes #44. Builds the onedir bundle and smoke-tests --help so the - # desktop entry point is verified without launching the GUI window. + # Closes #44. Check webview/lib after build; --help returns before import webview. + - name: Install pywebview for PyInstaller bundle + if: matrix.os == 'windows-latest' && matrix.python-version == '3.12' + shell: pwsh + run: | + $spec = python scripts/read_desktop_pywebview_spec.py + python -m pip install $spec + - name: Install PyInstaller if: matrix.os == 'windows-latest' && matrix.python-version == '3.12' run: python -m pip install 'pyinstaller>=6,<7' @@ -128,9 +139,15 @@ jobs: if: matrix.os == 'windows-latest' && matrix.python-version == '3.12' run: pyinstaller cursor-browser.spec --noconfirm - - name: Smoke-test PyInstaller exe (--help) + - name: Check webview/lib in bundle if: matrix.os == 'windows-latest' && matrix.python-version == '3.12' - run: dist\CursorChatBrowser\CursorChatBrowser.exe --help + shell: pwsh + run: | + $lib = 'dist\CursorChatBrowser\_internal\webview\lib' + if (-not (Test-Path $lib)) { + Write-Error "pywebview bundle missing: $lib" + exit 1 + } # ── Browser XSS: Playwright (sprint item #3) ───────────────────────────── browser-xss: diff --git a/README.md b/README.md index d96b429..e299d74 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,18 @@ Adding new optional fields to JSON responses, adding new CLI flags with sensible Notable changes will be documented in **[CHANGELOG.md](CHANGELOG.md)** following the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. +### GitHub Releases + +Push a `v*` tag (for example `v0.2.1`) to run [`.github/workflows/release.yml`](.github/workflows/release.yml). It uploads three assets to the GitHub Release. Set `[project].version` in `pyproject.toml` to match the tag before you push (`v0.2.1` needs `version = "0.2.1"`). Hatchling names the wheel and sdist from pyproject, not the git tag. + +| Asset | Contents | +|---|---| +| `cppa_cursor_browser--py3-none-any.whl` | Installable wheel (hatchling build) | +| `cppa_cursor_browser-.tar.gz` | Source distribution | +| `CursorChatBrowser-windows.zip` | Windows PyInstaller onedir bundle (`CursorChatBrowser.exe` plus supporting files) | + +At `0.2.0`, a local `python -m build` gave a ~154 KiB wheel and ~225 KiB sdist. Windows zip size varies with the locked tree at tag time. Paste release notes from the matching `[version]` section in `CHANGELOG.md`. + When an API surface is scheduled for removal, follow the process in **[docs/API_DEPRECATION.md](docs/API_DEPRECATION.md)** (response headers, changelog entries, minimum notice period). ## License diff --git a/cursor-browser.spec b/cursor-browser.spec index 6d18f8e..bf66db0 100644 --- a/cursor-browser.spec +++ b/cursor-browser.spec @@ -17,6 +17,7 @@ a = Analysis( (str(src / "static"), "static"), ], hiddenimports=[ + "webview", # needs pywebview installed at build time "api.workspaces", "api.composers", "api.logs", diff --git a/scripts/check_pywebview_workflow_pin.py b/scripts/check_pywebview_workflow_pin.py new file mode 100644 index 0000000..01ecb01 --- /dev/null +++ b/scripts/check_pywebview_workflow_pin.py @@ -0,0 +1,31 @@ +"""Fail if workflow YAML hardcodes a pywebview version instead of read_desktop_pywebview_spec.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +READ_SCRIPT = "read_desktop_pywebview_spec.py" +PYWEBVIEW_INSTALL_WORKFLOWS = ("release.yml", "tests.yml") + + +def main() -> None: + for path in sorted(Path(".github/workflows").glob("*.yml")): + text = path.read_text() + if "pywebview>=" in text or "pywebview<" in text: + print( + f"{path} hardcodes a pywebview version; use scripts/{READ_SCRIPT}", + file=sys.stderr, + ) + raise SystemExit(1) + if READ_SCRIPT not in text and "pywebview" in text.lower(): + if path.name in PYWEBVIEW_INSTALL_WORKFLOWS: + print( + f"{path} installs pywebview but does not read the pin from pyproject", + file=sys.stderr, + ) + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/read_desktop_pywebview_spec.py b/scripts/read_desktop_pywebview_spec.py new file mode 100644 index 0000000..f45d95d --- /dev/null +++ b/scripts/read_desktop_pywebview_spec.py @@ -0,0 +1,21 @@ +"""Stdout the pywebview pin from pyproject.toml [desktop].""" + +from __future__ import annotations + +import sys +import tomllib + + +def main() -> None: + deps = tomllib.load(open("pyproject.toml", "rb"))["project"]["optional-dependencies"]["desktop"] + if len(deps) != 1 or not deps[0].startswith("pywebview"): + print( + "need exactly one pywebview dep in [project.optional-dependencies].desktop", + file=sys.stderr, + ) + raise SystemExit(1) + print(deps[0]) + + +if __name__ == "__main__": + main()