name: Release # Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS # IPA, a Nintendo Switch SD-ready zip (experimental), Xbox UWP, the Anbernic # RG34XXSP (Stock OS 64-bit MOD / PortMaster) and Linux ARM SBC PortMaster # handheld ports 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' - 'mobile/ios/app-repo.json' 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 actions: write issues: read pull-requests: read concurrency: group: release cancel-in-progress: false jobs: version: name: determine release version runs-on: ubuntu-latest outputs: version: ${{ steps.ver.outputs.version }} tag: ${{ steps.ver.outputs.tag }} steps: - uses: actions/checkout@v7 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" love-payload: name: build release game.love needs: version runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Build shared payload run: | scripts/pack_love.sh \ --output dist/payload/game.love \ --listing dist/payload/love-listing.txt \ --version "${{ needs.version.outputs.version }}" - name: Upload shared payload uses: actions/upload-artifact@v7 with: name: gen1recomp-release-love path: dist/payload/game.love if-no-files-found: error retention-days: 7 linux-arm64: name: build Linux arm64 AppImage needs: [version, love-payload, shaderfx-bridge] # GitHub's free arm64 runner for public repos. It has to be arm64: the # AppImage compiles LÖVE natively inside a Debian bullseye arm64 # container, and the qemu-emulated alternative takes hours. runs-on: ubuntu-24.04-arm steps: - uses: actions/checkout@v7 - name: Download shared payload uses: actions/download-artifact@v8 with: name: gen1recomp-release-love path: .bazinga/work - name: Download the ShaderFX bridge uses: actions/download-artifact@v8 with: name: shaderfx-bridge-linux-arm64 path: dist/native/linux-arm64 - name: Restore compiled arm64 dependencies uses: actions/cache@v4 with: path: .bazinga/cache/linux-arm64 key: linux-arm64-deps-${{ hashFiles('scripts/linux-arm64/**', 'scripts/build_linux_arm64.sh') }} - name: Build Linux arm64 AppImage env: SHADERFX_BRIDGE_REQUIRED: "1" run: | set -euo pipefail scripts/build_linux_arm64.sh \ --version "${{ needs.version.outputs.version }}" \ --game-love .bazinga/work/game.love - name: Verify the AppImage is self-contained and bullseye-compatible env: SHADERFX_BRIDGE_REQUIRED: "1" run: bash scripts/linux-arm64/verify_appimage.sh "dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage" - name: Upload Linux arm64 release uses: actions/upload-artifact@v7 with: name: gen1recomp-linux-arm64-release path: | dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage.sha256 if-no-files-found: error retention-days: 7 linux-flatpak: name: build Linux Flatpak needs: [version, love-payload, shaderfx-bridge] runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 - name: Install flatpak-builder deps run: | set -euo pipefail sudo apt-get update sudo apt-get install -y flatpak flatpak-builder elfutils imagemagick - name: Download shared payload uses: actions/download-artifact@v8 with: name: gen1recomp-release-love path: .bazinga/work - name: Download the ShaderFX bridge uses: actions/download-artifact@v8 with: name: shaderfx-bridge-linux-x64 path: dist/native/linux-x64 - name: Restore the Flatpak runtime uses: actions/cache@v4 with: path: ~/.local/share/flatpak key: flatpak-runtime-${{ runner.arch }}-${{ hashFiles('flatpak/*.yml', 'scripts/build_flatpak.sh') }} - name: Build Flatpak bundle env: SHADERFX_BRIDGE_REQUIRED: "1" run: | set -euo pipefail scripts/build_flatpak.sh \ --version "${{ needs.version.outputs.version }}" \ --game-love .bazinga/work/game.love - name: Upload Flatpak release uses: actions/upload-artifact@v7 with: name: gen1recomp-linux-flatpak-release path: | dist/flatpak/gen1recomp-${{ needs.version.outputs.version }}-linux.flatpak dist/flatpak/gen1recomp-${{ needs.version.outputs.version }}-linux.flatpak.sha256 if-no-files-found: error retention-days: 7 xbox-uwp: name: build Xbox UWP release needs: [version, love-payload] runs-on: windows-2022 steps: - uses: actions/checkout@v7 - name: Download shared payload uses: actions/download-artifact@v8 with: name: gen1recomp-release-love path: .bazinga/work - name: Prepare signing certificate shell: pwsh env: CERTIFICATE_BASE64: ${{ secrets.XBOX_UWP_SIGNING_CERTIFICATE }} CERTIFICATE_PASSWORD: ${{ secrets.XBOX_UWP_SIGNING_PASSWORD }} CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }} run: | if ($env:CANONICAL_REPOSITORY -eq 'true' -and [string]::IsNullOrWhiteSpace($env:CERTIFICATE_BASE64)) { throw 'XBOX_UWP_SIGNING_CERTIFICATE is not configured.' } if ([string]::IsNullOrWhiteSpace($env:CERTIFICATE_BASE64)) { "UWP_PUBLISHER=CN=Gen1Recomp" | Out-File $env:GITHUB_ENV -Append exit 0 } $pfx = Join-Path $env:RUNNER_TEMP 'gen1recomp-uwp.pfx' [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:CERTIFICATE_BASE64)) $flags = [Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet $cert = [Security.Cryptography.X509Certificates.X509Certificate2]::new( $pfx, $env:CERTIFICATE_PASSWORD, $flags) $cer = Join-Path $env:RUNNER_TEMP 'gen1recomp-uwp.cer' [IO.File]::WriteAllBytes( $cer, $cert.Export([Security.Cryptography.X509Certificates.X509ContentType]::Cert)) Import-Certificate -FilePath $cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople | Out-Null "UWP_PFX=$pfx" | Out-File $env:GITHUB_ENV -Append "UWP_CERT_THUMBPRINT=$($cert.Thumbprint)" | Out-File $env:GITHUB_ENV -Append "UWP_PUBLISHER=$($cert.Subject)" | Out-File $env:GITHUB_ENV -Append - name: Build Xbox UWP package shell: bash run: | bash scripts/build_xbox_uwp.sh \ --release \ --version "${{ needs.version.outputs.version }}" \ --publisher "$UWP_PUBLISHER" \ --game-love .bazinga/work/game.love - name: Sign and stage Xbox UWP release shell: pwsh env: CERTIFICATE_PASSWORD: ${{ secrets.XBOX_UWP_SIGNING_PASSWORD }} run: | if (-not $env:UWP_PFX) { exit 0 } scripts/xbox-uwp/stage_release.ps1 ` -Version '${{ needs.version.outputs.version }}' ` -Configuration Release ` -BuildInfo .bazinga/work/xbox-uwp-build-info.json ` -CertificatePath $env:UWP_PFX ` -CertificatePassword $env:CERTIFICATE_PASSWORD - name: Upload Xbox UWP release uses: actions/upload-artifact@v7 with: name: gen1recomp-xbox-uwp-release path: | dist/xbox-uwp/gen1recomp-${{ needs.version.outputs.version }}-xbox-uwp.zip dist/xbox-uwp/gen1recomp-${{ needs.version.outputs.version }}-xbox-uwp.zip.sha256 if-no-files-found: error retention-days: 7 - name: Remove signing certificate if: always() shell: pwsh run: | if ($env:UWP_CERT_THUMBPRINT) { Remove-Item "Cert:\LocalMachine\TrustedPeople\$env:UWP_CERT_THUMBPRINT" -ErrorAction SilentlyContinue } if ($env:UWP_PFX) { Remove-Item $env:UWP_PFX -Force -ErrorAction SilentlyContinue } # Windows Native AOT TLS dialer. The Mac release runner fuses the win64 zip # from LÖVE's prebuilt binaries and cannot cross-compile this DLL, so build # it here and inject it in the release job before scripts/build.sh win. native-tls-win: name: build Windows gen1tls.dll needs: version runs-on: windows-2022 steps: - uses: actions/checkout@v7 - name: Identify TLS build environment id: tls-env shell: pwsh run: | "image=$env:ImageOS-$env:ImageVersion" >> $env:GITHUB_OUTPUT # Exact keys only: a partial match could ship an outdated native library. # Hash the build script too so changes to build flags invalidate the binary. - name: Restore finished TLS library id: tls-cache uses: actions/cache/restore@v4 with: path: dist/native/win-x64/gen1tls.dll key: native-tls-v2-${{ runner.os }}-${{ runner.arch }}-${{ steps.tls-env.outputs.image }}-dotnet8-${{ hashFiles('native/tls_dial/**', 'scripts/ci/build_gen1tls.ps1', '**/Directory.Build.*', '**/Directory.Packages.props', '**/NuGet.Config', '**/nuget.config', '**/global.json') }} - name: Setup .NET 8 if: steps.tls-cache.outputs.cache-hit != 'true' uses: actions/setup-dotnet@v6 with: dotnet-version: "8.0.x" - name: Publish gen1tls (win-x64 Native AOT) if: steps.tls-cache.outputs.cache-hit != 'true' shell: pwsh run: ./scripts/ci/build_gen1tls.ps1 - name: Verify TLS library shell: pwsh run: | $dll = Get-Item 'dist/native/win-x64/gen1tls.dll' -ErrorAction Stop if ($dll.Length -eq 0) { throw 'gen1tls.dll is empty' } - name: Cache finished TLS library if: steps.tls-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: dist/native/win-x64/gen1tls.dll key: ${{ steps.tls-cache.outputs.cache-primary-key }} - name: Upload gen1tls.dll uses: actions/upload-artifact@v7 with: name: gen1tls-win-x64 path: dist/native/win-x64/gen1tls.dll if-no-files-found: error retention-days: 7 shaderfx-bridge: name: build ShaderFX bridge (${{ matrix.plat }}) needs: version strategy: fail-fast: false matrix: include: - plat: win-x64 runs-on: windows-2022 lib: librashader_bridge.dll - plat: mac runs-on: macos-latest lib: liblibrashader_bridge.dylib - plat: linux-x64 runs-on: ubuntu-24.04 lib: liblibrashader_bridge.so glibc_triple: x86_64-unknown-linux-gnu.2.17 - plat: linux-arm64 runs-on: ubuntu-24.04-arm lib: liblibrashader_bridge.so glibc_triple: aarch64-unknown-linux-gnu.2.17 # Built here rather than on the packaging runner so the APK's bridge # does not depend on that machine's local Rust/NDK toolchain (#1932). - plat: android runs-on: ubuntu-24.04 lib: liblibrashader_bridge.so - plat: ios runs-on: macos-latest lib: liblibrashader_bridge.a runs-on: ${{ matrix.runs-on }} steps: - uses: actions/checkout@v7 - name: Identify bridge build environment id: bridge-env shell: bash env: PLAT: ${{ matrix.plat }} run: bash scripts/ci/shaderfx_bridge.sh env # Keep release versions and game sources out of this key. Only bridge # inputs, build instructions, and the native environment affect reuse. - name: Restore finished bridge id: bridge-cache uses: actions/cache/restore@v4 with: path: dist/native/${{ matrix.plat }} key: shaderfx-binary-v2-${{ matrix.plat }}-${{ runner.arch }}-${{ matrix.glibc_triple }}-${{ steps.bridge-env.outputs.hash }}-${{ hashFiles('tools/shaderfx-bridge/**', '.cargo/**', 'rust-toolchain*', 'scripts/ci/shaderfx_bridge.sh') }} - name: Setup Rust if: steps.bridge-cache.outputs.cache-hit != 'true' uses: dtolnay/rust-toolchain@stable - name: Cache cargo registry and build dir if: steps.bridge-cache.outputs.cache-hit != 'true' uses: Swatinem/rust-cache@v2 with: workspaces: tools/shaderfx-bridge key: ${{ matrix.plat }}-${{ matrix.glibc_triple }} - name: Build the bridge if: steps.bridge-cache.outputs.cache-hit != 'true' shell: bash env: PLAT: ${{ matrix.plat }} LIB: ${{ matrix.lib }} GLIBC_TRIPLE: ${{ matrix.glibc_triple }} run: bash scripts/ci/shaderfx_bridge.sh build - name: Verify bridge libraries shell: bash env: PLAT: ${{ matrix.plat }} LIB: ${{ matrix.lib }} run: bash scripts/ci/shaderfx_bridge.sh verify - name: Cache finished bridge if: steps.bridge-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: dist/native/${{ matrix.plat }} key: ${{ steps.bridge-cache.outputs.cache-primary-key }} - name: Upload the bridge uses: actions/upload-artifact@v7 with: name: shaderfx-bridge-${{ matrix.plat }} path: dist/native/${{ matrix.plat }} if-no-files-found: error retention-days: 7 desktop: name: build macOS + Linux needs: [version, love-payload, shaderfx-bridge] runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }} concurrency: group: mac-signing-keychain cancel-in-progress: false steps: # The self-hosted runner lives under the machine owner's home # directory; mask it first so absolute paths in every later step's # output show up as *** in the public workflow logs. - name: Mask runner paths run: echo "::add-mask::$HOME" - name: Checkout uses: actions/checkout@v7 - name: Download ShaderFX bridge libraries uses: actions/download-artifact@v8 with: pattern: shaderfx-bridge-* path: dist/native - name: Flatten the ShaderFX bridge artifact layout run: | set -euo pipefail shopt -s nullglob for d in dist/native/shaderfx-bridge-*/; do d="${d%/}" plat="${d#dist/native/shaderfx-bridge-}" mkdir -p "dist/native/$plat" for f in "$d"/*; do mv "$f" "dist/native/$plat/"; done rmdir "$d" done for plat in mac win-x64 linux-x64 linux-arm64 android; do ls "dist/native/$plat" >/dev/null \ || { echo "::error::no ShaderFX bridge staged for $plat"; exit 1; } done ls -lR dist/native # The same game.love the arm64 AppImage and Xbox UWP builds fused, so # every release asset ships one identical payload (build.sh's own pack # would omit PATCH_NOTES.md and mobile/ios/app-repo.json). - name: Download shared payload uses: actions/download-artifact@v8 with: name: gen1recomp-release-love path: dist/payload - name: Import signing certificate into a temporary keychain if: github.repository == 'bryanthaboi/gen1recomp' uses: ./.github/actions/mac-signing-keychain - name: Build macOS + Linux env: SHADERFX_BRIDGE_REQUIRED: "1" run: | set -euo pipefail scripts/build.sh mac --version "${{ needs.version.outputs.version }}" --no-notarize \ --game-love dist/payload/game.love scripts/build.sh linux --version "${{ needs.version.outputs.version }}" \ --game-love dist/payload/game.love - name: Notarize & staple macOS app if: github.repository == 'bryanthaboi/gen1recomp' 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: Upload gen1recomp-desktop-release uses: actions/upload-artifact@v7 with: name: gen1recomp-desktop-release path: | dist/mac/gen1recomp-macos.zip dist/linux/gen1recomp-linux-x86_64.AppImage if-no-files-found: error retention-days: 7 - name: Clean up signing keychain if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }} run: security delete-keychain "$RUNNER_TEMP/pokemon-signing.keychain-db" 2>/dev/null || true android: name: build Android needs: [version, shaderfx-bridge] runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }} steps: # The self-hosted runner lives under the machine owner's home # directory; mask it first so absolute paths in every later step's # output show up as *** in the public workflow logs. - name: Mask runner paths run: echo "::add-mask::$HOME" - name: Checkout uses: actions/checkout@v7 - name: Download the Android ShaderFX bridge uses: actions/download-artifact@v8 with: name: shaderfx-bridge-android path: dist/native/android - name: Materialize Android release signing key env: KEYSTORE_B64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_B64 }} run: | set -euo pipefail [ -n "$KEYSTORE_B64" ] || { echo "::error::ANDROID_RELEASE_KEYSTORE_B64 is required for a publishable Android update" exit 1 } python3 - <<'PY' import base64, os, pathlib encoded = os.environ["KEYSTORE_B64"] path = pathlib.Path(os.environ["RUNNER_TEMP"]) / "gen1recomp-android-release.keystore" path.write_bytes(base64.b64decode(encoded, validate=True)) PY - name: Build Android env: SHADERFX_BRIDGE_REQUIRED: "1" SHADERFX_BRIDGE_ANDROID_DIR: ${{ github.workspace }}/dist/native/android GEN1RECOMP_ANDROID_KEYSTORE: ${{ runner.temp }}/gen1recomp-android-release.keystore GEN1RECOMP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD }} GEN1RECOMP_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_RELEASE_KEY_ALIAS }} GEN1RECOMP_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_RELEASE_KEY_PASSWORD }} run: | set -euo pipefail export GEN1_ANDROID_SHADOW_DIR="$HOME/.cache/gen1recomp-ci/android-shadow" scripts/build_android.sh --release --version "${{ needs.version.outputs.version }}" - name: Upload gen1recomp-android-release uses: actions/upload-artifact@v7 with: name: gen1recomp-android-release path: | dist/android/release/**/*.apk if-no-files-found: error retention-days: 7 ios: name: build iOS needs: [version, shaderfx-bridge] runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }} concurrency: group: mac-signing-keychain cancel-in-progress: false steps: # The self-hosted runner lives under the machine owner's home # directory; mask it first so absolute paths in every later step's # output show up as *** in the public workflow logs. - name: Mask runner paths run: echo "::add-mask::$HOME" - name: Checkout uses: actions/checkout@v7 - name: Import signing certificate into a temporary keychain if: github.repository == 'bryanthaboi/gen1recomp' uses: ./.github/actions/mac-signing-keychain - name: Download the iOS ShaderFX bridge uses: actions/download-artifact@v8 with: name: shaderfx-bridge-ios path: dist/native/ios - name: Install xcbeautify run: | set -euo pipefail brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify - name: Build iOS env: CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }} SHADERFX_BRIDGE_IOS: ${{ github.workspace }}/dist/native/ios/liblibrashader_bridge.a run: | set -euo pipefail if [ "$CANONICAL_REPOSITORY" = true ]; then scripts/build_ios.sh --fetch --device --release \ --version "${{ needs.version.outputs.version }}" else scripts/build_ios.sh --fetch --release \ --version "${{ needs.version.outputs.version }}" fi - name: Upload gen1recomp-ios-release uses: actions/upload-artifact@v7 with: name: gen1recomp-ios-release path: | dist/ios/gen1recomp++.ipa if-no-files-found: error retention-days: 7 - name: Clean up signing keychain if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }} run: security delete-keychain "$RUNNER_TEMP/pokemon-signing.keychain-db" 2>/dev/null || true switch: name: build Switch needs: version runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }} steps: # The self-hosted runner lives under the machine owner's home # directory; mask it first so absolute paths in every later step's # output show up as *** in the public workflow logs. - name: Mask runner paths run: echo "::add-mask::$HOME" - name: Checkout uses: actions/checkout@v7 - name: Restore pinned love-nx binaries uses: actions/cache@v4 with: path: .bazinga/love-nx/11.5-nx1 key: love-nx-11.5-nx1-${{ hashFiles('scripts/switch/love-nx-11.5-nx1.sha256') }} - name: Build Switch run: | set -euo pipefail # Hard-fail gate: Switch ships with every release (never soft-fail). # PR CI is path-gated (ubuntu selftest + canonical fused); release # always builds Switch regardless of which files changed. # Needs native switch-tools (nacptool/elf2nro) and/or Docker on the # Mac self-hosted runner; see docs/switch-build.md. scripts/build_switch.sh --fetch --fused \ --version "${{ needs.version.outputs.version }}" - name: Upload gen1recomp-switch-release uses: actions/upload-artifact@v7 with: name: gen1recomp-switch-release path: | dist/switch/gen1recomp-${{ needs.version.outputs.version }}-switch.zip if-no-files-found: error retention-days: 7 rg34xxsp: name: build Anbernic RG34XXSP port needs: [version, shaderfx-bridge] runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }} steps: # The self-hosted runner lives under the machine owner's home # directory; mask it first so absolute paths in every later step's # output show up as *** in the public workflow logs. - name: Mask runner paths run: echo "::add-mask::$HOME" - name: Checkout uses: actions/checkout@v7 - name: Download the Linux arm64 ShaderFX bridge uses: actions/download-artifact@v8 with: name: shaderfx-bridge-linux-arm64 path: dist/native/linux-arm64 - name: Build Anbernic RG34XXSP port env: SHADERFX_BRIDGE_REQUIRED: "1" 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 "${{ needs.version.outputs.version }}" - name: Upload gen1recomp-rg34xxsp-release uses: actions/upload-artifact@v7 with: name: gen1recomp-rg34xxsp-release path: | dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip if-no-files-found: error retention-days: 7 linux-arm-sbc: name: build Linux ARM SBC PortMaster port needs: [version, shaderfx-bridge] runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }} steps: # The self-hosted runner lives under the machine owner's home # directory; mask it first so absolute paths in every later step's # output show up as *** in the public workflow logs. - name: Mask runner paths run: echo "::add-mask::$HOME" - name: Checkout uses: actions/checkout@v7 - name: Download the Linux arm64 ShaderFX bridge uses: actions/download-artifact@v8 with: name: shaderfx-bridge-linux-arm64 path: dist/native/linux-arm64 - name: Build Linux ARM SBC PortMaster port env: # The release workflow must package the commit being released. The # script defaults to the latest published release for standalone # builds, while this explicit local override keeps CI source-aligned. GEN1RECOMP_SOURCE_DIR: ${{ github.workspace }} GEN1RECOMP_RELEASE_TAG: v${{ needs.version.outputs.version }} SHADERFX_BRIDGE_REQUIRED: "1" run: | set -euo pipefail # Same aarch64 PortMaster-style pack for Linux ARM SBC PortMaster. The build # keeps its own cache because the two scripts use different staging # layouts and runtime package paths. ./build-linux-arm-sbc.sh --version "${{ needs.version.outputs.version }}" - name: Upload gen1recomp-linux-arm-sbc-release uses: actions/upload-artifact@v7 with: name: gen1recomp-linux-arm-sbc-release path: | dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip if-no-files-found: error retention-days: 7 notes: name: write release notes needs: version if: github.repository == 'bryanthaboi/gen1recomp' runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v7 with: fetch-depth: 0 fetch-tags: true - name: Write release notes env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail tag="${{ needs.version.outputs.tag }}" # Issues this release closes. Three sources, deduped by number: # 1. GitHub's own "closing issues" links on every PR whose # commits are in the range (works for squash, rebase, and # merge commits alike) -- including PRs that were merged # into a branch this release PR is itself merging in. # 2. CLOSES/fixes/resolves text in those PRs' titles + bodies # that GitHub didn't turn into a closing link (a PR into a # non-default branch never gets one). # 3. The same text in raw commit messages, so a direct push # with "CLOSES #N #M" still lands in the notes. # 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" # every #N on a line that carries a closing keyword (handles the # multi-issue "CLOSES #1 #2 #3" form the tracker uses) scan_closes() { grep -iE '\b(close[sd]?|fix(es|ed)?|resolve[sd]?)\b' \ | grep -oE '#[0-9]+' | tr -d '#' || true } nums="" nums+=" $(git log --pretty=%B "$range" | scan_closes | tr '\n' ' ')" prs="$(git log --pretty=%H "$range" \ | xargs -I{} gh api "repos/$GITHUB_REPOSITORY/commits/{}/pulls" \ --jq '.[].number' 2>/dev/null \ | sort -un || true)" for pr in $prs; do nums+=" $(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 } } } } }' \ --jq '.data.repository.pullRequest.closingIssuesReferences.nodes[].number' \ 2>/dev/null | grep -E '^[0-9]+$' | tr '\n' ' ' || true)" nums+=" $(gh api "repos/$GITHUB_REPOSITORY/pulls/$pr" \ --jq '.title + " " + (.body // "")' 2>/dev/null \ | scan_closes | tr '\n' ' ' || true)" done # dedupe, drop anything that is a PR or does not exist, keep # titles (gh api prints the response body to stdout on an HTTP # error, so only a zero exit counts) closed="" for n in $(printf '%s' "$nums" | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -un); do if title="$(gh api "repos/$GITHUB_REPOSITORY/issues/$n" \ --jq 'if .pull_request then empty else .title end' \ 2>/dev/null)"; then [ -n "$title" ] && closed+="- #$n $title"$'\n' fi done closed="$(printf '%s' "$closed" | grep . | sort -t'#' -k2 -n || true)" # Everyone whose commits are in the range: GitHub login when the # commit is linked to an account, the raw git author name when not; # CI bots filtered out. if [ -n "$prev_tag" ]; then commits_endpoint="repos/$GITHUB_REPOSITORY/compare/${prev_tag}...${GITHUB_SHA}?per_page=100" else # No previous release: include all reachable commits, even the root. commits_endpoint="repos/$GITHUB_REPOSITORY/commits?sha=${GITHUB_SHA}&per_page=100" fi contributors="$(gh api --paginate \ "$commits_endpoint" \ --jq '(if type == "array" then . else .commits end)[] | if .author and .author.login then "@" + .author.login else .commit.author.name end' 2>/dev/null \ | grep -viE '\[bot\]$' | sort -uf | sed 's/^/- /' || true)" # Use the dev -> main promotion PR for this release commit, not an # individual feature PR whose commits also happen to be in the range. promotion_body="$(gh api --paginate --slurp \ "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls?per_page=100" 2>/dev/null \ | jq -r --arg repo "$GITHUB_REPOSITORY" --arg sha "$GITHUB_SHA" ' [.[][] | select(.merged_at != null and .base.repo.full_name == $repo and .base.ref == "main" and .head.repo.full_name == $repo and .head.ref == "dev" and .merge_commit_sha == $sha)] | sort_by(.merged_at) | last | .body // ""' || true)" notes="Download the right version for your device below." if [ -n "$(printf '%s' "$promotion_body" | tr -d '[:space:]')" ]; then notes="$promotion_body" fi if [ -n "$closed" ]; then notes+=$'\n\n## Issues closed\n\n'"$closed" fi if [ -n "$contributors" ]; then notes+=$'\n\n## Contributors\n\n'"$contributors" fi printf 'Release notes:\n%s\n' "$notes" mkdir -p dist/release-notes printf '%s\n' "$notes" > dist/release-notes/release-notes.md - name: Upload release notes uses: actions/upload-artifact@v7 with: name: gen1recomp-release-notes path: dist/release-notes/release-notes.md if-no-files-found: error retention-days: 7 windows-sign: name: build and sign Windows (separate run) needs: [version, love-payload, native-tls-win, shaderfx-bridge] if: github.repository == 'bryanthaboi/gen1recomp' runs-on: ubuntu-latest timeout-minutes: 100 steps: - name: Start the Windows signing workflow id: dispatch env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail nonce="${{ github.run_id }}-${{ github.run_attempt }}" title="windows ${{ needs.version.outputs.version }} ($nonce)" gh workflow run windows-sign.yml -R "$GITHUB_REPOSITORY" --ref "$GITHUB_REF_NAME" \ -f version="${{ needs.version.outputs.version }}" \ -f sha="$GITHUB_SHA" \ -f source_run="${{ github.run_id }}" \ -f nonce="$nonce" id="" for _ in $(seq 1 60); do id="$(gh run list -R "$GITHUB_REPOSITORY" --workflow windows-sign.yml --event workflow_dispatch -L 30 \ --json databaseId,displayTitle --jq ".[] | select(.displayTitle == \"$title\") | .databaseId" | head -1)" [ -n "$id" ] && break sleep 5 done [ -n "$id" ] || { echo "::error::the Windows signing run never showed up"; exit 1; } echo "Windows signing run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$id" echo "run-id=$id" >> "$GITHUB_OUTPUT" gh run watch "$id" -R "$GITHUB_REPOSITORY" --exit-status --interval 15 - name: Download the signed Windows build uses: actions/download-artifact@v8 with: name: gen1recomp-windows-signed path: dist/win run-id: ${{ steps.dispatch.outputs.run-id }} github-token: ${{ github.token }} - name: Check the signed Windows zip run: | set -euo pipefail unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1recomp.exe unzip -l dist/win/gen1recomp-win64.zip | grep -F gen1tls.dll unzip -l dist/win/gen1recomp-win64.zip | grep -F librashader_bridge.dll - name: Upload gen1recomp-windows-signed uses: actions/upload-artifact@v7 with: name: gen1recomp-windows-signed path: dist/win/gen1recomp-win64.zip if-no-files-found: error retention-days: 7 release: needs: [version, notes, love-payload, xbox-uwp, linux-arm64, linux-flatpak, desktop, windows-sign, android, ios, switch, rg34xxsp, linux-arm-sbc] if: github.repository == 'bryanthaboi/gen1recomp' runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v7 with: fetch-depth: 0 fetch-tags: true - name: Download gen1recomp-release-notes uses: actions/download-artifact@v8 with: name: gen1recomp-release-notes path: dist/release-notes - name: Download gen1recomp-release-love uses: actions/download-artifact@v8 with: name: gen1recomp-release-love path: dist/payload - name: Download gen1recomp-desktop-release uses: actions/download-artifact@v8 with: name: gen1recomp-desktop-release path: dist - name: Download gen1recomp-windows-signed uses: actions/download-artifact@v8 with: name: gen1recomp-windows-signed path: dist/win - name: Download gen1recomp-android-release uses: actions/download-artifact@v8 with: name: gen1recomp-android-release path: dist/android/release - name: Download gen1recomp-ios-release uses: actions/download-artifact@v8 with: name: gen1recomp-ios-release path: dist/ios - name: Download gen1recomp-switch-release uses: actions/download-artifact@v8 with: name: gen1recomp-switch-release path: dist/switch - name: Download gen1recomp-rg34xxsp-release uses: actions/download-artifact@v8 with: name: gen1recomp-rg34xxsp-release path: dist/rg34xxsp - name: Download gen1recomp-linux-arm-sbc-release uses: actions/download-artifact@v8 with: name: gen1recomp-linux-arm-sbc-release path: dist/linux-arm-sbc - name: Download gen1recomp-xbox-uwp-release uses: actions/download-artifact@v8 with: name: gen1recomp-xbox-uwp-release path: dist/xbox-uwp - name: Download gen1recomp-linux-arm64-release uses: actions/download-artifact@v8 with: name: gen1recomp-linux-arm64-release path: dist/linux-arm64 - name: Download gen1recomp-linux-flatpak-release uses: actions/download-artifact@v8 with: name: gen1recomp-linux-flatpak-release path: dist/flatpak - name: Stage release assets if: github.repository == 'bryanthaboi/gen1recomp' id: assets run: | set -euo pipefail v="${{ needs.version.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" # x86_64 desktop Linux: raw AppImage (no zip wrapper). x64_appimage="dist/linux/gen1recomp-linux-x86_64.AppImage" [ -f "$x64_appimage" ] || { echo "::error::$x64_appimage not found (expected from scripts/build.sh linux)"; exit 1; } cp "$x64_appimage" "$outdir/gen1recomp-${v}-linux-x86_64.AppImage" chmod +x "$outdir/gen1recomp-${v}-linux-x86_64.AppImage" # arm64 desktop Linux (Raspberry Pi, Armbian, arm64 VMs). Built on # its own runner because LÖVE publishes no aarch64 binary and the # AppImage has to be compiled natively; ships as a runnable # AppImage so `chmod +x && ./it` just works. arm64_appimage="dist/linux-arm64/gen1recomp-${v}-linux-arm64.AppImage" [ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; } cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage" chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage" flatpak_bundle="dist/flatpak/gen1recomp-${v}-linux.flatpak" [ -f "$flatpak_bundle" ] || { echo "::error::$flatpak_bundle not found (expected from the linux-flatpak job)"; exit 1; } cp "$flatpak_bundle" "$outdir/gen1recomp-${v}-linux.flatpak" apk="$(find dist/android/release -name '*.apk' | head -1)" [ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/release"; exit 1; } cp "$apk" "$outdir/gen1recomp-${v}-android.apk" ipa="dist/ios/gen1recomp++.ipa" [ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; } cp "$ipa" "$outdir/gen1recomp++-${v}-ios.ipa" swzip="dist/switch/gen1recomp-${v}-switch.zip" [ -f "$swzip" ] || { echo "::error::$swzip not found (expected from scripts/build_switch.sh --fused → pack_sd_zip.sh)"; exit 1; } cp "$swzip" "$outdir/gen1recomp-${v}-switch.zip" # Local fused .nro stays under dist/switch/ for PR CI / debug; release # publishes the SD-ready zip only. uwp="dist/xbox-uwp/gen1recomp-${v}-xbox-uwp.zip" [ -f "$uwp" ] || { echo "::error::$uwp not found (expected from the Xbox UWP job)"; exit 1; } cp "$uwp" "$outdir/gen1recomp-${v}-xbox-uwp.zip" # 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" # Linux ARM SBC PortMaster handheld port. sbc="dist/linux-arm-sbc/gen1recomp-sbc-portmaster.zip" [ -f "$sbc" ] || { echo "::error::$sbc not found (expected from ./build-linux-arm-sbc.sh)"; exit 1; } cp "$sbc" "$outdir/gen1recomp-${v}-sbc-portmaster.zip" # Platform-independent update payload, built alongside the desktop # apps above (same game.love that gets fused into each of them). love_file="dist/payload/game.love" [ -f "$love_file" ] || { echo "::error::$love_file not found (expected from the love-payload job)"; 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 if: github.repository == 'bryanthaboi/gen1recomp' env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail v="${{ needs.version.outputs.version }}" tag="${{ needs.version.outputs.tag }}" notes_file="dist/release-notes/release-notes.md" [ -f "$notes_file" ] || { echo "::error::$notes_file not found (expected from the notes job)"; exit 1; } release_files=( "dist/release/gen1recomp-${v}-macos.zip" "dist/release/gen1recomp-${v}-windows.zip" "dist/release/gen1recomp-${v}-linux-x86_64.AppImage" "dist/release/gen1recomp-${v}-linux-arm64.AppImage" "dist/release/gen1recomp-${v}-linux.flatpak" "dist/release/gen1recomp-${v}-android.apk" "dist/release/gen1recomp++-${v}-ios.ipa" "dist/release/gen1recomp-${v}-switch.zip" "dist/release/gen1recomp-${v}-xbox-uwp.zip" "dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" "dist/release/gen1recomp-${v}-sbc-portmaster.zip" "dist/release/gen1recomp-${v}.love" "dist/release/sha256sums.txt" ) if [ "$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null || true)" = "true" ]; then gh release delete "$tag" --yes fi gh release create "$tag" \ --draft \ --target "$GITHUB_SHA" \ --title "$v" \ --notes-file "$notes_file" printf '%s\n' "${release_files[@]}" | xargs -P 6 -I{} gh release upload "$tag" {} --clobber gh release edit "$tag" --draft=false echo "Published release $tag" - name: Update iOS app repository if: github.repository == 'bryanthaboi/gen1recomp' run: | set -euo pipefail v="${{ needs.version.outputs.version }}" ipa="dist/release/gen1recomp++-${v}-ios.ipa" app_repo="mobile/ios/app-repo.json" [ -f "$ipa" ] || { echo "::error::$ipa not found"; exit 1; } [ -f "$app_repo" ] || { echo "::error::$app_repo not found"; exit 1; } date="$(date -u +"%Y-%m-%d")" size="$(wc -c < "$ipa" | tr -d '[:space:]')" download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${v}/gen1recomp++-${v}-ios.ipa" bundle_id="com.theboisclub.gen1recompplusplus" localized_description="Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke" release_notes="$(GH_TOKEN="${{ github.token }}" gh release view "v${v}" --json body --jq '.body // ""' 2>/dev/null || true)" if [ -n "$release_notes" ]; then localized_description="$release_notes" fi entry="$(jq -n \ --arg version "$v" \ --arg date "$date" \ --arg download_url "$download_url" \ --arg localized_description "$localized_description" \ --argjson size "$size" \ '{version: $version, date: $date, size: $size, downloadURL: $download_url, localizedDescription: $localized_description}')" if jq -e --arg bundle_id "$bundle_id" --arg version "$v" \ 'any(.apps[] | select(.bundleIdentifier == $bundle_id).versions[]?; .version == $version)' \ "$app_repo" >/dev/null; then jq --arg bundle_id "$bundle_id" --arg version "$v" --argjson entry "$entry" \ '(.apps[] | select(.bundleIdentifier == $bundle_id).versions) |= map(if .version == $version then $entry else . end)' \ "$app_repo" > "$app_repo.tmp" else jq --arg bundle_id "$bundle_id" --argjson entry "$entry" \ '(.apps[] | select(.bundleIdentifier == $bundle_id).versions) |= [$entry] + .' \ "$app_repo" > "$app_repo.tmp" fi mv "$app_repo.tmp" "$app_repo" # main is PR-only for everyone except deploy keys (the "main protection" # ruleset's bypass actor), so this push must authenticate with the # RELEASE_DEPLOY_KEY deploy key over SSH; the workflow's GITHUB_TOKEN # would be rejected by the branch protection. - name: Commit iOS app repository if: github.repository == 'bryanthaboi/gen1recomp' env: DEPLOY_KEY: ${{ secrets.RELEASE_DEPLOY_KEY }} run: | set -euo pipefail git add mobile/ios/app-repo.json if git diff --cached --quiet; then echo "app-repo.json unchanged; nothing to push" exit 0 fi key="$RUNNER_TEMP/release-deploy-key" printf '%s\n' "$DEPLOY_KEY" > "$key" chmod 600 "$key" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git commit -m "chore(ios): update app-repo.json [skip ci]" git -c core.sshCommand="ssh -i $key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \ push "git@github.com:${GITHUB_REPOSITORY}.git" "HEAD:${GITHUB_REF_NAME}" rm -f "$key"