name: ci # The ROM-free test run (21-testing-and-ci §CI). # # CI has no ROM and never will: data/generated/ is produced by a SHA-1 # verified import of a cartridge dump, and no ROM bytes are ever committed. # That is why the suite is tiered -- T1 (primitives), T2 (engine invariants) # and T4 (mod SDK) run against the committed tests/fixture_data dataset, so # they need no ROM, no display and no assets beyond what is in the repo. # The T3 content tier asserts Pokemon Red facts; scripts/test.sh detects # data/generated/ is absent and skips it rather than failing. # on: push: # Integration branch + release branch. PRs already run via pull_request # (any base); this list is only for post-merge push runs. branches: [dev, main] pull_request: # a force-push while CI is mid-run should cancel the stale run, not queue concurrency: group: ci-${{ github.ref }} cancel-in-progress: true permissions: contents: read jobs: ios-changes: name: detect iOS changes runs-on: ubuntu-latest outputs: changed: ${{ steps.paths.outputs.changed }} steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - id: paths env: BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} HEAD_SHA: ${{ github.sha }} run: | if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then echo "changed=true" >> "$GITHUB_OUTPUT" exit 0 fi if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(mobile/ios/|scripts/build_ios\.sh$)'; then echo "changed=true" >> "$GITHUB_OUTPUT" else echo "changed=false" >> "$GITHUB_OUTPUT" fi ios-build: name: iOS build needs: ios-changes if: needs.ios-changes.outputs.changed == 'true' runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }} outputs: ipa_url: ${{ steps.upload-ipa.outputs.artifact-url }} steps: - uses: actions/checkout@v7 - name: import signing certificate if: github.repository == 'bryanthaboi/gen1recomp' run: | keychain_path="$RUNNER_TEMP/gen1recomp-ci-signing.keychain-db" ci_dir="${POKEMON_CI_DIR:-$HOME/.config/pokemon-ci}" p12="$ci_dir/signing.p12" passfile="$ci_dir/signing.pass" [ -f "$p12" ] && [ -f "$passfile" ] || exit 1 p12pw="$(cat "$passfile")" kcpw="$(openssl rand -base64 24)" echo "::add-mask::$kcpw" security delete-keychain "$keychain_path" 2>/dev/null || true security create-keychain -p "$kcpw" "$keychain_path" security set-keychain-settings "$keychain_path" security unlock-keychain -p "$kcpw" "$keychain_path" security import "$p12" -P "$p12pw" -k "$keychain_path" -T /usr/bin/codesign -T /usr/bin/security security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$kcpw" "$keychain_path" >/dev/null existing="$(security list-keychains -d user | sed -e 's/^[[:space:]]*//' -e 's/"//g')" security list-keychains -d user -s "$keychain_path" $existing - name: install xcbeautify run: brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify - name: build iOS release env: CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }} run: | if [ "$CANONICAL_REPOSITORY" = true ]; then scripts/build_ios.sh --fetch --device --release else scripts/build_ios.sh --fetch --release fi - name: upload iOS release artifact id: upload-ipa if: github.repository == 'bryanthaboi/gen1recomp' uses: actions/upload-artifact@v7 with: name: gen1recomp-ios-ipa 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/gen1recomp-ci-signing.keychain-db" 2>/dev/null || true switch-changes: name: detect Switch changes runs-on: ubuntu-latest outputs: changed: ${{ steps.paths.outputs.changed }} steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - id: paths env: BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} HEAD_SHA: ${{ github.sha }} run: | if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then echo "changed=true" >> "$GITHUB_OUTPUT" exit 0 fi if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics)_test\.lua$|tests/engine/platform_nx)'; then echo "changed=true" >> "$GITHUB_OUTPUT" else echo "changed=false" >> "$GITHUB_OUTPUT" fi switch-selftest: name: Switch offline selftest needs: switch-changes if: needs.switch-changes.outputs.changed == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: install luajit run: sudo apt-get update && sudo apt-get install -y luajit - name: Switch offline selftest run: bash scripts/switch/selftest_build_switch.sh - name: verify_payload self-test run: bash scripts/switch/verify_payload.sh --self-test - name: Switch CI workflow content gate run: luajit tests/switch_ci_workflows_test.lua - name: Switch transfer docs content gate run: luajit tests/switch_transfer_docs_test.lua # NX runtime regressions gate this job via switch-changes; run the NX # engine suites here too so a PR touching them gets feedback on the # fork-safe ubuntu runner before the self-hosted Mac build. - name: NX engine suites (headless) run: | luajit tests/engine/assets_version_fallback_test.lua luajit tests/engine/nx_generated_guard_test.lua luajit tests/engine/nx_yellow_boot_test.lua switch-build: name: Switch fused build needs: [switch-changes, switch-selftest] if: | always() && needs.switch-changes.outputs.changed == 'true' && needs.switch-selftest.result == 'success' && github.repository == 'bryanthaboi/gen1recomp' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) runs-on: ["self-hosted", "macOS"] steps: - uses: actions/checkout@v7 - name: Build Switch fused NRO run: | set -euo pipefail VER="$(printf '%s' "$GITHUB_SHA" | cut -c1-7)" scripts/build_switch.sh --fetch --fused --version "$VER" echo "SWITCH_VER=$VER" >> "$GITHUB_ENV" - name: upload Switch NRO artifact uses: actions/upload-artifact@v7 with: name: gen1recomp-switch-nro path: | dist/switch/gen1recomp-${{ env.SWITCH_VER }}-switch.nro dist/switch/gen1recomp-${{ env.SWITCH_VER }}-switch.nro.sha256 if-no-files-found: error retention-days: 7 xbox-uwp-changes: name: detect Xbox UWP changes runs-on: ubuntu-latest outputs: changed: ${{ steps.paths.outputs.changed }} steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - id: paths env: BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} HEAD_SHA: ${{ github.sha }} run: | if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then echo "changed=true" >> "$GITHUB_OUTPUT" exit 0 fi if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(ports/uwp/|scripts/build_xbox_uwp\.sh$|scripts/xbox-uwp/|scripts/pack_love\.sh$|\.github/workflows/(ci|release)\.yml$|src/core/Platform\.lua$|src/import/(CacheFs|LauncherView|RomImporter)\.lua$|src/update/Check\.lua$|tests/engine/(platform_nx|uwp_baseroms|uwp_native_picker)_test\.lua$|tests/rom_importer_double_pick_test\.lua$)'; then echo "changed=true" >> "$GITHUB_OUTPUT" else echo "changed=false" >> "$GITHUB_OUTPUT" fi xbox-uwp-selftest: name: Xbox UWP offline selftest needs: xbox-uwp-changes if: needs.xbox-uwp-changes.outputs.changed == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Xbox UWP offline selftest run: bash scripts/xbox-uwp/selftest_build_xbox_uwp.sh - name: Build shared payload run: | scripts/pack_love.sh \ --output .bazinga/work/ci-game.love \ --listing .bazinga/work/ci-love-listing.txt \ --version 0.0.0 - name: Upload shared payload uses: actions/upload-artifact@v7 with: name: gen1recomp-xbox-uwp-payload path: .bazinga/work/ci-game.love if-no-files-found: error retention-days: 1 xbox-uwp-build: name: Xbox UWP build needs: [xbox-uwp-changes, xbox-uwp-selftest] if: | always() && needs.xbox-uwp-changes.outputs.changed == 'true' && needs.xbox-uwp-selftest.result == 'success' runs-on: windows-2022 steps: - uses: actions/checkout@v7 - name: Download shared payload uses: actions/download-artifact@v8 with: name: gen1recomp-xbox-uwp-payload path: .bazinga/work - name: Build Xbox UWP package shell: bash run: | bash scripts/build_xbox_uwp.sh \ --release \ --version 0.0.0 \ --game-love .bazinga/work/ci-game.love - name: Upload Xbox UWP package uses: actions/upload-artifact@v7 with: name: gen1recomp-xbox-uwp path: | dist/xbox-uwp/gen1recomp-0.0.0-xbox-uwp.zip dist/xbox-uwp/gen1recomp-0.0.0-xbox-uwp.zip.sha256 if-no-files-found: error retention-days: 7 linux-arm64-changes: name: detect Linux arm64 changes runs-on: ubuntu-latest outputs: changed: ${{ steps.paths.outputs.changed }} steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - id: paths env: BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} HEAD_SHA: ${{ github.sha }} run: | if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then echo "changed=true" >> "$GITHUB_OUTPUT" exit 0 fi if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_linux_arm64\.sh$|scripts/linux-arm64/|scripts/pack_love\.sh$|docs/linux-arm64-build\.md$|\.github/workflows/(ci|release)\.yml$)'; then echo "changed=true" >> "$GITHUB_OUTPUT" else echo "changed=false" >> "$GITHUB_OUTPUT" fi linux-arm64-selftest: name: Linux arm64 offline selftest needs: linux-arm64-changes if: needs.linux-arm64-changes.outputs.changed == 'true' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 # Deliberately on x86_64: everything this gate checks (pins, the # host-arch guard, the dependency exclude list, the AppRun fusion # contract) is answerable without a container or an aarch64 machine, # so the slow native job below only ever starts on a sane tree. - name: Linux arm64 offline selftest run: bash scripts/linux-arm64/selftest_build_linux_arm64.sh linux-arm64-build: name: Linux arm64 AppImage build needs: [linux-arm64-changes, linux-arm64-selftest] if: | always() && needs.linux-arm64-changes.outputs.changed == 'true' && needs.linux-arm64-selftest.result == 'success' # No fork restriction, unlike switch-build: this needs no secrets and no # self-hosted hardware, just GitHub's free arm64 runner for public repos, # so contributors get the same coverage on their own PRs. runs-on: ubuntu-24.04-arm steps: - uses: actions/checkout@v7 - name: Build the aarch64 AppImage run: | set -euo pipefail scripts/build_linux_arm64.sh --version 0.0.0 - name: Verify the AppImage is self-contained and bullseye-compatible run: | set -euo pipefail image="dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage" # --appimage-extract needs no FUSE, so this works on a runner # without /dev/fuse and still exercises the real payload. "$image" --appimage-extract >/dev/null for required in AppRun bin/love game.love lib/liblove-11.5.so; do [ -e "squashfs-root/$required" ] \ || { echo "::error::AppImage is missing $required"; exit 1; } done # Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is # applied; an unresolved soname here is a user-visible launch crash. # # This runs on a HEADLESS runner on purpose, and that is the point. # The first version of this build bundled Debian's SDL2, which # hard-links libpulse/libasound/libX11/libwayland, so it only ever # started on a full desktop -- a bare runner is what exposed it. missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \ ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \ | grep 'not found' || true)" [ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; } # Nothing may hard-link a driver, session or audio-stack library: # those must be reached through dlopen so the AppImage runs on a box # with only ALSA, only Wayland, or only KMSDRM. linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}' done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)" [ -z "$linked" ] \ || { echo "::error::these must be dlopened, not linked:"; echo "$linked"; exit 1; } # The whole point of compiling on bullseye. If a future change moves # the builder to a newer base, the glibc floor silently rises and # every user on an older distro gets "GLIBC_2.xx not found" -- catch # it here instead of in a release. floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \ | grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)" echo "highest required glibc symbol version: $floor" [ -n "$floor" ] \ || { echo "::error::found no versioned glibc symbols -- objdump read nothing"; exit 1; } highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)" [ "$highest" = "GLIBC_2.31" ] \ || { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; } - name: Upload the AppImage uses: actions/upload-artifact@v7 with: name: gen1recomp-linux-arm64 path: | dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage.sha256 if-no-files-found: error retention-days: 7 headless: name: headless suites (no ROM) runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 # LuaJIT, not lua5.4: LOVE 11.x embeds LuaJIT 2.1 and the engine is # written to Lua 5.1 semantics, so CI must run the interpreter the # game actually ships with or it would green-light 5.4-only syntax. - name: install luajit run: sudo apt-get update && sudo apt-get install -y luajit - name: install Pillow run: python3 -m pip install --upgrade pillow - name: interpreter version run: luajit -v - name: run every ROM-free tier run: ./scripts/test.sh fixture-dataset: name: fixture dataset integrity runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - run: sudo apt-get update && sudo apt-get install -y luajit - run: python3 -m pip install --upgrade pillow # the fixture PNGs are committed (they are 8x8 placeholders, not # ROM-derived); assert they are still readable 4-shade images rather # than regenerating them, so a corrupted commit is caught - name: fixture assets are valid PNGs run: | python3 - <<'PY' import glob, sys from PIL import Image paths = sorted(glob.glob("tests/fixture_data/assets/*.png")) if not paths: sys.exit("no fixture assets found") for path in paths: with Image.open(path) as image: image.load() print(f"ok {path} {image.size} {image.mode}") print(f"\n{len(paths)} fixture assets valid") PY # the fingerprint golden is the parity tripwire; prove it still # matches the dataset on a clean checkout - name: fingerprint gate run: luajit tests/engine/gate_fingerprint.lua - name: parity-guarantee meta-test run: luajit tests/engine/gate_meta_coverage.lua # Only the differ is under test here, and the job is named for that. The # capture half of the golden pipeline does not exist: a POKEPORT_DRIVER # chunk runs after main.lua has already booted the game, and # src/core/Data.lua has no POKEPORT_DATA_DIR branch, so no LOVE process # can be pointed at tests/fixture_data. There is deliberately no step # here that runs scripts/test.sh with WITH_SHOTS: it would have nothing # to capture and nothing to diff, and a job that green-lights on skipped # work is worse than an absent one. shot-differ: name: screenshot differ (capture not yet wired) runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - run: python3 -m pip install --upgrade pillow # 21-testing-and-ci §"Testing & acceptance criteria": compare_shots # flags a deliberately corrupted golden and passes the clean one. # That is the half of the pipeline this repo can actually prove. - name: compare_shots self-test run: | set -e python3 - <<'PY' import os from PIL import Image os.makedirs("/tmp/g", exist_ok=True) os.makedirs("/tmp/s", exist_ok=True) base = Image.new("RGB", (160, 144), (255, 255, 255)) for x in range(0, 160, 8): for y in range(0, 144, 8): base.putpixel((x, y), (0, 0, 0)) base.save("/tmp/g/clean.png") base.save("/tmp/s/clean.png") base.save("/tmp/g/broken.png") bad = base.copy() for x in range(40, 60): for y in range(40, 60): bad.putpixel((x, y), (255, 0, 0)) bad.save("/tmp/s/broken.png") PY if python3 tools/compare_shots.py /tmp/g /tmp/s; then echo "compare_shots passed a corrupted golden -- differ is broken" exit 1 fi rm /tmp/g/broken.png /tmp/s/broken.png python3 tools/compare_shots.py /tmp/g /tmp/s # an empty golden directory must not read as success - name: differ refuses to pass vacuously run: | set -e mkdir -p /tmp/empty-goldens /tmp/empty-shots if python3 tools/compare_shots.py /tmp/empty-goldens /tmp/empty-shots; then echo "compare_shots passed with no goldens" exit 1 fi lint: name: mod lint (no ROM-derived content) runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 # the MK305 dump check key-diffs shipped tables through luajit, and # modkit treats a missing interpreter as a fatal MK100 -- without # this install the gate would fail instead of failing open - run: sudo apt-get update && sudo apt-get install -y luajit - run: python3 -m pip install --upgrade pillow # constraint 1, enforced automatically: a committed mod that ships # ROM-derived bytes fails the build. `lint` is the no-ROM-content # check; `validate` is deliberately not run here because it resolves # a mod against the fixture dataset, and a Red-content mod such as # example_mew_starter legitimately does not resolve against it. - name: lint every committed mod run: | set -e found=0 for mod in mods/*/; do [ -f "$mod/manifest.json" ] || continue found=1 echo "== $mod" python3 tools/modkit.py lint "${mod%/}" done if [ "$found" = "0" ]; then echo "no committed mods to lint" fi