name: Release # Builds the macOS, Windows, and Linux desktop apps, an Android APK, and the # Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on the self-hosted # Mac runner, and publishes them as a GitHub Release. # # Versioning: # - First ever release is 0.1.0. # - Every push to main auto-increments the patch: 0.1.0 -> 0.1.1 -> ... -> 0.1.99, # then rolls over to 0.2.0 and keeps going. # - To force a specific version, either: # * run this workflow manually (Actions tab) and type it into "version", or # * put "[release X.Y.Z]" anywhere in the commit message. # # Branch model: day-to-day work merges to `dev`. Releases stay on `main` only # so promoting `dev` -> `main` is the ship gate that cuts a build. on: push: branches: [main] # CI/workflow and docs-only changes don't ship anything to users, so they # don't earn a release. A push touching these *and* real source still # releases; only pushes confined entirely to these paths are skipped. paths-ignore: - '.github/**' - '**.md' workflow_dispatch: inputs: version: description: "Exact version to release (e.g. 0.2.0). Leave blank to auto-increment." required: false default: "" permissions: contents: write issues: read pull-requests: read concurrency: group: release cancel-in-progress: false jobs: release: runs-on: [self-hosted, macOS] steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 fetch-tags: true - name: Determine version id: ver env: DISPATCH_VERSION: ${{ github.event.inputs.version }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail semver_re='^[0-9]+\.[0-9]+\.[0-9]+$' # 1) Explicit override from a manual run. override="" if [ -n "${DISPATCH_VERSION:-}" ]; then override="$DISPATCH_VERSION" else # 2) Override from the commit message: [release X.Y.Z] msg="$(git log -1 --pretty=%B || true)" tag_ver="$(printf '%s' "$msg" | sed -n -E 's/.*\[release[[:space:]]+([0-9]+\.[0-9]+\.[0-9]+)\].*/\1/p' | head -1)" if [ -n "$tag_ver" ]; then override="$tag_ver" fi fi if [ -n "$override" ]; then if ! printf '%s' "$override" | grep -Eq "$semver_re"; then echo "::error::Invalid version override '$override' (expected X.Y.Z)" exit 1 fi version="$override" echo "Using override version: $version" else # 3) Auto-increment from the highest existing vX.Y.Z tag. latest="$(git tag -l 'v*' \ | sed -E 's/^v//' \ | grep -E "$semver_re" \ | sort -t. -k1,1n -k2,2n -k3,3n \ | tail -1 || true)" if [ -z "$latest" ]; then version="0.1.0" echo "No existing release tag; starting at $version" else major="${latest%%.*}" rest="${latest#*.}" minor="${rest%%.*}" patch="${rest##*.}" patch=$((patch + 1)) if [ "$patch" -gt 99 ]; then minor=$((minor + 1)) patch=0 fi version="${major}.${minor}.${patch}" echo "Latest was $latest; next is $version" fi fi tag="v${version}" if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then echo "::error::Tag $tag already exists. Pick a different version." exit 1 fi if gh release view "$tag" >/dev/null 2>&1; then echo "::error::Release $tag already exists. Pick a different version." exit 1 fi echo "version=$version" >> "$GITHUB_OUTPUT" echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Import signing certificate into a temporary keychain run: | set -euo pipefail KEYCHAIN_PATH="$RUNNER_TEMP/pokemon-signing.keychain-db" ci_dir="${POKEMON_CI_DIR:-$HOME/.config/pokemon-ci}" p12="$ci_dir/signing.p12" passfile="$ci_dir/signing.pass" if [ ! -f "$p12" ] || [ ! -f "$passfile" ]; then echo "::error::Signing material not found in $ci_dir. Run scripts/ci-setup-signing.sh on the runner." exit 1 fi p12pw="$(cat "$passfile")" kcpw="$(openssl rand -base64 24)" echo "::add-mask::$kcpw" # Fresh, dedicated keychain — no dependence on the login keychain/session. security delete-keychain "$KEYCHAIN_PATH" 2>/dev/null || true security create-keychain -p "$kcpw" "$KEYCHAIN_PATH" security set-keychain-settings "$KEYCHAIN_PATH" # disable auto-lock security unlock-keychain -p "$kcpw" "$KEYCHAIN_PATH" security import "$p12" -P "$p12pw" -k "$KEYCHAIN_PATH" \ -T /usr/bin/codesign -T /usr/bin/security # Let codesign use the key non-interactively. security set-key-partition-list -S apple-tool:,apple:,codesign: \ -s -k "$kcpw" "$KEYCHAIN_PATH" >/dev/null # Make the keychain visible to find-identity/codesign (prepend to search list). existing="$(security list-keychains -d user | sed -e 's/^[[:space:]]*//' -e 's/"//g')" security list-keychains -d user -s "$KEYCHAIN_PATH" $existing echo "Identities available to codesign:" security find-identity -v -p codesigning "$KEYCHAIN_PATH" - name: Build macOS + Windows + Linux run: | set -euo pipefail # Sign in-build (identity auto-detected from the temp keychain); # notarize separately below so it uses secret credentials, not a # login-keychain profile. "all" also builds the Linux AppImage, # which needs no signing/notarization. scripts/build.sh all --version "${{ steps.ver.outputs.version }}" --no-notarize - name: Build Android run: | set -euo pipefail scripts/build_android.sh --version "${{ steps.ver.outputs.version }}" - name: Build Anbernic RG34XXSP port run: | set -euo pipefail # Self-contained aarch64 PortMaster-style pack; pulls the LÖVE 11.5 # runtime from PortMaster-GUI, so it needs no signing/notarization. ./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}" - name: Notarize & staple macOS app run: | set -euo pipefail ci_dir="${POKEMON_CI_DIR:-$HOME/.config/pokemon-ci}" if [ ! -f "$ci_dir/notary.env" ]; then echo "::error::Missing $ci_dir/notary.env. Run scripts/ci-setup-signing.sh on the runner." exit 1 fi set -a; . "$ci_dir/notary.env"; set +a echo "::add-mask::$APPLE_APP_PASSWORD" app=".bazinga/work/gen1recomp.app" zip="dist/mac/gen1recomp-macos.zip" [ -d "$app" ] || { echo "::error::signed app not found at $app"; exit 1; } if [ -z "${APPLE_ID:-}" ] || [ -z "${APPLE_APP_PASSWORD:-}" ] || [ -z "${APPLE_TEAM_ID:-}" ]; then echo "::error::notary.env is missing APPLE_ID / APPLE_APP_PASSWORD / APPLE_TEAM_ID." exit 1 fi echo "Submitting to Apple notary service (can take a few minutes)..." xcrun notarytool submit "$zip" \ --apple-id "$APPLE_ID" \ --team-id "$APPLE_TEAM_ID" \ --password "$APPLE_APP_PASSWORD" \ --wait echo "Stapling ticket to the app..." xcrun stapler staple "$app" # Re-zip the now-stapled app (same format build.sh uses). rm -f "$zip" ditto -c -k --sequesterRsrc --keepParent "$app" "$zip" echo "Notarized + stapled ✓" - name: Stage release assets id: assets run: | set -euo pipefail v="${{ steps.ver.outputs.version }}" outdir="dist/release" rm -rf "$outdir" mkdir -p "$outdir" cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip" cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip" cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip" apk="$(find dist/android/debug -name '*.apk' | head -1)" [ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; } cp "$apk" "$outdir/gen1recomp-${v}-android.apk" # Anbernic handheld port (suffix names the CFW it targets, so a # future RG35XX/other-CFW pack can ship alongside it). rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip" [ -f "$rg34" ] || { echo "::error::$rg34 not found (expected from ./build-rg34xxsp.sh)"; exit 1; } cp "$rg34" "$outdir/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" # Platform-independent update payload, built alongside the desktop # apps above (same game.love that gets fused into each of them). love_file=".bazinga/work/game.love" [ -f "$love_file" ] || { echo "::error::$love_file not found (expected from scripts/build.sh)"; exit 1; } cp "$love_file" "$outdir/gen1recomp-${v}.love" ls -lh "$outdir" # Checksums for every staged release asset (sums file itself is # written after this and named outside the gen1recomp-* glob, so it # never lists itself). (cd "$outdir" && shasum -a 256 gen1recomp-* > sha256sums.txt) cat "$outdir/sha256sums.txt" - name: Publish GitHub Release env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail v="${{ steps.ver.outputs.version }}" tag="${{ steps.ver.outputs.tag }}" # Issues this release closes, per GitHub's own "closing issues" links # (works for squash, rebase, and merge commits alike). Scans every # commit since the previous tag so a skipped release run doesn't drop # issues on the floor. prev_tag="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${tag}$" | head -1 || true)" range="${prev_tag:+${prev_tag}..}$GITHUB_SHA" closed="" for pr in $(git log --pretty=%H "$range" \ | xargs -I{} gh api "repos/$GITHUB_REPOSITORY/commits/{}/pulls" \ --jq '.[].number' 2>/dev/null \ | sort -un || true); do closed+="$(gh api graphql \ -f owner="${GITHUB_REPOSITORY%/*}" \ -f name="${GITHUB_REPOSITORY#*/}" \ -F pr="$pr" \ -f query=' query($owner:String!, $name:String!, $pr:Int!) { repository(owner:$owner, name:$name) { pullRequest(number:$pr) { closingIssuesReferences(first:50) { nodes { number title } } } } }' \ --jq '.data.repository.pullRequest.closingIssuesReferences.nodes[] | "- #\(.number) \(.title)"' 2>/dev/null || true)"$'\n' done closed="$(printf '%s' "$closed" | grep . | sort -t'#' -k2 -n || true)" notes="Download the correct version for your computer below." if [ -n "$closed" ]; then notes+=$'\n\n## Issues closed\n\n'"$closed" fi printf 'Release notes:\n%s\n' "$notes" gh release create "$tag" \ --target "$GITHUB_SHA" \ --title "$v" \ --notes "$notes" \ "dist/release/gen1recomp-${v}-macos.zip" \ "dist/release/gen1recomp-${v}-windows.zip" \ "dist/release/gen1recomp-${v}-linux.zip" \ "dist/release/gen1recomp-${v}-android.apk" \ "dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \ "dist/release/gen1recomp-${v}.love" \ "dist/release/sha256sums.txt" echo "Published release $tag" - name: Clean up signing keychain if: always() run: | security delete-keychain "$RUNNER_TEMP/pokemon-signing.keychain-db" 2>/dev/null || true