diff --git a/.github/workflows/play-listing.yml b/.github/workflows/play-listing.yml index 0d4d9e8e..e0edeb66 100644 --- a/.github/workflows/play-listing.yml +++ b/.github/workflows/play-listing.yml @@ -1,7 +1,8 @@ # Keeps the Google Play store listing the same as fastlane/metadata/android/en-US/, which -# F-Droid reads too: its title and descriptions, icon, feature graphic and phone screenshots +# F-Droid reads too: its title and descriptions, icon and feature graphic # (scripts/play_listing.py). Only what differs is changed, so the listing only goes to review -# when it has changed. The release notes aren't part of it: releases send those. +# when it has changed. The phone screenshots and release notes aren't part of it: each +# release takes its own screenshots and sends them, with its notes (release.yml). # # On pull requests it only checks the listing against Play's limits. On master it updates Play, # signed in as dev.yml and release.yml are (Workload Identity Federation, no keys), once the diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f351733b..4174e0fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,13 +3,16 @@ # Commits that change something users notice carry a "Release-note:" trailer: one line for # users. Every Monday this collects the notes since the last release (scripts/plan_release.sh); # with none, it does nothing, so refactoring, tests and CI never make a release. With some, it: -# - makes a release commit on top of master, setting the version and writing the notes as -# the fastlane changelog, and pushes it as the tag v. Nothing is pushed to master, -# which keeps its -dev version. F-Droid builds the tag. +# - takes the store screenshots: the app on an emulator against a real Clementine +# (store-screenshots.yml) +# - makes a release commit on top of master, setting the version, writing the notes as the +# fastlane changelog and adding the screenshots, and pushes it as the tag v. +# Nothing is pushed to master, which keeps its -dev version. F-Droid builds the tag. # - publishes a GitHub release with every note and the APK, signed with the release key in # Cloud KMS # - uploads the Play bundle to the closed and open testing tracks (PLAY_RELEASE_TRACK, -# default alpha,beta), with the notes as its release notes +# default alpha,beta), with the notes as its release notes, and sends Play the store +# listing with the new screenshots (scripts/play_listing.py) # # Run it by hand (Actions → release → Run workflow) to release now rather than on Monday, or, # with a tag, to publish an existing release again after a failure. Run by hand, it releases @@ -29,11 +32,6 @@ on: description: "Publish this existing release again (such as v13.1), instead of a new one" required: false -# With dev.yml: one Google Play upload at a time. -concurrency: - group: play - cancel-in-progress: false - permissions: contents: write # for the tag and the GitHub release id-token: write # for the OIDC token Workload Identity Federation exchanges @@ -52,10 +50,51 @@ env: KMS_SIGNER_SHA256: 843d553610ebca4a4beee8fc9a66c0c9c7575bc614bc69ea3646eb656974798a jobs: + # Whether there's a release to make, so the screenshots are only taken for one. The release + # job plans it again, in full. + plan: + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + release: ${{ steps.plan.outputs.release }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Plan the release + id: plan + env: + TAG: ${{ inputs.tag }} + ALWAYS: ${{ github.event_name == 'workflow_dispatch' && '1' || '' }} + run: | + # Publishing a release again: it has its screenshots. + if [ -n "$TAG" ]; then + echo "release=false" >> "$GITHUB_OUTPUT" + else + scripts/plan_release.sh | grep '^release=' | tee -a "$GITHUB_OUTPUT" + fi + + screenshots: + needs: plan + if: needs.plan.outputs.release == 'true' + uses: ./.github/workflows/store-screenshots.yml + # What store-screenshots.yml asks for; it only comments on pull requests. + permissions: + contents: read + pull-requests: write + release: + needs: [plan, screenshots] + # Also without screenshots: when they failed, the release keeps the last release's. # Workload Identity Federation admits master alone. - if: github.ref == 'refs/heads/master' + if: ${{ !cancelled() && github.ref == 'refs/heads/master' }} runs-on: ubuntu-latest + # With dev.yml and play-listing.yml: one Google Play upload at a time. + concurrency: + group: play + cancel-in-progress: false steps: - uses: actions/checkout@v5 with: @@ -117,6 +156,40 @@ jobs: echo "::warning::Releases aren't set up yet, so nothing was released (see RELEASING.md)." fi + - name: Download the screenshots + if: > + steps.setup.outputs.cloud == 'true' && steps.plan.outputs.again != 'true' + && needs.screenshots.result == 'success' + uses: actions/download-artifact@v8 + with: + name: store-screenshots + path: ${{ runner.temp }}/taken + + - uses: actions/setup-python@v7 + if: steps.setup.outputs.cloud == 'true' + with: + python-version: "3.13" + - run: pip install google-api-python-client google-auth pillow + if: steps.setup.outputs.cloud == 'true' + + # From the last release's, so a screen that looks the same stays the same file, and when + # the screenshots failed, the release keeps them. + - name: The store screenshots + if: steps.setup.outputs.cloud == 'true' && steps.plan.outputs.again != 'true' + env: + LAST: ${{ steps.plan.outputs.last }} + TAKEN: ${{ needs.screenshots.result == 'success' && format('{0}/taken/screenshots', runner.temp) || '' }} + run: | + shots=fastlane/metadata/android/en-US/images/phoneScreenshots + if [ -n "$LAST" ] && git cat-file -e "$LAST:$shots" 2> /dev/null; then + git checkout "$LAST" -- "$shots" + fi + if [ -n "$TAKEN" ]; then + scripts/store_screenshots.py "$TAKEN" + else + echo "::warning::The screenshots weren't taken, so this release keeps ${LAST:-no release}'s." + fi + - name: Make the release commit if: steps.setup.outputs.cloud == 'true' && steps.plan.outputs.again != 'true' env: @@ -130,6 +203,7 @@ jobs: mkdir -p "$(dirname "$changelog")" cp "$NOTES/changelog.txt" "$changelog" git add app/build.gradle.kts "$changelog" + git add -A fastlane/metadata/android/en-US/images # Who makes the release commit, and the tag after it. git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" @@ -238,3 +312,8 @@ jobs: tracks: ${{ vars.PLAY_RELEASE_TRACK || 'alpha,beta' }} status: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }} whatsNewDirectory: ${{ runner.temp }}/whatsnew + + # The listing as released: its text and images, with this release's screenshots. + - name: Send the store listing to Google Play + if: steps.setup.outputs.cloud == 'true' + run: scripts/play_listing.py diff --git a/.github/workflows/store-screenshots.yml b/.github/workflows/store-screenshots.yml index 8e63a5fb..b5eaec9d 100644 --- a/.github/workflows/store-screenshots.yml +++ b/.github/workflows/store-screenshots.yml @@ -1,11 +1,12 @@ # Takes the Google Play screenshots: the app on an emulator, connected to a real headless # Clementine (clementine-it) playing the showcase library. The screenshots are uploaded as -# an artifact; scripts/store_graphics.py turns them into the store listing images. +# an artifact. Each release runs this (release.yml) and puts them in its store listing, with +# scripts/store_screenshots.py. # # Also checks the media session against that Clementine (MediaSessionDeviceTest): Android # sees the session and its song, and the system media controls drive Clementine. # -# On pull requests, the screenshots are also posted as a comment next to the store listing's +# On pull requests, the screenshots are also posted as a comment next to the latest release's # (scripts/post_screenshots.sh). They're hosted in a Cloudflare R2 bucket, which charges # nothing for bandwidth, with a token that can only read and write that bucket: # secrets R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY; @@ -14,6 +15,7 @@ name: store-screenshots on: workflow_dispatch: + workflow_call: pull_request: paths: - .github/workflows/store-screenshots.yml diff --git a/RELEASING.md b/RELEASING.md index 9405f1f3..2efd7158 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -31,16 +31,19 @@ rebase merges, each commit keeps its trailer on `master`. (`scripts/plan_release.sh`; run it to see what the next release would be). With none, it does nothing. With some, it releases: -- It makes a **release commit** on top of `master` that sets the version and writes the notes - as the fastlane changelog, and pushes it as the tag `v`. Nothing is pushed to - `master`, which keeps its `-dev` version. +- It takes the **store screenshots**: the app on an emulator, against a real Clementine + (`.github/workflows/store-screenshots.yml`). +- It makes a **release commit** on top of `master` that sets the version, writes the notes + as the fastlane changelog and adds the screenshots, and pushes it as the tag `v`. + Nothing is pushed to `master`, which keeps its `-dev` version. - **F-Droid** builds that tag, reading the version and changelog from it. - A **GitHub release** gets every note, and the APK, signed with the release key in Cloud KMS. - **Google Play** gets the bundle on the closed and open testing tracks (`alpha,beta`, or the repository variable `PLAY_RELEASE_TRACK`, which takes one track or several separated by commas), with the notes as its release notes: at most 500 characters, so a long list ends with "And more fixes and improvements." Closed testing also gets every development build, - so there a release is soon followed by newer builds; open testing gets only releases. + so there a release is soon followed by newer builds; open testing gets only releases. Play's + store listing gets the release's screenshots. **Versions.** Releases are named after `master`'s `versionName` without `-dev`: 13, then 13.1, 13.2 and so on. For a major version, change `master` to `14-dev`. Version codes come @@ -182,18 +185,26 @@ New personal developer accounts must also run a closed test with at least 12 tes ## Store listing `fastlane/metadata/android/en-US/` is the store listing for both stores: `title.txt`, -`short_description.txt`, `full_description.txt`, and `images/` (the icon, the feature graphic -and the phone screenshots). F-Droid reads it from each release tag. - -Google Play gets it from `.github/workflows/play-listing.yml`, whenever it changes on `master`, -or when the workflow is run by hand (`scripts/play_listing.py`). It goes to the app's default -language in Play Console, whichever that is. Only what differs from Play's listing is changed, so the listing goes to review only when it has changed. On pull requests, -the workflow checks the listing against Play's limits instead: text lengths, image sizes, and -two to eight screenshots. - -To refresh the images: run the *store-screenshots* workflow (on pull requests that change the -UI, it runs by itself), download its `store-screenshots` artifact, run -`scripts/store_graphics.py `, and open a pull request with the result. +`short_description.txt`, `full_description.txt`, and `images/` (the icon and the feature +graphic, which `scripts/store_graphics.py` draws). F-Droid reads it from each release tag. + +The **phone screenshots** are each release's own, so they're only on release tags, not on +`master`: every release takes them, and puts them in its release commit +(`scripts/store_screenshots.py`), so the listing always shows the app as released. They're +the main screens in the dark theme, which most people use, then the player in the light +theme. A screen that looks the same as in the last release stays the same file. If taking +them fails, the release goes ahead with the last release's, with a warning. + +Google Play gets the text, icon and feature graphic from `.github/workflows/play-listing.yml`, +whenever they change on `master` or when the workflow is run by hand, and the whole listing, +screenshots too, from each release (`scripts/play_listing.py`). It goes to the app's default +language in Play Console, whichever that is. Only what differs from Play's listing is +changed, so the listing goes to review only when it has changed. On pull requests, the +workflow checks the listing against Play's limits instead: text lengths and image sizes. + +Pull requests that change the UI get the *store-screenshots* workflow's screenshots as a +comment, next to the latest release's, so a change to how the listing will look is seen in +review. **One-time setup:** in Play Console, *Users and permissions*, give `android-play-release@clementine-data.iam.gserviceaccount.com` *Edit store listing, pricing diff --git a/app/src/androidTest/java/de/qspool/clementineremote/StoreScreenshots.java b/app/src/androidTest/java/de/qspool/clementineremote/StoreScreenshots.java index 7db13d55..890812db 100644 --- a/app/src/androidTest/java/de/qspool/clementineremote/StoreScreenshots.java +++ b/app/src/androidTest/java/de/qspool/clementineremote/StoreScreenshots.java @@ -319,7 +319,7 @@ public void takeScreenshots() { screenshot("4_search"); // The same screens in the dark theme: the store listing takes the numbered ones, and the - // light theme's player after them (scripts/store_graphics.py). The activity is recreated + // light theme's player after them (scripts/store_screenshots.py). The activity is recreated // in the dark, keeping where it was. shell("cmd uimode night yes"); SystemClock.sleep(SETTLE_MILLIS); diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png deleted file mode 100644 index f8420052..00000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png deleted file mode 100644 index f35114e2..00000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png deleted file mode 100644 index f1e18be5..00000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png deleted file mode 100644 index a2c326f0..00000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png deleted file mode 100644 index cf4d1e1f..00000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png deleted file mode 100644 index d5ef3953..00000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png deleted file mode 100644 index dd99d2d7..00000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/7.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png deleted file mode 100644 index d536d4d1..00000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/8.png and /dev/null differ diff --git a/scripts/play_listing.py b/scripts/play_listing.py index f28346df..a81e883d 100755 --- a/scripts/play_listing.py +++ b/scripts/play_listing.py @@ -13,6 +13,9 @@ The listing goes to the app's default language on Play, whichever that is (it's set in Play Console); fastlane/ has the one listing, in en-US. +The phone screenshots are each release's own (release.yml takes them), so they're in the +listing only on release tags: without them, Play's are left as they are. + Only what differs from Play's listing is changed: text as it is, images by their SHA-256, so merging something else doesn't send the listing for review again. With nothing to change, the edit is thrown away. @@ -53,15 +56,17 @@ def text(): def images(): """Each image type's files, in the listing's order.""" - screenshots = sorted(IMAGES.glob("phoneScreenshots/*.png"), key=lambda p: int(p.stem)) - if not MIN_SCREENSHOTS <= len(screenshots) <= MAX_SCREENSHOTS: - sys.exit(f"{len(screenshots)} phone screenshots; Play takes {MIN_SCREENSHOTS} to " - f"{MAX_SCREENSHOTS}") - return { + files = { "icon": [IMAGES / "icon.png"], "featureGraphic": [IMAGES / "featureGraphic.png"], - "phoneScreenshots": screenshots, } + screenshots = sorted(IMAGES.glob("phoneScreenshots/*.png"), key=lambda p: int(p.stem)) + if screenshots: + if not MIN_SCREENSHOTS <= len(screenshots) <= MAX_SCREENSHOTS: + sys.exit(f"{len(screenshots)} phone screenshots; Play takes {MIN_SCREENSHOTS} to " + f"{MAX_SCREENSHOTS}") + files["phoneScreenshots"] = screenshots + return files def check_image(kind, path): @@ -157,7 +162,7 @@ def main(): for path in paths: check_image(kind, path) print(f"Listing: {', '.join(f'{f} {len(v)} characters' for f, v in fields.items())}; " - f"{len(files['phoneScreenshots'])} phone screenshots") + f"{len(files.get('phoneScreenshots', [])) or 'no'} phone screenshots") if not args.check: publish(fields, files) diff --git a/scripts/post_screenshots.sh b/scripts/post_screenshots.sh index 23945507..c3eb9c67 100755 --- a/scripts/post_screenshots.sh +++ b/scripts/post_screenshots.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Uploads a store-screenshots run's screenshots to the R2 bucket and posts them on the pull -# request, next to the store listing's current screenshots from master. Run by +# request, next to the latest release's store screenshots. Run by # .github/workflows/store-screenshots.yml; one comment per pull request, updated in place. # # Usage: post_screenshots.sh @@ -9,7 +9,7 @@ # (the bucket-scoped R2 token), GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT, # GITHUB_SERVER_URL, GH_TOKEN, and the AWS and GitHub CLIs. set -euo pipefail -# Name order as scripts/store_graphics.py sorts them (by code point). +# Name order as scripts/store_screenshots.py sorts them (by code point). export LC_ALL=C dir=$1 @@ -34,22 +34,29 @@ AWS_RESPONSE_CHECKSUM_VALIDATION=when_required \ --endpoint-url "https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com" --only-show-errors base="${R2_PUBLIC_URL%/}/$prefix" -# The store screenshots on master, in the order scripts/store_graphics.py numbers them: the -# numbered screens in the dark theme, then the light theme's player. Compared only when this -# run took all of them: after a failure the numbers don't line up. +# The latest release's store screenshots (each release takes its own, in release.yml), in the +# order scripts/store_screenshots.py numbers them: the numbered screens in the dark theme, then +# the light theme's player. Compared only when this run took all of them: after a failure the +# numbers don't line up. +listing=fastlane/metadata/android/en-US/images/phoneScreenshots numbered=("$dir"/dark_[0-9]_*.png "$dir"/1_*.png) -listed=(fastlane/metadata/android/en-US/images/phoneScreenshots/*.png) -compare=$([ ${#numbered[@]} -eq ${#listed[@]} ] && echo yes || echo no) -store="https://raw.githubusercontent.com/$GITHUB_REPOSITORY/master/fastlane/metadata/android/en-US/images/phoneScreenshots" +release=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name 2> /dev/null || true) +listed=0 +if [ -n "$release" ]; then + listed=$(gh api "repos/$GITHUB_REPOSITORY/contents/$listing?ref=$release" --jq length \ + 2> /dev/null || echo 0) +fi +compare=$([ "$listed" -gt 0 ] && [ ${#numbered[@]} -eq "$listed" ] && echo yes || echo no) +store="https://raw.githubusercontent.com/$GITHUB_REPOSITORY/$release/$listing" run="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" { echo "$marker" echo "### Store screenshots" echo - echo "From [run $GITHUB_RUN_ID]($run), against clementine-it. Left: the store listing on master, which shows the dark theme. Right: this pull request, dark and light." + echo "From [run $GITHUB_RUN_ID]($run), against clementine-it. Left: the store listing of the latest release${release:+, $release}, which shows the dark theme. Right: this pull request, dark and light." echo - echo "| Screen | master | This PR, dark | This PR |" + echo "| Screen | ${release:-Released} | This PR, dark | This PR |" echo "| --- | --- | --- | --- |" # The store's screens first (numbered), then the others the run took, which the store # listing doesn't show (such as the settings). diff --git a/scripts/store_graphics.py b/scripts/store_graphics.py index 9b4cdc4d..a8f97ada 100755 --- a/scripts/store_graphics.py +++ b/scripts/store_graphics.py @@ -2,22 +2,18 @@ """Builds the Google Play listing graphics from the project's own artwork. pip install pillow cairosvg - scripts/store_graphics.py [screenshots dir] + scripts/store_graphics.py - icon.png (512x512): Clementine's logo, store/clementine-icon.svg, copied from clementine-player/Clementine's data/icon.svg. - featureGraphic.png (1024x500): the logo on the app's own purple-to-orange gradient (the connect screen's background, res/drawable/activity_background.xml) with the app's name, set in Liberation Sans. -- phoneScreenshots/: the emulator screenshots from the store-screenshots workflow, if a - directory of them is given, copied as they are: the numbered screens in the dark theme, - which most people use, then the player in the light theme, to show there's one. -Everything is drawn from these sources; nothing is generated. +Everything is drawn from these sources; nothing is generated. The phone screenshots are each +release's own (scripts/store_screenshots.py). """ import io -import shutil -import sys from pathlib import Path import cairosvg @@ -107,20 +103,7 @@ def main(): icon().save(OUT / "icon.png", optimize=True) feature_graphic().save(OUT / "featureGraphic.png", optimize=True) - if len(sys.argv) > 1: - shots = OUT / "phoneScreenshots" - shutil.rmtree(shots, ignore_errors=True) - shots.mkdir() - taken = Path(sys.argv[1]) - dark = sorted(taken.glob("dark_[0-9]_*.png")) - if not dark: - sys.exit(f"No dark screenshots (dark__.png) in {taken}") - # Play shows at most eight. - for i, source in enumerate(dark + sorted(taken.glob("1_*.png")), 1): - with Image.open(source) as image: - image.convert("RGB").save(shots / f"{i}.png", optimize=True) - - for path in sorted(OUT.rglob("*.png")): + for path in sorted(OUT.glob("*.png")): with Image.open(path) as image: print(f"{path.relative_to(ROOT)}: {image.size[0]}x{image.size[1]} {image.mode}") diff --git a/scripts/store_screenshots.py b/scripts/store_screenshots.py new file mode 100755 index 00000000..08232035 --- /dev/null +++ b/scripts/store_screenshots.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Makes the store listing's phone screenshots from a store-screenshots run. Run by +.github/workflows/release.yml, so each release's listing shows the app as released: + + pip install pillow + scripts/store_screenshots.py + +They're the numbered screens in the dark theme, which most people use, then the player in +the light theme, to show there's one: Play shows at most eight. They go in +fastlane/metadata/android/en-US/images/phoneScreenshots/, as 1.png, 2.png, ... + +A screenshot whose pixels are the same as the one already there is left as it is, so a +release that doesn't change a screen doesn't store it again. +""" +import sys +from pathlib import Path + +from PIL import Image + +ROOT = Path(__file__).resolve().parent.parent +SHOTS = ROOT / "fastlane" / "metadata" / "android" / "en-US" / "images" / "phoneScreenshots" +MOST = 8 + + +def main(): + if len(sys.argv) != 2: + sys.exit(__doc__) + taken = Path(sys.argv[1]) + dark = sorted(taken.glob("dark_[0-9]_*.png")) + if not dark: + sys.exit(f"No dark screenshots (dark__.png) in {taken}") + sources = dark + sorted(taken.glob("1_*.png")) + if len(sources) > MOST: + sys.exit(f"{len(sources)} screenshots; Play shows at most {MOST}") + + SHOTS.mkdir(parents=True, exist_ok=True) + for i, source in enumerate(sources, 1): + target = SHOTS / f"{i}.png" + with Image.open(source) as image: + new = image.convert("RGB") + if target.exists(): + with Image.open(target) as image: + old = image.convert("RGB") + if old.size == new.size and old.tobytes() == new.tobytes(): + print(f"{target.relative_to(ROOT)}: unchanged ({source.name})") + continue + new.save(target, optimize=True) + print(f"{target.relative_to(ROOT)}: {source.name}, {new.size[0]}x{new.size[1]}") + # Fewer screens than last time: the rest go. + for extra in SHOTS.glob("*.png"): + if not extra.stem.isdigit() or int(extra.stem) > len(sources): + extra.unlink() + print(f"{extra.relative_to(ROOT)}: removed") + + +if __name__ == "__main__": + main()