name: Release # Three publication paths share one build matrix: # push tag v* -> the `release` job: a real, permanent GitHub release # push to develop -> the `nightly` job, tag `nightly`, after a 10 minute # debounce (see the `guard` job) so a burst of merges # builds once, for the last one # push to release/** -> the same job, tag `prerelease` (issue #507) # The build/vita-build/switch-build jobs below are trigger-agnostic; only the # final publishing job differs. Neither rolling tag is named v-anything, so a # CI-pushed tag can never re-enter the release path. # # The release/** lane exists because a release branch was otherwise invisible # until it was tagged: the code most in need of 24-platform coverage got none, # and the libretro buildbot only reacted after the tag, when a break becomes a # hotfix rather than a fix. # # workflow_dispatch exists ONLY to re-publish a rolling tag, so it must be run # with the branch set to `develop` or a `release/*` branch. GitHub defaults # the branch picker to the repo default (`master` here), and every publishing # job is ref-gated, so a dispatch left on the default would build all 24 # targets and publish nothing. # The `guard` job below fails that case immediately instead of burning the # matrix. To test-build an arbitrary branch, use c-cpp.yml — it has its own # workflow_dispatch and runs on PRs. on: push: tags: ['v*'] branches: [develop, 'release/**'] workflow_dispatch: permissions: contents: write # Tag pushes always run to completion — a release build must never be # cancelled. Develop pushes cancel superseded runs, because only the newest # commit's binaries matter for a rolling prerelease and an in-flight build of # an older tip is pure waste. The `guard` job's debounce sleep makes the cancel # land while a burst of merges is still asleep, before any build starts. # c-cpp.yml / acid-test.yml no longer run on develop pushes at all (their PR runs # already covered the tree), so this is the only per-merge workflow on develop. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/heads/release/') }} jobs: # ---------------------------------------------------------------- # Fail a pointless dispatch in seconds rather than after 24 builds. # Only reachable via workflow_dispatch: push events always arrive on a # ref one of the two publishing jobs accepts. # ---------------------------------------------------------------- guard: name: Check dispatch ref runs-on: ${{ contains(fromJSON(vars.VJ_TEMP_RUNNERS || '{"refs":[]}').refs, github.ref) && fromJSON(vars.VJ_TEMP_RUNNERS)['ubuntu-latest'] || 'ubuntu-latest' }} steps: # Debounce for the rolling nightly. A burst of merges to develop would # otherwise start one 24-target build per merge and cancel it when the next # lands. Every build job needs this job, so the whole matrix waits here; # a newer develop push cancels this run (concurrency below) while it is still # asleep, at the cost of one idle ubuntu runner, and only the last push of a # burst goes on to build. Only develop pushes wait: tags, release/** and # manual dispatches start immediately. - name: Wait for the develop burst to settle if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} run: sleep 600 - name: Refuse a dispatch that would publish nothing if: ${{ github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/develop' && !startsWith(github.ref, 'refs/heads/release/') }} run: | # Built up in one variable because a ::error:: annotation has to be a # single line to render as one. # TARGETS = 22 build configs + vita-build + switch-build = 24 MSG="Run this workflow with the branch set to 'develop' or a 'release/*' branch." MSG="$MSG The publishing jobs are ref-gated (tags v* -> release," MSG="$MSG develop -> nightly, release/* -> prerelease), so a dispatch" MSG="$MSG from '${GITHUB_REF_NAME}' would build all 24 targets and" MSG="$MSG publish nothing." MSG="$MSG To test-build a branch, use the C/C++ CI workflow instead." echo "::error::$MSG" exit 1 # ---------------------------------------------------------------- # Native + cross builds (Linux/macOS/Windows/iOS/tvOS/Android/WASM) # Each job builds with RELEASE_DEBUG_INFO=1 so we keep -g on the # optimized binary, then extracts split debug symbols and strips the # shipped binary. # ---------------------------------------------------------------- build: needs: [guard] strategy: fail-fast: false matrix: config: # Linux - platform: linux-x86_64 artifact: virtualjaguar_libretro.so os: ubuntu-latest cc: gcc cxx: g++ - platform: linux-aarch64 artifact: virtualjaguar_libretro.so os: ubuntu-24.04-arm cc: gcc cxx: g++ - platform: linux-i686 artifact: virtualjaguar_libretro.so os: ubuntu-latest cc: 'gcc -m32' cxx: 'g++ -m32' multilib: true # Raspberry Pi -- per-SoC tuned (issue #560). # # Cross-compiled on x86_64: -mcpu names a core to generate code FOR, # it does not require running on one, so no Pi hardware is needed to # produce these. The per-SoC flags live in the Makefile's rpi* # platform blocks -- do not duplicate them here. # # rpi0 is deliberately absent: it is the same BCM2835/ARM1176 part # as rpi1 and resolves to identical flags, so the rpi1 artifact # serves both. Every other row is a genuinely distinct target. # # 32- and 64-bit rows for the same chip are BOTH needed: they are # different userlands (Raspberry Pi OS ships both), and the 32-bit # ones additionally carry -mfpu/-mfloat-abi, which aarch64 gcc # rejects outright. - platform: linux-rpi1-armv6 artifact: virtualjaguar_libretro.so os: ubuntu-latest cc: arm-linux-gnueabihf-gcc cxx: arm-linux-gnueabihf-g++ make_extra: platform=rpi1 cross_arm: armhf - platform: linux-rpi2-cortex-a7 artifact: virtualjaguar_libretro.so os: ubuntu-latest cc: arm-linux-gnueabihf-gcc cxx: arm-linux-gnueabihf-g++ make_extra: platform=rpi2 cross_arm: armhf - platform: linux-rpi3-cortex-a53 artifact: virtualjaguar_libretro.so os: ubuntu-latest cc: arm-linux-gnueabihf-gcc cxx: arm-linux-gnueabihf-g++ make_extra: platform=rpi3 cross_arm: armhf - platform: linux-rpi3_64-cortex-a53 artifact: virtualjaguar_libretro.so os: ubuntu-latest cc: aarch64-linux-gnu-gcc cxx: aarch64-linux-gnu-g++ make_extra: platform=rpi3_64 cross_arm: arm64 - platform: linux-rpi4-cortex-a72 artifact: virtualjaguar_libretro.so os: ubuntu-latest cc: arm-linux-gnueabihf-gcc cxx: arm-linux-gnueabihf-g++ make_extra: platform=rpi4 cross_arm: armhf - platform: linux-rpi4_64-cortex-a72 artifact: virtualjaguar_libretro.so os: ubuntu-latest cc: aarch64-linux-gnu-gcc cxx: aarch64-linux-gnu-g++ make_extra: platform=rpi4_64 cross_arm: arm64 - platform: linux-rpi5-cortex-a76 artifact: virtualjaguar_libretro.so os: ubuntu-latest cc: arm-linux-gnueabihf-gcc cxx: arm-linux-gnueabihf-g++ make_extra: platform=rpi5 cross_arm: armhf - platform: linux-rpi5_64-cortex-a76 artifact: virtualjaguar_libretro.so os: ubuntu-latest cc: aarch64-linux-gnu-gcc cxx: aarch64-linux-gnu-g++ make_extra: platform=rpi5_64 cross_arm: arm64 # macOS - platform: macos-arm64 artifact: virtualjaguar_libretro.dylib os: macos-latest cc: clang cxx: clang++ # Cross-compiled from the macos-latest (arm64) runner via # `clang -arch x86_64`. Avoids the increasingly scarce # macos-13 (Intel) runner pool that left v2.2.0's release # job queued for an hour. BLITTER_SIMD=sse2 is required # because the Makefile's auto-detect uses host arch. - platform: macos-x86_64 artifact: virtualjaguar_libretro.dylib os: macos-latest cc: clang -arch x86_64 cxx: clang++ -arch x86_64 make_extra: 'BLITTER_SIMD=sse2' # Windows - platform: windows-x86_64 artifact: virtualjaguar_libretro.dll os: windows-latest cc: gcc cxx: g++ shell: 'msys2 {0}' msystem: 'MINGW64' msys2_packages: 'mingw-w64-x86_64-gcc make' - platform: windows-i686 artifact: virtualjaguar_libretro.dll os: windows-latest cc: gcc cxx: g++ shell: 'msys2 {0}' msystem: 'MINGW32' msys2_packages: 'mingw-w64-i686-gcc make' # Emscripten (WebAssembly) - platform: emscripten-wasm artifact: virtualjaguar_libretro_emscripten.bc os: ubuntu-latest emscripten: true # Android NDK - platform: android-arm64-v8a artifact: libs/arm64-v8a/libretro.so os: ubuntu-latest android: true android_abi: 'arm64-v8a' android_simd: 'neon' - platform: android-armeabi-v7a artifact: libs/armeabi-v7a/libretro.so os: ubuntu-latest android: true android_abi: 'armeabi-v7a' android_simd: 'neon' - platform: android-x86_64 artifact: libs/x86_64/libretro.so os: ubuntu-latest android: true android_abi: 'x86_64' android_simd: 'sse2' - platform: android-x86 artifact: libs/x86/libretro.so os: ubuntu-latest android: true android_abi: 'x86' android_simd: 'sse2' # iOS / tvOS - platform: ios-arm64 artifact: virtualjaguar_libretro_ios.dylib os: macos-latest make_platform: 'ios-arm64' - platform: tvos-arm64 artifact: virtualjaguar_libretro_tvos.dylib os: macos-latest make_platform: 'tvos-arm64' name: build-${{ matrix.config.platform }} # Only the unsigned iOS/tvOS rows of the develop nightly lane run on the Mac Studio self-hosted # runner. macOS rows sign + notarize (fixed-name keychain, 25-45 min notary waits); tags and # release/** (whose ci-core intermediates C/C++ CI reuses) always build on clean hosted runners. # Kill switch: repository variable VJ_STUDIO_RUNNER=off. runs-on: ${{ matrix.config.os == 'macos-latest' && matrix.config.make_platform && github.ref == 'refs/heads/develop' && vars.VJ_STUDIO_RUNNER != 'off' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && fromJSON('["self-hosted","studio","vj"]') || matrix.config.os }} defaults: run: shell: ${{ matrix.config.shell || 'bash' }} steps: - uses: actions/checkout@v7 - name: Install multilib if: matrix.config.multilib run: | sudo dpkg --add-architecture i386 sudo apt-get update sudo apt-get install -y gcc-multilib g++-multilib # Cross toolchain for the Raspberry Pi rows. Also provides the matching # objcopy: the host x86_64 objcopy in the packaging step below cannot # read an ARM ELF (Ubuntu's binutils is single-target), so the strip / # split-debug step needs the prefixed one. - name: Install ARM cross toolchain if: matrix.config.cross_arm run: | set -e sudo apt-get update if [ "${{ matrix.config.cross_arm }}" = armhf ]; then sudo apt-get install -y gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf else sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu fi - name: Set up MSYS2 if: runner.os == 'Windows' uses: msys2/setup-msys2@v2 with: msystem: ${{ matrix.config.msystem || 'MINGW64' }} update: false install: ${{ matrix.config.msys2_packages || 'mingw-w64-x86_64-gcc make' }} - name: Set up Emscripten if: matrix.config.emscripten uses: mymindstorm/setup-emsdk@v16 - name: Set up Android NDK if: matrix.config.android uses: nttld/setup-ndk@v1 id: setup-ndk with: ndk-version: r26d - name: Build if: ${{ !matrix.config.emscripten && !matrix.config.android && !matrix.config.make_platform }} env: RELEASE_DEBUG_INFO: '1' TEST_EXPORTS: '0' run: make -j4 CC="${{ matrix.config.cc }}" CXX="${{ matrix.config.cxx }}" ${{ matrix.config.make_extra || '' }} - name: Build (platform) if: matrix.config.make_platform env: RELEASE_DEBUG_INFO: '1' TEST_EXPORTS: '0' run: make -j4 platform=${{ matrix.config.make_platform }} - name: Build (Emscripten) if: matrix.config.emscripten env: RELEASE_DEBUG_INFO: '1' TEST_EXPORTS: '0' run: emmake make -j4 platform=emscripten - name: Build (Android NDK) if: matrix.config.android env: RELEASE_DEBUG_INFO: '1' TEST_EXPORTS: '0' run: bash scripts/build-ndk-ci.sh "${{ steps.setup-ndk.outputs.ndk-path }}" "${{ matrix.config.android_abi }}" "${{ matrix.config.android_simd }}" - name: Verify compile-only Mach-O production exports if: ${{ matrix.config.platform == 'macos-x86_64' || matrix.config.platform == 'ios-arm64' || matrix.config.platform == 'tvos-arm64' }} run: | LEAKS=$(nm -gU "${{ matrix.config.artifact }}" | grep -v ' _retro_' || true) if [ -n "$LEAKS" ]; then echo "::error::Production dylib leaked non-retro_* symbols:" echo "$LEAKS" exit 1 fi - name: Preserve unstripped compile-only core for PR validation if: ${{ matrix.config.emscripten || matrix.config.android || matrix.config.platform == 'macos-x86_64' || matrix.config.make_platform }} env: TEST_EXPORTS: '0' RELEASE_DEBUG_INFO: '1' run: bash scripts/stage-ci-core.sh "${{ matrix.config.platform }}" "${{ matrix.config.artifact }}" ci-core - name: Upload production CI intermediate if: ${{ matrix.config.emscripten || matrix.config.android || matrix.config.platform == 'macos-x86_64' || matrix.config.make_platform }} uses: actions/upload-artifact@v7 with: name: ci-core-${{ matrix.config.platform }} overwrite: true path: ci-core/ if-no-files-found: error retention-days: 14 # ---------------------------------------------------------------- # Package: emit # dist/ (stripped, shipped binary) # dist/-debug.tar.gz (split debug symbols) # The shipped binary keeps a debug-link / dSYM reference so a # debugger can find the symbols when the .tar.gz is extracted # alongside it. # ---------------------------------------------------------------- - name: Package (Linux native build) if: ${{ runner.os == 'Linux' && !matrix.config.emscripten && !matrix.config.android }} run: | set -e mkdir -p dist SRC="${{ matrix.config.artifact }}" EXT=$(echo "$SRC" | grep -o '\.[^.]*$') OUT="virtualjaguar_libretro-${{ matrix.config.platform }}${EXT}" DBG="${OUT}.debug" cp "$SRC" "dist/${OUT}" # A cross-built ARM core needs the cross objcopy -- Ubuntu's # binutils is single-target, so the host one cannot read an ARM ELF # and the split-debug step would fail (or, worse, be skipped). case "${{ matrix.config.cross_arm }}" in armhf) OBJCOPY=arm-linux-gnueabihf-objcopy ;; arm64) OBJCOPY=aarch64-linux-gnu-objcopy ;; *) OBJCOPY=objcopy ;; esac "$OBJCOPY" --only-keep-debug "dist/${OUT}" "dist/${DBG}" "$OBJCOPY" --strip-debug --strip-unneeded "dist/${OUT}" ( cd dist && "$OBJCOPY" --add-gnu-debuglink="${DBG}" "${OUT}" ) tar -C dist -czf "dist/virtualjaguar_libretro-${{ matrix.config.platform }}-debug.tar.gz" "${DBG}" rm "dist/${DBG}" - name: Package (Android) if: matrix.config.android run: | set -e mkdir -p dist SRC="${{ matrix.config.artifact }}" OUT="virtualjaguar_libretro-${{ matrix.config.platform }}.so" DBG="${OUT}.debug" cp "$SRC" "dist/${OUT}" # NDK ships its own objcopy/strip; use the unprefixed system one # (the artifact is already linked). llvm-objcopy works on all ABIs. OBJCOPY="${{ steps.setup-ndk.outputs.ndk-path }}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objcopy" if [ ! -x "$OBJCOPY" ]; then OBJCOPY=objcopy; fi "$OBJCOPY" --only-keep-debug "dist/${OUT}" "dist/${DBG}" "$OBJCOPY" --strip-debug --strip-unneeded "dist/${OUT}" ( cd dist && "$OBJCOPY" --add-gnu-debuglink="${DBG}" "${OUT}" ) tar -C dist -czf "dist/virtualjaguar_libretro-${{ matrix.config.platform }}-debug.tar.gz" "${DBG}" rm "dist/${DBG}" - name: Package (Emscripten) if: matrix.config.emscripten run: | set -e mkdir -p dist SRC="${{ matrix.config.artifact }}" OUT="virtualjaguar_libretro-${{ matrix.config.platform }}.bc" cp "$SRC" "dist/${OUT}" # LLVM bitcode keeps debug info inline; ship the .bc twice with # the second copy gzipped under -debug for symmetry with the # other platforms. gzip -c "dist/${OUT}" > "dist/virtualjaguar_libretro-${{ matrix.config.platform }}-debug.tar.gz" - name: Package (macOS / iOS / tvOS) if: ${{ runner.os == 'macOS' }} run: | set -e mkdir -p dist SRC="${{ matrix.config.artifact }}" EXT=$(echo "$SRC" | grep -o '\.[^.]*$') OUT="virtualjaguar_libretro-${{ matrix.config.platform }}${EXT}" cp "$SRC" "dist/${OUT}" # dsymutil produces a .dSYM bundle next to the binary dsymutil "dist/${OUT}" -o "dist/${OUT}.dSYM" # Strip debug info from the shipped binary (-S = local syms, # -x = remove non-globals); keeps externals so dlsym still works strip -S -x "dist/${OUT}" tar -C dist -czf "dist/virtualjaguar_libretro-${{ matrix.config.platform }}-debug.tar.gz" "${OUT}.dSYM" rm -rf "dist/${OUT}.dSYM" - name: Package (Windows MSYS2) if: ${{ runner.os == 'Windows' }} run: | set -e mkdir -p dist SRC="${{ matrix.config.artifact }}" OUT="virtualjaguar_libretro-${{ matrix.config.platform }}.dll" DBG="${OUT}.debug" cp "$SRC" "dist/${OUT}" objcopy --only-keep-debug "dist/${OUT}" "dist/${DBG}" objcopy --strip-debug --strip-unneeded "dist/${OUT}" ( cd dist && objcopy --add-gnu-debuglink="${DBG}" "${OUT}" ) tar -C dist -czf "dist/virtualjaguar_libretro-${{ matrix.config.platform }}-debug.tar.gz" "${DBG}" rm "dist/${DBG}" # ---------------------------------------------------------------- # cue2cdi -- standalone CUE/BIN -> DiscJuggler CDI converter # (test/tools/cue2cdi.c, `make cue2cdi`). Shipped for the three # common desktop platforms only; dropping the binary into dist/ # rides the existing artifact -> stage -> release/nightly flow, so # it appears in both tagged releases and the rolling nightly and # lands in SHA256SUMS automatically. macOS ships one universal # (arm64 + x86_64) binary built from the arm64 job; Windows from # the MINGW64 job. # ---------------------------------------------------------------- - name: Build cue2cdi (shipped desktop platforms) if: ${{ matrix.config.platform == 'linux-x86_64' || matrix.config.platform == 'macos-arm64' || matrix.config.platform == 'windows-x86_64' }} run: | set -e mkdir -p dist case "${{ matrix.config.platform }}" in linux-x86_64) make cue2cdi CC="${{ matrix.config.cc }}" cp test/tools/cue2cdi dist/cue2cdi-linux-x86_64 strip dist/cue2cdi-linux-x86_64 ;; macos-arm64) clang -O2 -Wall -std=c99 -arch arm64 -arch x86_64 \ -o dist/cue2cdi-macos-universal test/tools/cue2cdi.c strip -S -x dist/cue2cdi-macos-universal ;; windows-x86_64) gcc -O2 -Wall -std=c99 -o dist/cue2cdi-windows-x86_64.exe test/tools/cue2cdi.c strip dist/cue2cdi-windows-x86_64.exe ;; esac # ---------------------------------------------------------------- # macOS desktop cores and cue2cdi: Developer ID sign + notarize (#989). # Runs after strip (it would invalidate the signature) and before the # upload. Bare Mach-O files cannot be stapled; they are notarized in # a zip and Gatekeeper checks the ticket online. iOS/tvOS dylibs are # NOT signed here (Developer ID does not apply to them; Provenance # signs them when it embeds them). Without the APPLE_* secrets the # action only warns. Needs a macOS runner: macos-arm64 also carries # the universal cue2cdi built above. # ---------------------------------------------------------------- - name: Developer ID sign and notarize (macOS core${{ matrix.config.platform == 'macos-arm64' && ' + cue2cdi' || '' }}) if: ${{ matrix.config.platform == 'macos-arm64' || matrix.config.platform == 'macos-x86_64' }} uses: ./.github/actions/macos-sign with: paths: | dist/virtualjaguar_libretro-${{ matrix.config.platform }}.dylib ${{ matrix.config.platform == 'macos-arm64' && 'dist/cue2cdi-macos-universal' || '' }} notarize: 'true' apple-developer-id-p12-base64: ${{ secrets.APPLE_DEVELOPER_ID_P12_BASE64 }} apple-developer-id-p12-password: ${{ secrets.APPLE_DEVELOPER_ID_P12_PASSWORD }} apple-notary-key-id: ${{ secrets.APPLE_NOTARY_KEY_ID }} apple-notary-issuer-id: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} apple-notary-key-p8-base64: ${{ secrets.APPLE_NOTARY_KEY_P8_BASE64 }} - name: Upload artifact uses: actions/upload-artifact@v7 with: name: virtualjaguar_libretro-${{ matrix.config.platform }} path: dist/ if-no-files-found: error # ---------------------------------------------------------------- # PS Vita (vitasdk) - cross-compiled .a static archive # ---------------------------------------------------------------- vita-build: name: build-vita needs: [guard] runs-on: ubuntu-latest container: image: vitasdk/vitasdk:latest steps: - uses: actions/checkout@v7 - name: Build env: RELEASE_DEBUG_INFO: '1' TEST_EXPORTS: '0' run: make -j4 platform=vita - name: Preserve unstripped production core for PR validation env: TEST_EXPORTS: '0' RELEASE_DEBUG_INFO: '1' run: bash scripts/stage-ci-core.sh vita virtualjaguar_libretro_vita.a ci-core - name: Upload production CI intermediate uses: actions/upload-artifact@v7 with: name: ci-core-vita overwrite: true path: ci-core/ if-no-files-found: error retention-days: 14 - name: Package run: | set -e mkdir -p dist OUT="virtualjaguar_libretro-vita.a" cp virtualjaguar_libretro_vita.a "dist/${OUT}" # Static archive — extract debug info via objcopy on the archive # itself. arm-vita-eabi-objcopy is in the vitasdk image. OBJCOPY=arm-vita-eabi-objcopy "$OBJCOPY" --only-keep-debug "dist/${OUT}" "dist/${OUT}.debug" || cp "dist/${OUT}" "dist/${OUT}.debug" "$OBJCOPY" --strip-debug "dist/${OUT}" || true tar -C dist -czf "dist/virtualjaguar_libretro-vita-debug.tar.gz" "${OUT}.debug" rm "dist/${OUT}.debug" - name: Upload artifact uses: actions/upload-artifact@v7 with: name: virtualjaguar_libretro-vita path: dist/ if-no-files-found: error # ---------------------------------------------------------------- # Nintendo Switch (devkitPro libnx) - .a static archive # ---------------------------------------------------------------- switch-build: name: build-switch needs: [guard] runs-on: ubuntu-latest container: image: devkitpro/devkita64:latest steps: - uses: actions/checkout@v7 - name: Build env: DEVKITPRO: /opt/devkitpro RELEASE_DEBUG_INFO: '1' TEST_EXPORTS: '0' run: make -j4 platform=libnx - name: Preserve unstripped production core for PR validation env: TEST_EXPORTS: '0' RELEASE_DEBUG_INFO: '1' run: bash scripts/stage-ci-core.sh switch virtualjaguar_libretro_libnx.a ci-core - name: Upload production CI intermediate uses: actions/upload-artifact@v7 with: name: ci-core-switch overwrite: true path: ci-core/ if-no-files-found: error retention-days: 14 - name: Package run: | set -e mkdir -p dist OUT="virtualjaguar_libretro-switch.a" cp virtualjaguar_libretro_libnx.a "dist/${OUT}" OBJCOPY=/opt/devkitpro/devkitA64/bin/aarch64-none-elf-objcopy "$OBJCOPY" --only-keep-debug "dist/${OUT}" "dist/${OUT}.debug" || cp "dist/${OUT}" "dist/${OUT}.debug" "$OBJCOPY" --strip-debug "dist/${OUT}" || true tar -C dist -czf "dist/virtualjaguar_libretro-switch-debug.tar.gz" "${OUT}.debug" rm "dist/${OUT}.debug" - name: Upload artifact uses: actions/upload-artifact@v7 with: name: virtualjaguar_libretro-switch path: dist/ if-no-files-found: error # ---------------------------------------------------------------- # Desktop jagcd tools (pinned chdman + jagcd-to-chd + jagcd-chd-check). # Same three hosts as cue2cdi. Runs in parallel with the core matrix; # the release/nightly jobs wait on it so the zips land in SHA256SUMS # and on the GitHub release next to the cores. # ---------------------------------------------------------------- jagcd-tools: name: jagcd tools (chdman) needs: [guard] uses: ./.github/workflows/chdman-tools.yml secrets: inherit # ---------------------------------------------------------------- # Standalone app packages (#839): macOS zip, Windows zip/setup, # Linux AppImage/deb. One reusable workflow also used by app.yml for # PRs. It uploads only final, contract-named files as `app-package-*` # artifacts, so the release/nightly jobs' flatten-and-checksum steps # attach them next to the cores with no further logic. On a tag whose # version is not the app's (a core-only release) it builds nothing. # ---------------------------------------------------------------- app-packages: name: App packages needs: [guard] uses: ./.github/workflows/app-package.yml with: release_tag: ${{ github.ref_type == 'tag' && github.ref_name || (github.ref == 'refs/heads/develop' && 'nightly' || 'prerelease') }} sign: true secrets: inherit # ---------------------------------------------------------------- # Release: collect all artifacts, generate SHA256SUMS, build the # release body from docs/RELEASE_NOTES_v.md (artifact list # and embedded SHA256 checksums always appended), then create the # tagged release. See SECURITY.md for binary verification docs. # ---------------------------------------------------------------- release: needs: [build, vita-build, switch-build, jagcd-tools, app-packages] # Tag pushes only. Everything below assumes GITHUB_REF_NAME is a vX.Y.Z # tag (release-notes lookup, release title, `gh release create `), so # this guard is what keeps a develop push from creating a release literally # named "develop". The nightly job handles that case instead. # # The status checks spell out what the implicit success() gate meant before # app-packages existed (every need succeeded), with one addition: the app # job may be `skipped`, which is what a tag without an app to package # produces. A FAILED app job still blocks the release, like a failed # tools build: re-run the failed jobs and the release follows. if: | startsWith(github.ref, 'refs/tags/v') && !cancelled() && needs.build.result == 'success' && needs.vita-build.result == 'success' && needs.switch-build.result == 'success' && needs.jagcd-tools.result == 'success' && (needs.app-packages.result == 'success' || needs.app-packages.result == 'skipped') runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Download all artifacts uses: actions/download-artifact@v8 with: path: artifacts/ - name: Stage release files run: | set -e mkdir -p release # Flatten one level: each artifact directory contains the # binary + matching -debug.tar.gz; copy them all into release/. # librashader- are intermediates of the app packages (the shader runtime built # from source, librashader.yml): already inside the packages, not release assets. find artifacts/ -mindepth 2 -type f -not -path 'artifacts/librashader-*' -not -path 'artifacts/ci-core-*' -exec cp {} release/ \; # Include the RetroArch core .info file so frontend maintainers # / users can drop it into RetroArch's info/ dir. The same file # is what gets PR'd to libretro/libretro-super after the release. if [ -f dist/info/virtualjaguar_libretro.info ]; then cp dist/info/virtualjaguar_libretro.info release/ fi echo "==> Staged release files:" ls -la release/ - name: Generate SHA256SUMS run: | set -e cd release # Deterministic ordering, two-space format compatible with # `sha256sum -c SHA256SUMS.txt` on Linux + Mac (with coreutils). # # Build it OUTSIDE the directory and move it in. Redirecting straight # to ./SHA256SUMS.txt races: the shell starts both pipeline stages at # once, so `sort`'s redirect can create the (empty) file before # `sha256sum`'s glob expands, leaving the manifest listing itself with # the empty-file hash — which then fails `sha256sum --check` on that # one line. v2.3.2 happened to win the race; nightlies would not. sha256sum -- * | sort -k 2 > "$RUNNER_TEMP/SHA256SUMS.txt" mv "$RUNNER_TEMP/SHA256SUMS.txt" SHA256SUMS.txt echo "==> SHA256SUMS.txt:" cat SHA256SUMS.txt - name: Prepare release body id: prep_body run: | set -e TAG="${GITHUB_REF_NAME}" NOTES_FILE="docs/RELEASE_NOTES_${TAG}.md" BODY_FILE="$RUNNER_TEMP/release_body.md" if [ -f "$NOTES_FILE" ]; then echo "==> Using curated release notes: $NOTES_FILE" cp "$NOTES_FILE" "$BODY_FILE" else echo "==> No curated $NOTES_FILE found; generating notes from commits" gh api repos/${{ github.repository }}/releases/generate-notes \ -f tag_name="${TAG}" --jq '.body' > "$BODY_FILE" 2>/dev/null || true fi # Append artifact list and embedded SHA256 checksums so users can # verify binaries directly from the release page without needing to # download SHA256SUMS.txt first. See SECURITY.md for details. { echo echo "---" echo echo "## Artifacts in this release" echo echo '```' find release -mindepth 1 -maxdepth 1 -type f -printf "%f\n" | sort echo '```' echo echo "## SHA-256 checksums" echo echo "Verify with \`sha256sum --check SHA256SUMS.txt\` (Linux) or" echo "\`shasum -a 256 --check SHA256SUMS.txt\` (macOS)." echo "See [SECURITY.md](https://github.com/${{ github.repository }}/blob/${{ github.event.repository.default_branch }}/SECURITY.md)" echo "for full verification instructions and antivirus false-positive guidance." echo echo '```' cat release/SHA256SUMS.txt echo '```' } >> "$BODY_FILE" echo "body_file=$BODY_FILE" >> "$GITHUB_OUTPUT" - name: Create release env: GH_TOKEN: ${{ github.token }} run: | TAG="${GITHUB_REF_NAME}" gh release create "${TAG}" \ --title "${TAG}" \ --notes-file "${{ steps.prep_body.outputs.body_file }}" \ release/* # ---------------------------------------------------------------- # Nightly: same artifacts, published to a single rolling `nightly` # prerelease that is moved onto each new develop tip, plus a pinned # tracking issue that always shows the current build. # # Gating: `needs` covers the build jobs only, so this publishes a # commit that COMPILES on all 24 targets. It does NOT wait on # c-cpp.yml / acid-test.yml / regression-test.yml, which run # independently on the same push — a commit that builds but fails a # test can still ship a nightly. The release body says so; do not # upgrade that wording to "passes all CI" without also switching this # to a `workflow_run` trigger (see artifacts.yml for that idiom). # ---------------------------------------------------------------- nightly: name: Update rolling prerelease needs: [build, vita-build, switch-build, jagcd-tools, app-packages] # The app packages (app-packages) are waited for and not required either: # a macOS notarization outage must not withhold the core nightly. # Wait for the tools build, but do not REQUIRE it. chdman is an # auxiliary desktop helper; the nightly's product is the 24 core # binaries. Gating the publish on it meant a missing pkg-config # package on a runner image silently withheld three days of good core # builds (2026-08-18). Tagged releases still require it below, because # there the tools zips are part of the deliverable. if: | always() && (github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/heads/release/')) && needs.build.result == 'success' && needs.vita-build.result == 'success' && needs.switch-build.result == 'success' runs-on: ubuntu-latest permissions: contents: write # move the tag, create/update the release issues: write # update + pin the tracking issue env: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} # One rolling tag per lane. Deliberately NOT a per-branch or per-PR # tag that gets cleaned up on release: one rolling tag has a lifecycle # the project already understands, needs no teardown step anyone can # forget, and cannot orphan tags when a release branch is renamed or # abandoned (issue #507). Neither name matches the v* filter, so a # CI-pushed tag can never re-enter the release path. TAG: ${{ github.ref == 'refs/heads/develop' && 'nightly' || 'prerelease' }} LANE: ${{ github.ref == 'refs/heads/develop' && 'Nightly' || 'Release candidate' }} # Must match issue #236's title exactly (including the emoji / [Release] # prefix added by hand). Label lookup is preferred; this is the # fallback if the nightly-tracker label is ever removed. ISSUE_TITLE: '🐈‍⬛ [Release] Nightly builds — download, install, and what they are' steps: - uses: actions/checkout@v7 with: # Full history: the release body diffs against the commit the # `nightly` tag pointed at on the previous run. fetch-depth: 0 - name: Download all artifacts uses: actions/download-artifact@v8 with: path: artifacts/ - name: Stage release files run: | set -e mkdir -p release # (librashader- are intermediates of the app packages, not release assets.) find artifacts/ -mindepth 2 -type f -not -path 'artifacts/librashader-*' -not -path 'artifacts/ci-core-*' -exec cp {} release/ \; # The .info file's display_version names the last TAGGED release, so # shipping it verbatim would label a nightly "v2.3.2". Rewrite it to # the build's actual identity — this also makes it obvious that a # nightly .info is not the file to PR to libretro-super. if [ -f dist/info/virtualjaguar_libretro.info ]; then sed "s/^display_version = \".*\"/display_version = \"$(sed -n 's/^CORE_BASE_VERSION[[:space:]]*:*=[[:space:]]*\(.*\)/\1/p' Makefile | head -1)-nightly-${GITHUB_SHA:0:7}\"/" \ dist/info/virtualjaguar_libretro.info > release/virtualjaguar_libretro.info fi echo "==> Staged nightly files:" ls -la release/ - name: Generate SHA256SUMS run: | set -e cd release # Written outside the directory then moved in — see the release job's # copy of this step for why the obvious redirect races. sha256sum -- * | sort -k 2 > "$RUNNER_TEMP/SHA256SUMS.txt" mv "$RUNNER_TEMP/SHA256SUMS.txt" SHA256SUMS.txt cat SHA256SUMS.txt - name: Prepare rolling release body id: prep_body run: | set -e SHA="${GITHUB_SHA}" SHORT_SHA="${SHA:0:7}" BODY_FILE="$RUNNER_TEMP/rolling_body.md" RUN_URL="${GITHUB_SERVER_URL}/${REPO}/actions/runs/${GITHUB_RUN_ID}" BASE_VERSION=$(sed -n 's/^CORE_BASE_VERSION[[:space:]]*:*=[[:space:]]*\(.*\)/\1/p' Makefile | head -1) # Where the rolling tag pointed BEFORE this run — captured here # because the next step moves it. PREV_SHA=$(gh api "repos/${REPO}/git/refs/tags/${TAG}" --jq '.object.sha' 2>/dev/null || echo "") CHANGES="" if [ -n "$PREV_SHA" ] && [ "$PREV_SHA" != "$SHA" ] \ && git cat-file -e "${PREV_SHA}^{commit}" 2>/dev/null; then CHANGES=$(git log --oneline --no-merges "${PREV_SHA}..${SHA}" | head -40) fi [ -n "$CHANGES" ] || CHANGES=$(git log --oneline --no-merges -10) { echo "### ${LANE} \`${SHORT_SHA}\` — \`${BASE_VERSION}\` (\`${GITHUB_REF_NAME}\`)" echo echo "| | |" echo "|---|---|" echo "| **Commit** | [\`${SHORT_SHA}\`](${GITHUB_SERVER_URL}/${REPO}/commit/${SHA}) |" echo "| **Built** | $(date -u '+%Y-%m-%d %H:%M UTC') |" echo "| **CI run** | [build log](${RUN_URL}) |" echo "| **Branch** | \`${GITHUB_REF_NAME}\` |" echo P="> Automatic build of the \`${GITHUB_REF_NAME}\` tip, replaced on every push." P="$P It compiled on all 24 targets; it has **not** been through the" P="$P test suite or any manual QA. For a stable build use the" P="$P [latest release](${GITHUB_SERVER_URL}/${REPO}/releases/latest)." if [ "${TAG}" = "prerelease" ]; then P="$P This is a RELEASE CANDIDATE from a release branch, not a release:" P="$P it exists so the 24-platform matrix runs on the code about to" P="$P ship, instead of first meeting it at the tag." fi echo "$P" echo echo "The core logs its identity as \`${BASE_VERSION} ${SHORT_SHA}\` at startup — quote that line when reporting a bug against this build." echo echo "### Changes since the previous ${LANE,,}" echo echo '```' echo "${CHANGES}" echo '```' echo echo "### Artifacts" echo echo '```' find release -mindepth 1 -maxdepth 1 -type f -printf "%f\n" | sort echo '```' echo echo "Each platform ships alongside a \`-debug.tar.gz\` of its split debug symbols; extract it next to the binary to get symbolised backtraces." echo echo "### SHA-256 checksums" echo P="Verify with \`sha256sum --check SHA256SUMS.txt\` (Linux) or" P="$P \`shasum -a 256 --check SHA256SUMS.txt\` (macOS). See" P="$P [SECURITY.md](${GITHUB_SERVER_URL}/${REPO}/blob/${{ github.event.repository.default_branch }}/SECURITY.md)." echo "$P" echo echo '```' cat release/SHA256SUMS.txt echo '```' } > "$BODY_FILE" echo "body_file=$BODY_FILE" >> "$GITHUB_OUTPUT" echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" - name: Move the rolling tag run: | set -e # -F (typed) for force so it serialises as a JSON boolean; -f would # send the string "true", which the refs API rejects with 422. gh api "repos/${REPO}/git/refs/tags/${TAG}" \ -X PATCH -f sha="${GITHUB_SHA}" -F force=true >/dev/null 2>&1 || \ gh api "repos/${REPO}/git/refs" \ -X POST -f ref="refs/tags/${TAG}" -f sha="${GITHUB_SHA}" >/dev/null - name: Publish rolling prerelease run: | set -e TITLE="${LANE} ${{ steps.prep_body.outputs.short_sha }}" # Update in place rather than delete-and-recreate: asset names are # deterministic per platform, so --clobber replaces them without ever # leaving /releases/tag/nightly 404ing mid-run, and the release keeps # its id (so anyone watching it stays subscribed). if gh release view "${TAG}" --repo "${REPO}" >/dev/null 2>&1; then gh release edit "${TAG}" --repo "${REPO}" \ --title "${TITLE}" \ --notes-file "${{ steps.prep_body.outputs.body_file }}" \ --target "${GITHUB_SHA}" \ --prerelease --draft=false gh release upload "${TAG}" --repo "${REPO}" release/* --clobber # `gh release edit` leaves published_at at the original create # date, so the Releases list kept showing "Published Aug 1" while # assets/body moved every develop push — looking abandoned under # newer stable tags. Draft→publish is the only API that rewrites # that timestamp; assets stay attached and the id is unchanged. # Window is one round-trip. REL_ID=$(gh api "repos/${REPO}/releases/tags/${TAG}" --jq .id) gh api -X PATCH "repos/${REPO}/releases/${REL_ID}" -F draft=true >/dev/null gh api -X PATCH "repos/${REPO}/releases/${REL_ID}" \ -F draft=false -F prerelease=true >/dev/null else gh release create "${TAG}" --repo "${REPO}" \ --title "${TITLE}" \ --notes-file "${{ steps.prep_body.outputs.body_file }}" \ --prerelease \ --target "${GITHUB_SHA}" \ release/* fi - name: Update pinned tracking issue # develop only: issue #236 documents the NIGHTLY, and its title, label # and prose are all nightly-specific. A release-candidate publish has # no tracking issue and must not rewrite that one (issue #507). if: github.ref == 'refs/heads/develop' # Never fail the publish over the issue: the binaries are already out. continue-on-error: true env: # The app section is listed only when this nightly actually carries # app packages (the job skips commits without app/, and a failed # package build does not block the core nightly). APP_BUILT: ${{ needs.app-packages.result == 'success' }} run: | set -e SHORT_SHA="${{ steps.prep_body.outputs.short_sha }}" BODY_FILE="$RUNNER_TEMP/issue_body.md" { echo "## Latest nightly" echo echo "| | |" echo "|---|---|" echo "| **Build** | [\`${SHORT_SHA}\`](${GITHUB_SERVER_URL}/${REPO}/commit/${GITHUB_SHA}) |" echo "| **Built** | $(date -u '+%Y-%m-%d %H:%M UTC') |" echo "| **Download** | [\`nightly\` release](${GITHUB_SERVER_URL}/${REPO}/releases/tag/nightly) |" echo "| **CI run** | [build log](${GITHUB_SERVER_URL}/${REPO}/actions/runs/${GITHUB_RUN_ID}) |" if [ "$APP_BUILT" = "true" ]; then echo "| **App** | [Virtual Jaguar desktop app](#the-virtual-jaguar-app-nightly) (macOS, Windows, Linux) |" fi echo if [ "$APP_BUILT" = "true" ]; then DL="${GITHUB_SERVER_URL}/${REPO}/releases/download/nightly" RAW="https://raw.githubusercontent.com/${REPO}/master/tools" echo "### The Virtual Jaguar app (nightly)" echo echo "The standalone desktop app, built from this same commit. These links always point at the newest nightly." echo echo "| System | Download |" echo "|---|---|" echo "| macOS (Apple Silicon + Intel) | [VirtualJaguar-nightly-macos-universal.zip](${DL}/VirtualJaguar-nightly-macos-universal.zip) |" echo "| Windows x64 | [portable .zip](${DL}/VirtualJaguar-nightly-windows-x64.zip) · [installer .exe](${DL}/VirtualJaguar-nightly-windows-x64-setup.exe) |" echo "| Windows ARM64 | [portable .zip](${DL}/VirtualJaguar-nightly-windows-arm64.zip) · [installer .exe](${DL}/VirtualJaguar-nightly-windows-arm64-setup.exe) |" echo "| Linux x86_64 | [AppImage](${DL}/VirtualJaguar-nightly-linux-x86_64.AppImage) · [.deb](${DL}/virtualjaguar_nightly_amd64.deb) |" echo "| Linux aarch64 | [AppImage](${DL}/VirtualJaguar-nightly-linux-aarch64.AppImage) · [.deb](${DL}/virtualjaguar_nightly_arm64.deb) |" echo "| Linux x86_64 (Flatpak) | [.flatpak](${DL}/VirtualJaguar-nightly-linux-x86_64.flatpak) |" echo echo "Or install / update it with one line (pass \`--what both\` for the app and the RetroArch core):" echo echo '```sh' echo "curl -fsSL ${RAW}/install.sh | sh -s -- --what app --channel nightly" echo '```' echo echo '```powershell' echo "\$env:VJ_WHAT='app'; \$env:VJ_CHANNEL='nightly'; irm ${RAW}/install.ps1 | iex" echo '```' echo P="Nightly macOS builds (the app, the cores and the command-line tools) are Developer ID" P="$P signed and notarized by Apple when the release signing secrets are configured;" P="$P without them macOS needs right-click > Open the first time." P="$P Windows binaries are not code-signed yet, so SmartScreen may warn." echo "$P" echo echo "---" echo fi echo "---" echo echo "### What these are" echo P="Every push to \`develop\` rebuilds the core for all 24 supported platforms" P="$P and replaces the single" P="$P [\`nightly\`](${GITHUB_SERVER_URL}/${REPO}/releases/tag/nightly) prerelease." P="$P There is only ever one nightly — the current tip." echo "$P" echo echo "| | Stable | Nightly |" echo "|---|---|---|" echo "| Source | tagged \`vX.Y.Z\` on \`master\` | every push to \`develop\` |" echo "| Gate | full CI + release checklist | compiles on all 24 targets |" echo "| Frequency | periodic | continuous |" echo "| Use it for | playing | testing a fix before it ships |" echo P="Nightlies are **not** gated on the test suite, the acid suite, or the" P="$P screenshot regression run — those workflows report separately on the" P="$P same commit. A nightly can build cleanly and still be broken." echo "$P" echo echo "### Installing" echo echo "1. Download \`virtualjaguar_libretro-.\` for your system from the [\`nightly\` release](${GITHUB_SERVER_URL}/${REPO}/releases/tag/nightly)." P="2. **Rename it**, dropping the \`-\` part —" P="$P \`virtualjaguar_libretro-linux-x86_64.so\` becomes" P="$P \`virtualjaguar_libretro.so\`. RetroArch pairs a core with its metadata" P="$P by exact basename, so a core left under the download name loads with" P="$P no name, no version and no core options." echo "$P" echo "3. Drop it into RetroArch's \`cores/\` directory, replacing the existing \`virtualjaguar_libretro.\`." echo "4. Copy \`virtualjaguar_libretro.info\` into RetroArch's \`info/\` directory to see the nightly version string in the core list." echo echo "RetroArch's own Core Updater always serves the **stable** buildbot core. Installing a nightly by hand means the updater may overwrite it later." echo echo "### Reporting a bug in a nightly" echo echo "The core logs its exact identity at startup:" echo echo '```' echo "Virtual Jaguar vX.Y.Z " echo '```' echo P="Include that line — it is the only way to tell which nightly you ran." P="$P A RetroArch log is worth more than a description: the core has a" P="$P watchdog that names the failing subsystem (\`gpu_pc_escape\`," P="$P \`cd_seek_wedge\`, \`video_stall\`, …) at the moment it breaks." echo "$P" echo echo "### Verifying a download" echo P="Each release lists SHA-256 checksums, and every platform has a matching" P="$P \`-debug.tar.gz\` of split debug symbols. See" P="$P [SECURITY.md](${GITHUB_SERVER_URL}/${REPO}/blob/${{ github.event.repository.default_branch }}/SECURITY.md)" P="$P for verification steps and antivirus false-positive guidance." echo "$P" echo echo "---" echo echo "> Auto-updated by CI on every nightly build. Edits here are overwritten." } > "$BODY_FILE" # Find the existing tracker without the search API, which is # eventually-consistent — a miss opens a DUPLICATE issue, and then # another on every run after that, so the lookup has to be exact. # # Label first: filtering is server-side and stays exact no matter how # many issues the repo accumulates. Exact-title match is the fallback # for the very first run (before the label exists) and for the case # where the label was removed by hand. Both must miss to duplicate. LABEL=nightly-tracker gh label create "$LABEL" --repo "${REPO}" --color 0E8A16 \ --description "Auto-updated nightly build tracker" >/dev/null 2>&1 || true NUM=$(gh issue list --repo "${REPO}" --state all --label "$LABEL" \ --limit 1 --json number --jq '.[0].number // empty' 2>/dev/null || echo "") if [ -z "$NUM" ]; then NUM=$(gh issue list --repo "${REPO}" --state all --limit 500 \ --json number,title \ --jq "map(select(.title == \"${ISSUE_TITLE}\")) | .[0].number // empty") fi if [ -n "$NUM" ]; then gh issue edit "$NUM" --repo "${REPO}" --body-file "$BODY_FILE" \ --add-label "$LABEL" >/dev/null else NUM=$(gh issue create --repo "${REPO}" \ --title "${ISSUE_TITLE}" \ --body-file "$BODY_FILE" \ | grep -oE '[0-9]+$') gh issue edit "$NUM" --repo "${REPO}" --add-label "$LABEL" >/dev/null 2>&1 || \ echo "::warning::could not label tracking issue #${NUM}; title match is now the only lookup" echo "==> Created tracking issue #${NUM}" fi # Pinning has no REST endpoint; pinIssue is GraphQL-only and caps at # 3 pinned issues per repo. Re-pinning an already-pinned issue errors # harmlessly, but so does hitting the cap or lacking permission — and # a pinned issue is the whole point, so warn rather than swallow it. ID=$(gh api "repos/${REPO}/issues/${NUM}" --jq '.node_id') # shellcheck disable=SC2016 # $id is a GraphQL variable; it must NOT # be expanded by the shell — gh binds it from the -f id= argument. gh api graphql -f query='mutation($id:ID!){pinIssue(input:{issueId:$id}){issue{number}}}' \ -f id="$ID" >/dev/null 2>&1 || \ echo "::warning::could not pin issue #${NUM} (already pinned, 3-pin cap, or permission)" echo "==> Tracking issue: ${GITHUB_SERVER_URL}/${REPO}/issues/${NUM}"