diff --git a/.github/workflows/screenshot.yml b/.github/workflows/screenshot.yml new file mode 100644 index 0000000..fe5e138 --- /dev/null +++ b/.github/workflows/screenshot.yml @@ -0,0 +1,320 @@ +name: screenshot test + +on: + pull_request: + push: + branches: [main, master] + +jobs: + screenshot: + runs-on: ubuntu-24.04 + continue-on-error: true # screenshots are informational, never block a PR + permissions: + contents: write + pull-requests: write + issues: write + + steps: + - name: checkout repository + uses: actions/checkout@v6 + + - name: validate gradle wrapper + uses: gradle/actions/wrapper-validation@v6 + + - name: setup jdk + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'microsoft' + + - name: make gradle wrapper executable + run: chmod +x ./gradlew + + - name: build mod + run: ./gradlew build + + - name: install display tools + run: | + sudo apt-get update -qq + # Mesa software GL + full LWJGL/GLFW X11 + audio deps + sudo apt-get install -y \ + xvfb scrot fluxbox x11-utils xdotool imagemagick \ + libgl1-mesa-dri libgles2-mesa mesa-vulkan-drivers \ + libegl1-mesa libopengl0 \ + libx11-6 libxext6 libxrender1 libxrandr2 \ + libxcursor1 libxinerama1 libxi6 libxxf86vm1 \ + libopenal1 pulseaudio-utils + + - name: start virtual display + run: | + Xvfb :99 -screen 0 1920x1080x24 +extension GLX +render -ac & + DISPLAY=:99 fluxbox &>/dev/null & + sleep 2 + echo "DISPLAY=:99" >> $GITHUB_ENV + # Mesa software renderer — no GPU in CI + echo "LIBGL_ALWAYS_SOFTWARE=1" >> $GITHUB_ENV + echo "GALLIUM_DRIVER=llvmpipe" >> $GITHUB_ENV + # Disable audio so OpenAL doesn't crash LWJGL on a headless runner + echo "ALSOFT_DRIVERS=null" >> $GITHUB_ENV + echo "SDL_AUDIODRIVER=dummy" >> $GITHUB_ENV + + - name: prepare run directory + run: | + mkdir -p run/saves + + # Accept EULA so the server can generate a world + echo "eula=true" > run/eula.txt + + # Fast flat world, offline mode + cat > run/server.properties << 'EOF' + level-name=world + gamemode=creative + generate-structures=false + level-type=flat + spawn-protection=0 + view-distance=4 + simulation-distance=4 + online-mode=false + max-tick-time=60000 + EOF + + # Low-quality client settings so Minecraft loads faster in CI + cat > run/options.txt << 'EOF' + autoJump:false + renderDistance:4 + simulationDistance:4 + graphicsMode:0 + ao:false + bobView:false + guiScale:2 + fullscreen:false + EOF + + - name: generate world via server + continue-on-error: true + run: | + set +e # process management shouldn't abort the step + ./gradlew runServer --args="--nogui" > /tmp/server.log 2>&1 & + SERVER_PID=$! + + echo "Waiting for server to finish world generation..." + TIMEOUT=180 + ELAPSED=0 + until grep -q "Done" /tmp/server.log 2>/dev/null || [ "$ELAPSED" -ge "$TIMEOUT" ]; do + sleep 3 + ELAPSED=$((ELAPSED + 3)) + done + + if grep -q "Done" /tmp/server.log 2>/dev/null; then + echo "World generated after ${ELAPSED}s" + else + echo "Server timed out — continuing anyway" + tail -30 /tmp/server.log + fi + + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + + # Copy server world to client saves + if [ -d "run/world" ]; then + cp -r run/world "run/saves/CI_World" + echo "Copied run/world -> run/saves/CI_World" + else + echo "WARNING: server world not found, client will show main menu" + ls run/ || true + fi + + - name: launch minecraft client + if: always() + run: | + # Pass all rendering/audio env vars explicitly so they reach the + # forked JVM even if the Gradle daemon was started earlier + DISPLAY=:99 \ + LIBGL_ALWAYS_SOFTWARE=1 \ + GALLIUM_DRIVER=llvmpipe \ + ALSOFT_DRIVERS=null \ + SDL_AUDIODRIVER=dummy \ + ./gradlew runClient \ + --args="--username CIBot --uuid 00000000-0000-0000-0000-000000000001 --quickPlaySingleplayer CI_World" \ + > /tmp/client.log 2>&1 & + CLIENT_PID=$! + echo "CLIENT_PID=$CLIENT_PID" >> "$GITHUB_ENV" + + # Give the JVM a moment, then check for early crash + sleep 10 + if ! kill -0 "$CLIENT_PID" 2>/dev/null; then + echo "WARNING: Minecraft process exited within 10s — showing last log lines:" + tail -100 /tmp/client.log + echo "Taking screenshot of whatever is on screen for diagnostics" + else + # Wait up to 3 minutes for a Minecraft window to actually appear + echo "Client running (PID $CLIENT_PID) — waiting for Minecraft window..." + TIMEOUT=180 + ELAPSED=0 + until xdotool search --name "Minecraft" 2>/dev/null | grep -q . || [ "$ELAPSED" -ge "$TIMEOUT" ]; do + sleep 3 + ELAPSED=$((ELAPSED + 3)) + done + + if xdotool search --name "Minecraft" 2>/dev/null | grep -q .; then + echo "Minecraft window appeared after ${ELAPSED}s — waiting 30s for world to load" + sleep 30 + else + echo "WARNING: No Minecraft window after ${TIMEOUT}s — taking diagnostic screenshot" + tail -30 /tmp/client.log + fi + fi + + - name: take screenshot + if: always() + run: | + # Verify the display is actually reachable before attempting capture + if ! DISPLAY=:99 xdpyinfo > /dev/null 2>&1; then + echo "ERROR: Display :99 is not available — no screenshot possible" + exit 0 + fi + echo "Display :99 is up" + + # ImageMagick import is more reliable than scrot on headless X11; + # fall back to scrot if import fails for any reason + DISPLAY=:99 import -window root /tmp/mc-screenshot.png 2>&1 \ + || DISPLAY=:99 scrot /tmp/mc-screenshot.png 2>&1 \ + || echo "WARNING: all screenshot methods failed" + + ls -lh /tmp/mc-screenshot.png 2>/dev/null || echo "No screenshot file produced" + + - name: stop minecraft + if: always() + run: | + [ -n "$CLIENT_PID" ] && kill "$CLIENT_PID" 2>/dev/null || true + pkill -f 'net.minecraft' 2>/dev/null || true + + - name: upload screenshot artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: minecraft-screenshot + path: /tmp/mc-screenshot.png + if-no-files-found: warn + + - name: upload logs artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: minecraft-logs + path: | + /tmp/server.log + /tmp/client.log + run/logs/ + if-no-files-found: ignore + + - name: publish screenshot to screenshots branch + if: always() + run: | + if [ ! -f /tmp/mc-screenshot.png ]; then + echo "No screenshot file found, skipping upload" + exit 0 + fi + + FILENAME="screenshots/${{ github.sha }}.png" + COMMIT_MSG="Screenshot for ${{ github.sha }}" + + # Create the screenshots branch if it doesn't exist + BRANCH_EXISTS=$(curl -sf \ + -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + "https://api.github.com/repos/${{ github.repository }}/branches/screenshots" \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('name',''))" 2>/dev/null || echo "") + + if [ -z "$BRANCH_EXISTS" ]; then + echo "Creating screenshots branch..." + HEAD_SHA=$(git rev-parse HEAD) + curl -sf -X POST \ + -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + -H "Content-Type: application/json" \ + "https://api.github.com/repos/${{ github.repository }}/git/refs" \ + -d "{\"ref\":\"refs/heads/screenshots\",\"sha\":\"${HEAD_SHA}\"}" > /dev/null \ + && echo "Branch created" || echo "Branch creation failed (may already exist)" + fi + + # Get current file SHA if it already exists (required for updates) + EXISTING_SHA=$(curl -sf \ + -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + "https://api.github.com/repos/${{ github.repository }}/contents/${FILENAME}?ref=screenshots" \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('sha',''))" 2>/dev/null || echo "") + + # Write Python helper with single-quoted heredoc (no shell substitution), + # then pass variables as argv so there are no quoting/expansion issues. + cat > /tmp/make_body.py << 'PYEOF' + import json, base64, sys + img_path, commit_msg, existing_sha = sys.argv[1], sys.argv[2], sys.argv[3] + with open(img_path, "rb") as f: + content = base64.b64encode(f.read()).decode() + body = {"message": commit_msg, "content": content, "branch": "screenshots"} + if existing_sha: + body["sha"] = existing_sha + with open("/tmp/upload-body.json", "w") as f: + json.dump(body, f) + print(f"Upload body: {len(content)} base64 chars") + PYEOF + python3 /tmp/make_body.py "/tmp/mc-screenshot.png" "$COMMIT_MSG" "$EXISTING_SHA" \ + || { echo "WARNING: failed to build upload body"; exit 0; } + + # Upload to GitHub Contents API + HTTP_STATUS=$(curl -sf -o /tmp/upload-response.json -w "%{http_code}" \ + -X PUT \ + -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + -H "Content-Type: application/json" \ + -d @/tmp/upload-body.json \ + "https://api.github.com/repos/${{ github.repository }}/contents/${FILENAME}" \ + || echo "000") + + if [ "$HTTP_STATUS" -ge 200 ] && [ "$HTTP_STATUS" -lt 300 ]; then + echo "Screenshot uploaded (HTTP $HTTP_STATUS)" + else + echo "Upload failed (HTTP $HTTP_STATUS)" + cat /tmp/upload-response.json || true + fi + + - name: post screenshot comment + if: always() + uses: actions/github-script@v7 + with: + script: | + const sha = context.sha; + const repo = `${context.repo.owner}/${context.repo.repo}`; + const runId = context.runId; + + const screenshotUrl = + `https://raw.githubusercontent.com/${repo}/screenshots/screenshots/${sha}.png`; + const runUrl = + `https://github.com/${repo}/actions/runs/${runId}`; + + const body = [ + '## Minecraft Screenshot', + '', + `![BetterHUD in Minecraft](${screenshotUrl})`, + '', + `> Commit \`${sha.slice(0, 7)}\` — [View CI Run & Artifacts](${runUrl})`, + ].join('\n'); + + try { + if (context.eventName === 'pull_request') { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body, + }); + console.log('Posted comment on PR #' + context.payload.pull_request.number); + } else { + await github.rest.repos.createCommitComment({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: sha, + body, + }); + console.log('Posted comment on commit ' + sha.slice(0, 7)); + } + } catch (err) { + core.warning('Failed to post comment: ' + err.message); + }