diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 22a9b1a..11d91ec 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,10 +14,10 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: persist-credentials: false - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" cache: pip @@ -33,10 +33,10 @@ jobs: os: [macos-14, windows-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: persist-credentials: false - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" cache: pip @@ -62,10 +62,10 @@ jobs: arch: apple-silicon runs-on: ${{ matrix.runner }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: persist-credentials: false - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" cache: pip @@ -84,7 +84,7 @@ jobs: # tested here — confirm the embedded interpreter starts and imports. "$APP/Contents/MacOS/WebP Studio" --check - run: ditto -c -k --keepParent "dist/WebP Studio.app" "WebP-Studio-macOS-${{ matrix.arch }}.zip" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: WebP-Studio-macOS-${{ matrix.arch }} path: WebP-Studio-macOS-${{ matrix.arch }}.zip diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..545b0f5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,73 @@ +name: Release + +on: + push: + tags: ["v*.*.*"] + +# Only the publish job needs to write, to create the release and attach assets. +permissions: + contents: read + +jobs: + macos-app: + strategy: + fail-fast: false + matrix: + include: + # py2app bundles the running interpreter, so a single runner produces + # a single-arch app that will not launch on the other kind of Mac. + - runner: macos-15-intel + arch: intel + - runner: macos-14 + arch: apple-silicon + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + cache: pip + - run: | + pip install -r requirements.txt + pip install py2app + - run: python setup.py py2app + - name: Verify the bundle launches + run: | + APP="dist/WebP Studio.app" + test -d "$APP" || { echo "no bundle produced"; exit 1; } + "$APP/Contents/MacOS/WebP Studio" --check + - run: ditto -c -k --keepParent "dist/WebP Studio.app" "WebP-Studio-macOS-${{ matrix.arch }}.zip" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: WebP-Studio-macOS-${{ matrix.arch }} + path: WebP-Studio-macOS-${{ matrix.arch }}.zip + retention-days: 5 + + publish: + needs: macos-app + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + pattern: WebP-Studio-macOS-* + path: dist + merge-multiple: true + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + # Templated into the run script as literal text, so a tag crafted + # with shell metacharacters would be interpreted by bash. Passing + # it through env and referencing $TAG_NAME instead means the shell + # only ever sees it as a variable's value, never as script text. + TAG_NAME: ${{ github.ref_name }} + run: | + gh release create "$TAG_NAME" dist/*.zip \ + --title "$TAG_NAME" \ + --generate-notes diff --git a/README.md b/README.md index 2d34cec..855d8d8 100644 --- a/README.md +++ b/README.md @@ -63,12 +63,15 @@ python main.py --check ### Prebuilt macOS app -No signed releases yet — grab a build straight from CI instead: -[**Actions → Build → latest run**](https://github.com/DhakadG/ConvertImagesToWebP-MacApp/actions/workflows/build.yml) -→ pick `WebP-Studio-macOS-apple-silicon` or `WebP-Studio-macOS-intel` under -Artifacts. Unzip, then see [Gatekeeper](#the-app-is-damaged-and-cant-be-opened) -below before opening it. Artifacts expire after 14 days, so if the run has -aged out, use `workflow_dispatch` to trigger a fresh one from the Actions tab. +Grab the latest build from [**Releases**](https://github.com/DhakadG/ConvertImagesToWebP-MacApp/releases) — +pick `WebP-Studio-macOS-apple-silicon.zip` or `WebP-Studio-macOS-intel.zip`. +Unzip, then see [Gatekeeper](#the-app-is-damaged-and-cant-be-opened) below +before opening it. These are unsigned dev builds cut from tags, not +App Store releases. + +No tagged release yet, or want the very latest commit instead? Grab a build +straight from CI: [**Actions → Build → latest run**](https://github.com/DhakadG/ConvertImagesToWebP-MacApp/actions/workflows/build.yml) +→ Artifacts. Those expire after 14 days; run `workflow_dispatch` for a fresh one. ## macOS @@ -180,6 +183,18 @@ no window server, so that last step is yours. Issues and PRs welcome. `python tests/test_engine.py` should stay green with no display, and CI runs it plus the GUI boot suite on every PR. +### Cutting a release + +Push a tag matching `v*.*.*`: + +```bash +git tag v2.0.0 +git push origin v2.0.0 +``` + +`.github/workflows/release.yml` builds both macOS bundles and publishes them +to a new GitHub Release, titled after the tag with auto-generated notes. + ## License [MIT](LICENSE). diff --git a/core/imaging.py b/core/imaging.py index 45cefff..5892bd8 100644 --- a/core/imaging.py +++ b/core/imaging.py @@ -14,9 +14,13 @@ from core.config import Settings -# We routinely handle 100 MP camera scans; the decompression-bomb guard is for -# untrusted input, and these files come from the user's own disk. -Image.MAX_IMAGE_PIXELS = None +# Raised, not disabled: legitimate scans and panoramas run well past Pillow's +# ~89 MP default, but a folder can still contain a downloaded or crafted file +# with an absurd declared size. Pillow itself only *warns* (doesn't raise) +# between 1x and 2x this value, so the real gate is the explicit check in +# convert_file below; this just keeps Pillow's own internal calls bounded too. +MAX_PIXELS = 300_000_000 +Image.MAX_IMAGE_PIXELS = MAX_PIXELS # Optional decoders. Both are pure-import side effects, so probe once at module # load rather than per-file. @@ -224,11 +228,25 @@ def _check_dimensions(width: int, height: int, output_format: str) -> None: f"{limit}px — set a downscale limit, or choose PNG/AVIF") +def _check_pixel_count(width: int, height: int) -> None: + """Pillow only warns, and still decodes, between 1x and 2x MAX_PIXELS — + this is the actual gate. Called right after Image.open(), before any + pixel data is touched, so an oversized file costs a header read, not a + full decode.""" + pixels = width * height + if pixels > MAX_PIXELS: + raise ValueError( + f"{width}x{height} ({pixels:,} px) exceeds the {MAX_PIXELS:,} px " + f"safety limit — skipped before decoding") + + def convert_file(source: Path, destination: Path, settings: Settings) -> Encoded: """Convert one image. Raises on failure — the runner turns that into a per-file error row so one bad file can't abort the batch.""" note = "" with Image.open(source) as opened: + _check_pixel_count(*opened.size) + if getattr(opened, "n_frames", 1) > 1: note = "animated source, first frame only" diff --git a/tests/test_engine.py b/tests/test_engine.py index 0705732..998d47a 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -15,8 +15,8 @@ from PIL import Image from core.config import Settings -from core.imaging import (_check_dimensions, _target_size, available_output_formats, - convert_file) +from core.imaging import (MAX_PIXELS, _check_dimensions, _check_pixel_count, + _target_size, available_output_formats, convert_file) from core.runner import CONVERTED, FAILED, SKIPPED, Runner, scan_sources, common_root @@ -242,6 +242,21 @@ def test_webp_dimension_limit(tmp: Path): print(" webp dimension limit ok") +def test_pixel_count_limit(): + """Pillow only warns (still decodes) between 1x and 2x MAX_IMAGE_PIXELS — + this must reject anything over the limit itself, not Pillow's fuzzed one.""" + side = int(MAX_PIXELS ** 0.5) + _check_pixel_count(side, side) # just under the limit: fine + + try: + _check_pixel_count(side + 1, side + 1) # inside Pillow's warn-only zone + except ValueError as exc: + assert str(MAX_PIXELS) in str(exc).replace(",", ""), exc + else: + raise AssertionError("image over MAX_PIXELS was not rejected") + print(" pixel count limit ok") + + def test_format_falls_back_to_a_writable_one(tmp: Path): """A preset must not select a format this Pillow build cannot encode.""" settings = Settings.from_dict({"output_format": "avif"}) @@ -272,6 +287,7 @@ def main() -> int: test_overwrite_keeps_good_output_when_encode_fails(tmp / "t11") test_webp_dimension_limit(tmp / "t12") test_format_falls_back_to_a_writable_one(tmp / "t13") + test_pixel_count_limit() print("\nall engine checks passed") return 0