Author SHA1 Message Date
Pedro Santos f9649cb5d9 Build RommPS in the release workflow, never publish without the store zip
1.3.0 went out without PPSA76677.zip because RommPS was built by hand after
the release, and the step was missed. The release workflow now builds the
payloads, then RommPS against the pinned PS5 stack (RADV cached), and
publishes only once both have built. PS4-only re-releases are unchanged.

- tools/ps5-stack.sh checks out the stack at its pins
- tools/package-app.sh is the shared from-scratch build and zip
- tools/release-app.sh stays as the fallback for a release missing its zip
- Store workflow: hourly check that the latest release has the zip and
  that ProsperoStore lists it
2026-10-23 10:36:07 -04:00
7 changed files with 379 additions and 56 deletions

No files matched your search

+133
View File
@@ -0,0 +1,133 @@
# Builds RommPS, the PS5 app, into PPSA76677.zip: the file ProsperoStore
# installs. release.yml runs it between building the payloads and publishing,
# so a PS5 release can't go out without it (1.3.0 did, back when it was built
# by hand, and the store sat on 1.2.1 for a day).
#
# The PS5 stack is checked out at its pins (tools/ps5-stack.sh) and RADV, the
# slow part, is built once per Mesa and SDK revision and cached. The zip ends
# up as the artifact "rommps"; this workflow never touches a release.
name: RommPS
on:
workflow_call:
inputs:
sha:
description: "Commit to build"
type: string
required: true
tag:
description: "The release, e.g. v1.3.1"
type: string
required: true
payloads:
description: "Artifact holding that release's romm-sync.elf"
type: string
required: true
# Test runs only, removed before merging: builds this branch with v1.3.0's
# payload, and stops at the artifact.
push:
branches: [ensure-store-zip-creation]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 300
env:
GH_TOKEN: ${{ github.token }}
PS5_STACK: ${{ github.workspace }}/ps5-stack
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.sha || github.sha }}
path: repo
- name: Resolve version
id: version
run: |
tag="${{ inputs.tag }}"
if [ -z "$tag" ]; then
IFS=. read -r major minor patch < <(jq -r .contentVersion repo/platform/ps5/app/rommps/ps5/sce_sys/param.json)
tag="v$((10#$major)).$((10#$minor)).$((10#$patch))"
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "Building RommPS for $tag"
# RommPS carries the payload and starts it: the release's own romm-sync.elf.
- uses: actions/download-artifact@v4
if: inputs.payloads
with:
name: ${{ inputs.payloads }}
path: payloads
- name: The payload
env:
TAG: ${{ steps.version.outputs.tag }}
FROM_RELEASE: ${{ inputs.payloads == '' }}
run: |
mkdir -p repo/build/ps5
if [ "$FROM_RELEASE" = true ]; then
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" -p romm-sync.elf -O repo/build/ps5/romm-sync.elf
else
cp payloads/romm-sync.elf repo/build/ps5/romm-sync.elf
fi
- name: Check out the PS5 stack
id: stack
run: |
repo/tools/ps5-stack.sh "$PS5_STACK"
pin() { sed -n "s/^$2=//p" "$PS5_STACK/PS5_Vulkan/tools/$1"; }
echo "radv=$(pin build-radv.sh mesa_revision)-$(pin setup-native-dependencies.sh sdk_revision)" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
# The image tools/ps5-app-build.sh runs everything in. The cache only
# saves time, so failing to write it must not fail the build.
- uses: docker/build-push-action@v6
with:
context: repo/platform/ps5/app/docker
tags: rommps-ps5app:1
load: true
cache-from: type=gha,scope=ps5app
cache-to: type=gha,mode=max,scope=ps5app,ignore-error=true
- uses: actions/cache@v4
with:
path: ps5-stack/PS5_Vulkan/.deps/native/radv-release
key: radv-release-${{ steps.stack.outputs.radv }}
# What ps5/tools/bootstrap.sh does, minus the console: PS5_Vulkan's SDK,
# its native tool and libc.prx (make builds those first, then a sample a
# title doesn't need), and RADV, which build-radv.sh skips when the
# cached archive is already at its pins.
- name: Build the PS5 stack
run: |
repo/tools/ps5-app-build.sh bash -c '
set -euo pipefail
cd /stack/PS5_Vulkan
bash tools/setup-native-dependencies.sh > /dev/null
make > build-make.log 2>&1 || true
[[ -x build/host/ps5-native-tool && -f runtime/libc.prx ]] || { tail -50 build-make.log; exit 1; }
time bash tools/build-radv.sh release'
- name: Title art
run: repo/tools/ps5-app-build.sh bash /repo/platform/ps5/app/tools/title-art.sh
- name: Build and package RommPS
run: repo/tools/package-app.sh "${{ steps.version.outputs.tag }}"
- name: Logs
if: failure()
run: |
shopt -s globstar nullglob
for log in ps5-stack/PS5_Vulkan/build-make.log ps5-stack/PS5_Vulkan/.deps/work/**/*.log repo/platform/ps5/app/rommps/build/*.log; do
echo "::group::$log"; tail -80 "$log"; echo "::endgroup::"
done
- uses: actions/upload-artifact@v4
with:
name: rommps
path: repo/build/release/PPSA76677.zip
if-no-files-found: error
+71 -23
View File
@@ -1,4 +1,9 @@
# Builds the PS5 and PS4 payloads and publishes a GitHub release. Triggered either by:
# Builds the PS5 and PS4 payloads and RommPS (PPSA76677.zip, the file
# ProsperoStore installs), then publishes them as one GitHub release. Nothing is
# published unless all of it built: 1.3.0 went out without the zip, back when
# RommPS was built by hand, and the store sat on 1.2.1 for a day.
#
# Triggered either by:
#
# - merging a PR into main from a branch named with a version, e.g.
# release/v1.0.2 or v1.0.2-some-fix (the tag is created on the merge commit)
@@ -9,6 +14,7 @@
# rebuilds only the PS4 payload from ref and swaps it into the existing tag's
# release: a PS4 fix that doesn't make the PS5 download anything. Those already
# on that version reinstall it by hand; the in-app update only offers newer ones.
# The PS5's files, PPSA76677.zip included, stay exactly as they are.
name: Release
on:
@@ -30,12 +36,15 @@ permissions:
contents: write
jobs:
release:
payloads:
if: >-
github.event_name == 'push' ||
(github.event.pull_request.merged &&
contains(github.event.pull_request.head.ref, 'v'))
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.version.outputs.tag }}
sha: ${{ steps.version.outputs.sha }}
steps:
- name: Resolve version
id: version
@@ -68,13 +77,18 @@ jobs:
with:
ref: ${{ steps.version.outputs.sha }}
# RommPS (built with tools/release-app.sh, outside this workflow) is
# offered as an update by the homebrew catalog only if its contentVersion
# goes up, so the tag has to match it: v1.1.0 is 01.001.000.
- name: Check RommPS's contentVersion
# The catalog offers RommPS as an update only if its contentVersion goes
# up, so the tag has to match it: v1.1.0 is 01.001.000. And RommPS starts
# the payload it carries, so it must ask for this one. Both are checked
# again when it's built (tools/package-app.sh); here they fail in a
# minute rather than after the long build.
- name: Check versions and release notes
if: steps.version.outputs.tag
env:
TAG: ${{ steps.version.outputs.tag }}
# The notes are written by hand, in docs/releases/<tag>.md: the homebrew
# catalog shows them as RommPS's release notes, and drops GitHub's
# generated list.
run: |
IFS=. read -r major minor patch <<< "${TAG#v}"
want=$(printf "%02d.%03d.%03d" "$major" "$minor" "$patch")
@@ -83,6 +97,14 @@ jobs:
echo "::error::RommPS's contentVersion is $have, $TAG needs $want (platform/ps5/app/rommps/ps5/sce_sys/param.json)"
exit 1
fi
if ! grep -q "kMinPayload = \"${TAG#v}\"" platform/ps5/app/rommps/examples/rommps/kit/rommps_app.hpp; then
echo "::error::kMinPayload in rommps_app.hpp isn't ${TAG#v}"
exit 1
fi
if [ ! -s "docs/releases/$TAG.md" ]; then
echo "::error::docs/releases/$TAG.md is missing; write the release notes before tagging"
exit 1
fi
- uses: docker/setup-buildx-action@v3
if: steps.version.outputs.tag
@@ -140,27 +162,53 @@ jobs:
}
sign romm-sync.elf manifest
sign romm-sync-ps4.elf manifest-ps4
cd dist && sha256sum * > SHA256SUMS
# Creates the tag on the target commit if it doesn't exist yet. A tag pushed
# by GITHUB_TOKEN does not re-trigger this workflow, so there's no double release.
- name: Publish release
- uses: actions/upload-artifact@v4
if: steps.version.outputs.tag
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.version.outputs.tag }}
SHA: ${{ steps.version.outputs.sha }}
# The notes are written by hand, in docs/releases/<tag>.md: the homebrew
# catalog shows them as RommPS's release notes, and drops GitHub's
# generated list.
with:
name: payloads
path: dist/
if-no-files-found: error
app:
needs: payloads
if: needs.payloads.outputs.tag
uses: ./.github/workflows/app.yml
with:
sha: ${{ needs.payloads.outputs.sha }}
tag: ${{ needs.payloads.outputs.tag }}
payloads: payloads
# Only once the payloads and RommPS have both built. gh uploads every file
# before the release is published, so nobody sees half of one. It creates
# the tag on the target commit too; a tag pushed by GITHUB_TOKEN does not
# re-trigger this workflow, so there's no double release.
publish:
needs: [payloads, app]
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.payloads.outputs.tag }}
SHA: ${{ needs.payloads.outputs.sha }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.payloads.outputs.sha }}
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Publish release
run: |
notes="docs/releases/$TAG.md"
if [ ! -s "$notes" ]; then
echo "::error::$notes is missing; write the release notes before tagging"
exit 1
fi
for file in romm-sync.elf manifest.json manifest.sig romm-sync-ps4.elf romm-sync-ps4.pkg \
manifest-ps4.json manifest-ps4.sig PPSA76677.zip; do
[ -s "dist/$file" ] || { echo "::error::dist/$file is missing"; exit 1; }
done
(cd dist && sha256sum * > SHA256SUMS)
gh release create "$TAG" dist/* \
--target "$SHA" --title "$TAG" --notes-file "$notes"
--target "$SHA" --title "$TAG" --notes-file "docs/releases/$TAG.md"
replace-ps4:
if: github.event_name == 'workflow_dispatch'
+72
View File
@@ -0,0 +1,72 @@
# Makes sure the latest release actually reaches ProsperoStore. release.yml
# doesn't publish a PS5 release without PPSA76677.zip any more (1.3.0 went out
# without one and the store sat on 1.2.1 for a day); this is the backstop for
# a release published some other way, and for the store side.
#
# Every hour it looks at the latest release:
#
# - no zip at all (published by hand, say)
# - the store still lists an older version 12 hours on (the catalog's own
# bot proposes new releases three times a day)
#
# A problem opens an issue (one per release) and fails the run; once it's
# fixed, the issue is closed. PS4-only re-releases (release.yml's replace-ps4)
# never touch the zip, so they never come up here.
name: Store
on:
schedule:
- cron: "17 * * * *"
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
check:
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
ZIP: PPSA76677.zip
TITLE_ID: PPSA76677
STORE_API: https://prosperostore.net/api/v1/versions.json
steps:
- name: Check the latest release
run: |
minutes_since() { echo $(( ($(date +%s) - $(date -d "$1" +%s)) / 60 )); }
failed=0
# report <tag> <problem, or empty when there's none>
report() {
local title="$1 isn't on ProsperoStore" open
open=$(gh issue list --state open --search "in:title \"$title\"" --json number,title |
jq -r --arg t "$title" '.[] | select(.title == $t) | .number')
if [[ -z "$2" ]]; then
for n in $open; do gh issue close "$n" --comment "Fixed."; done
echo "$1: fine"
return
fi
[[ -n "$open" ]] || gh issue create --title "$title" --body "$2"
echo "::error::$2"
failed=1
}
release=$(gh api "repos/$GH_REPO/releases/latest")
tag=$(jq -r .tag_name <<< "$release")
problem=
if ! jq -e --arg zip "$ZIP" 'any(.assets[]; .name == $zip)' <<< "$release" >/dev/null; then
problem="$tag is published without $ZIP, so the store has nothing to pick up. Run \`tools/release-app.sh $tag --upload\` from a checkout of $tag."
# The store's API being down isn't our problem; the next run asks again.
elif listed=$(curl -fsS --retry 3 "$STORE_API" | jq -r --arg id "$TITLE_ID" '.apps[$id].version // empty'); then
if [[ "$listed" != "${tag#v}" ]] && (( $(minutes_since "$(jq -r .published_at <<< "$release")") >= 720 )); then
problem="$ZIP is attached to $tag, but the store still lists ${listed:-nothing}. Open the catalog update by hand (docs/release-checklist.md)."
fi
else
echo "::warning::couldn't read $STORE_API"
exit "$failed" # no verdict, so its issue stays as it is
fi
report "$tag" "$problem"
exit "$failed"
+6 -2
View File
@@ -45,5 +45,9 @@ For each one, start from the old version actually installed and set up, then upd
## After
- The release workflow published the payloads, then `tools/release-app.sh <tag> --upload` attached `PPSA76677.zip`.
- The catalog bot's pull request for the new version shows up the next day.
Every PS5 release, alone or with the PS4, ships `PPSA76677.zip`. Only a PS4-only re-release (the manual Release run) skips it, and that never touches the PS5's files.
- The release workflow builds the payloads, then RommPS (`.github/workflows/app.yml`), and publishes the release only once both have built. No zip, no release: rerun the failed job.
- The store: the catalog bot proposes the new version within 8 hours. For sooner, open the pull request yourself on the catalog (`apps/PPSA76677.json`: `version`, `artifact_url`, `sha256`, `icon_url`), from the account that owns the repository; it's merged and live within minutes.
- The Store workflow checks every hour and opens an issue if the latest release has no zip, or the store still lists the old version 12 hours after publishing.
- A release that went out without the zip anyway: `git checkout <tag> && tools/release-app.sh <tag> --upload` builds it here and attaches it.
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Builds RommPS from scratch and packages it as build/release/PPSA76677.zip,
# the file ProsperoStore installs.
#
# tools/package-app.sh v1.3.1
#
# Used by the release workflow (.github/workflows/app.yml) and by
# tools/release-app.sh. It expects build/ps5/romm-sync.elf to be the payload
# of that same release, since RommPS carries it and starts it, and the title
# art (pic0/pic1.dds, from platform/ps5/app/tools/title-art.sh) in place.
# The PS5 stack is PS5_STACK, or ../ps5-stack (tools/ps5-app-build.sh).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP="$ROOT/platform/ps5/app/rommps"
TAG="${1:?usage: tools/package-app.sh vX.Y.Z}"
die() { echo "package-app: $*" >&2; exit 1; }
# The catalog tells consoles about an update from contentVersion, so it must
# match the tag: v1.1.0 is 01.001.000.
[[ "$TAG" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || die "$TAG isn't a vX.Y.Z tag"
want=$(printf "%02d.%03d.%03d" "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}")
have=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["contentVersion"])' "$APP/ps5/sce_sys/param.json")
[[ "$have" == "$want" ]] || die "contentVersion is $have, $TAG needs $want (ps5/sce_sys/param.json)"
grep -q "kMinPayload = \"${TAG#v}\"" "$APP/examples/rommps/kit/rommps_app.hpp" ||
die "kMinPayload in rommps_app.hpp isn't ${TAG#v}"
for art in pic0 pic1; do
[[ -s "$APP/ps5/sce_sys/$art.dds" ]] || die "$art.dds is missing: run platform/ps5/app/tools/title-art.sh"
done
[[ -s "$ROOT/build/ps5/romm-sync.elf" ]] || die "build/ps5/romm-sync.elf is missing"
# From scratch: a dev build left behind gives ninja nothing to do, and its
# notices still say "uncommitted changes", so the check below refuses it.
rm -rf "$APP/build/ps5" "$APP/dist/PPSA76677"
"$ROOT/tools/ps5-app-build.sh" bash -c 'cd /repo/platform/ps5/app/rommps && bash ps5/tools/build.sh rommps'
# The notices name the commit of every part; one built with uncommitted changes
# wouldn't be the source they point to.
dirty=$(python3 -c 'import json,sys; print(" ".join(p["id"] for p in json.load(open(sys.argv[1])) if p["source"].get("dirty")))' \
"$APP/dist/PPSA76677/licenses/components.json")
[[ -z "$dirty" ]] || die "built from uncommitted changes in: $dirty"
cmp -s "$ROOT/build/ps5/romm-sync.elf" "$APP/dist/PPSA76677/assets/rommps/romm-sync.elf" ||
die "the romm-sync.elf in RommPS isn't build/ps5/romm-sync.elf"
mkdir -p "$ROOT/build/release"
rm -f "$ROOT/build/release/PPSA76677.zip"
(cd "$APP/dist" && zip -qrX "$ROOT/build/release/PPSA76677.zip" PPSA76677 -x 'PPSA76677/screenshots/*')
(cd "$ROOT" && shasum -a 256 build/release/PPSA76677.zip)
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Checks out the PS5 stack RommPS builds against, at the commits a release
# uses, for the release workflow (or anyone without ../ps5-stack):
#
# tools/ps5-stack.sh <dir> PS5_Vulkan, PS5_Mesa and PS5_PayloadSDK in <dir>
#
# PS5_Vulkan is pinned here. Everything else is pinned where it's used, and
# read from there so there's one place to bump: Mesa by PS5_Vulkan's
# tools/build-radv.sh, the payload SDK by PS5_Vulkan's
# tools/setup-native-dependencies.sh and by RommPS's ps5/tools/setup-sdk.sh.
# Every script that uses them does `git archive <pin>`, so only those commits
# are fetched, not the histories (Mesa's alone is a gigabyte).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
STACK="${1:?usage: tools/ps5-stack.sh <dir>}"
# 5b5e4fc: what 1.3.0 was built with. RADV comes out of this one's pin of PS5_Mesa.
vulkan_revision=5b5e4fc2d80fdb67a4f61ba9e6a8a424026bb8a5
# fetch <repo> <commit>...: a repository holding just those commits, the first checked out
fetch() {
local repo="$1" dir="$STACK/$1"
shift
[[ -d $dir/.git ]] || git init -q "$dir"
git -C "$dir" remote get-url origin >/dev/null 2>&1 ||
git -C "$dir" remote add origin "https://github.com/mihawk-99/$repo.git"
git -C "$dir" fetch -q --depth 1 origin "$@"
git -C "$dir" -c advice.detachedHead=false checkout -q "$1"
echo "$repo at ${1:0:11}"
}
pin() { sed -n "s/^$2=//p" "$1"; }
fetch PS5_Vulkan "$vulkan_revision"
fetch PS5_Mesa "$(pin "$STACK/PS5_Vulkan/tools/build-radv.sh" mesa_revision)"
fetch PS5_PayloadSDK "$(pin "$ROOT/platform/ps5/app/rommps/ps5/tools/setup-sdk.sh" sdk_revision)" \
"$(pin "$STACK/PS5_Vulkan/tools/setup-native-dependencies.sh" sdk_revision)"
+15 -31
View File
@@ -1,56 +1,40 @@
#!/usr/bin/env bash
# Builds RommPS for a release and attaches PPSA76677.zip to its GitHub release.
# Builds RommPS for a release here, and attaches PPSA76677.zip to its GitHub release.
#
# tools/release-app.sh v1.1.0 build and package it, in build/release/
# tools/release-app.sh v1.1.0 --upload and attach it to the release v1.1.0
#
# The release workflow can't build RommPS: it needs the PS5 stack beside this
# repository (tools/ps5-app-build.sh). So it is built here, from the tagged
# commit, once the workflow has published the payloads. The homebrew catalog picks the release up from the asset's name,
# so it stays PPSA76677.zip, and an asset already published is never replaced.
# The release workflow builds and attaches the zip itself (.github/workflows/app.yml);
# a PS5 release isn't published without it. This is the fallback for a release
# that went out without one anyway, which is how the store sat on 1.2.1 for a
# day after 1.3.0. The homebrew catalog picks the release up from the asset's
# name, so it stays PPSA76677.zip, and an asset already published is never replaced.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP="$ROOT/platform/ps5/app/rommps"
TAG="${1:?usage: tools/release-app.sh vX.Y.Z [--upload]}"
die() { echo "release-app: $*" >&2; exit 1; }
# The catalog tells consoles about an update from contentVersion, so it must
# match the tag: v1.1.0 is 01.001.000.
[[ "$TAG" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || die "$TAG isn't a vX.Y.Z tag"
want=$(printf "%02d.%03d.%03d" "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}")
have=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["contentVersion"])' "$APP/ps5/sce_sys/param.json")
[[ "$have" == "$want" ]] || die "contentVersion is $have, $TAG needs $want (ps5/sce_sys/param.json)"
cd "$ROOT"
git diff --quiet && git diff --cached --quiet || die "commit or stash your changes first"
[[ "$(git rev-parse HEAD)" == "$(git rev-parse "$TAG^{commit}" 2>/dev/null)" ]] || die "check out $TAG first"
for art in pic0 pic1; do # the home screen art, made by platform/ps5/app/tools/title-art.sh
[[ -s "$APP/ps5/sce_sys/$art.dds" ]] || die "$art.dds is missing: run platform/ps5/app/tools/title-art.sh"
done
if [[ "${2:-}" == --upload ]] && gh release view "$TAG" --json assets -q '.assets[].name' | grep -qx PPSA76677.zip; then
die "$TAG already has PPSA76677.zip; publish a new release instead of replacing it"
fi
# RommPS carries the payload and starts it when it isn't running: the
# release's own romm-sync.elf, the same file the in-app updater installs.
grep -q "kMinPayload = \"${TAG#v}\"" "$APP/examples/rommps/kit/rommps_app.hpp" ||
die "kMinPayload in rommps_app.hpp isn't ${TAG#v}"
mkdir -p build/ps5
gh release download "$TAG" -p romm-sync.elf -O build/ps5/romm-sync.elf --clobber
(cd build/ps5 && gh release download "$TAG" -p SHA256SUMS -O - | grep " romm-sync.elf$" | shasum -a 256 -c -) ||
die "build/ps5/romm-sync.elf isn't $TAG's"
tools/ps5-app-build.sh bash -c 'cd /repo/platform/ps5/app/rommps && bash ps5/tools/build.sh rommps'
# The notices name the commit of every part; one built with uncommitted changes
# wouldn't be the source they point to.
dirty=$(python3 -c 'import json,sys; print(" ".join(p["id"] for p in json.load(open(sys.argv[1])) if p["source"].get("dirty")))' \
"$APP/dist/PPSA76677/licenses/components.json")
[[ -z "$dirty" ]] || die "built from uncommitted changes in: $dirty"
mkdir -p build/release
rm -f build/release/PPSA76677.zip
(cd "$APP/dist" && zip -qrX "$ROOT/build/release/PPSA76677.zip" PPSA76677 -x 'PPSA76677/screenshots/*')
shasum -a 256 build/release/PPSA76677.zip
tools/package-app.sh "$TAG"
if [[ "${2:-}" == --upload ]]; then
gh release view "$TAG" --json assets -q '.assets[].name' | grep -qx PPSA76677.zip &&
die "$TAG already has PPSA76677.zip; publish a new release instead of replacing it"
gh release upload "$TAG" build/release/PPSA76677.zip
local_sum=$(shasum -a 256 build/release/PPSA76677.zip | cut -d' ' -f1)
remote_sum=$(gh release download "$TAG" -p PPSA76677.zip -O - | shasum -a 256 | cut -d' ' -f1)
[[ "$local_sum" == "$remote_sum" ]] || die "the PPSA76677.zip on $TAG isn't the one built here"
echo "$TAG has PPSA76677.zip ($local_sum). The catalog bot proposes it within 8 hours;"
echo "for sooner, open the update yourself (docs/release-checklist.md)."
fi