From 47ef0ccc9e5018c8c8c6ffca08dd11da6a59e574 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 16:23:30 +0000 Subject: [PATCH 1/2] Address unresolved PR #1 review comments; add tag-triggered releases Two greptile-apps threads on PR #1 were never resolved (the 14 CodeRabbit findings were; these weren't caught in that pass): - core/imaging.py: Image.MAX_IMAGE_PIXELS was disabled outright. A folder can contain a downloaded or crafted file with an absurd declared size, not just the user's own camera scans. Raised it to 300M pixels instead of disabling it, so Pillow's decompression-bomb check still fires (and is caught per-file like any other conversion failure) while leaving real large scans well clear of the ceiling. - .github/workflows/build.yml: actions/checkout, setup-python and upload-artifact were pinned to mutable major-version tags, so a retagged upstream release changes what a given commit runs and what an unsigned distributable .app was built with. Pinned all three to commit SHAs (with the version kept as a trailing comment). Also adds .github/workflows/release.yml: pushing a v*.*.* tag builds both macOS bundles and publishes them to a GitHub Release via `gh release create`, instead of only the 14-day CI artifacts build.yml produces. README points at Releases first now and documents how to cut one. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PRY53DANtDbuMuYEanvhGz --- .github/workflows/build.yml | 14 ++++---- .github/workflows/release.yml | 68 +++++++++++++++++++++++++++++++++++ README.md | 27 ++++++++++---- core/imaging.py | 9 +++-- 4 files changed, 102 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/release.yml 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..4b20b0b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,68 @@ +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 }} + run: | + gh release create "${{ github.ref_name }}" dist/*.zip \ + --title "${{ github.ref_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..862de61 100644 --- a/core/imaging.py +++ b/core/imaging.py @@ -14,9 +14,12 @@ 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. This keeps Pillow's check — which raises +# DecompressionBombError before allocating pixel memory, caught per-file like +# any other conversion failure — while giving real large images headroom. +Image.MAX_IMAGE_PIXELS = 300_000_000 # Optional decoders. Both are pure-import side effects, so probe once at module # load rather than per-file. From 50fc1027cef24169b2412dd414f357dbd8df09d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 16:31:02 +0000 Subject: [PATCH 2/2] Fix two P1 findings from Greptile's review of #2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - release.yml: the publish step templated ${{ github.ref_name }} directly into a run: block with contents: write. Actions substitutes that as literal script text before bash ever sees it, so a tag containing shell metachars (git tag names allow $, (, ), backticks — nothing in git-check-ref-format forbids them) would execute inside the double-quoted string. Passed it through env: TAG_NAME instead: the shell only ever sees it as a variable value, never re-parsed as script. - core/imaging.py: Pillow only *warns* (doesn't raise) for images between 1x and 2x MAX_IMAGE_PIXELS and still decodes them — so the 300M cap added in the last commit had a silent 300M-600M gray zone where nothing actually stopped the decode. Added an explicit check right after Image.open() reads the header (before any pixel data is touched), so anything over the limit is rejected the same way regardless of which side of Pillow's fuzzed threshold it falls on. New test covers the gap directly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01PRY53DANtDbuMuYEanvhGz --- .github/workflows/release.yml | 9 +++++++-- core/imaging.py | 23 +++++++++++++++++++---- tests/test_engine.py | 20 ++++++++++++++++++-- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b20b0b..545b0f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,7 +62,12 @@ jobs: - 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 "${{ github.ref_name }}" dist/*.zip \ - --title "${{ github.ref_name }}" \ + gh release create "$TAG_NAME" dist/*.zip \ + --title "$TAG_NAME" \ --generate-notes diff --git a/core/imaging.py b/core/imaging.py index 862de61..5892bd8 100644 --- a/core/imaging.py +++ b/core/imaging.py @@ -16,10 +16,11 @@ # 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. This keeps Pillow's check — which raises -# DecompressionBombError before allocating pixel memory, caught per-file like -# any other conversion failure — while giving real large images headroom. -Image.MAX_IMAGE_PIXELS = 300_000_000 +# 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. @@ -227,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